Skip to content

Commit abf5f6d

Browse files
feat: handle single key secret stores (#812)
Signed-off-by: nishantmunjal7 <[email protected]>
1 parent 5f83d88 commit abf5f6d

File tree

2 files changed

+415
-32
lines changed

2 files changed

+415
-32
lines changed

application_sdk/services/secretstore.py

Lines changed: 146 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
"""Unified secret store service for the application."""
1+
"""Unified secret store service for the application.
2+
3+
Logic summary:
4+
5+
1. Fetch credential config from state store.
6+
7+
2. Determine mode: Multi-key if (credentialSource == 'direct' OR secret-path is defined), Single-key otherwise.
8+
9+
3. Fetch secrets accordingly: Multi-key uses secret_path if credentialSource == "agent" else credential_guid, Single-key fetches each field individually.
10+
11+
4. Merge & resolve secrets.
12+
"""
213

314
import collections.abc
415
import copy
516
import json
617
import uuid
18+
from enum import Enum
719
from typing import Any, Dict
820

921
from dapr.clients import DaprClient
@@ -23,8 +35,22 @@
2335
logger = get_logger(__name__)
2436

2537

38+
class CredentialSource(Enum):
39+
"""Enumeration of credential source types."""
40+
41+
DIRECT = "direct"
42+
AGENT = "agent"
43+
44+
45+
class SecretMode(Enum):
46+
"""Enumeration of secret retrieval modes."""
47+
48+
MULTI_KEY = "multi-key"
49+
SINGLE_KEY = "single-key"
50+
51+
2652
class SecretStore:
27-
"""Unified secret store service for handling secret management."""
53+
"""Unified secret store service for handling secret management across providers."""
2854

2955
@classmethod
3056
async def get_credentials(cls, credential_guid: str) -> Dict[str, Any]:
@@ -33,6 +59,8 @@ async def get_credentials(cls, credential_guid: str) -> Dict[str, Any]:
3359
This method retrieves credential configuration from the state store and resolves
3460
any secret references by fetching actual values from the secret store.
3561
62+
Supports Multi-key mode (direct / has secret-path) and Single-key mode (no secret-path, non-direct).
63+
3664
Args:
3765
credential_guid (str): The unique GUID of the credential configuration to resolve.
3866
@@ -48,7 +76,6 @@ async def get_credentials(cls, credential_guid: str) -> Dict[str, Any]:
4876
>>> creds = await SecretStore.get_credentials("db-cred-abc123")
4977
>>> print(f"Connecting to {creds['host']}:{creds['port']}")
5078
>>> # Password is automatically resolved from secret store
51-
5279
>>> # Handle resolution errors
5380
>>> try:
5481
... creds = await SecretStore.get_credentials("invalid-guid")
@@ -62,13 +89,44 @@ async def _get_credentials_async(credential_guid: str) -> Dict[str, Any]:
6289
credential_guid, StateType.CREDENTIALS
6390
)
6491

65-
# Fetch secret data from secret store
66-
secret_key = credential_config.get("secret-path", credential_guid)
67-
secret_data = SecretStore.get_secret(secret_key=secret_key)
92+
credential_source_str = credential_config.get(
93+
"credentialSource", CredentialSource.DIRECT.value
94+
)
95+
try:
96+
credential_source = CredentialSource(credential_source_str)
97+
except ValueError:
98+
credential_source = CredentialSource.DIRECT
99+
secret_path = credential_config.get("secret-path")
100+
101+
secret_data: Dict[str, Any] = {}
102+
103+
# Decide mode
104+
if credential_source == CredentialSource.DIRECT or secret_path:
105+
mode = SecretMode.MULTI_KEY
106+
else:
107+
mode = SecretMode.SINGLE_KEY
108+
109+
# Multi-key secret fetch (direct or has secret-path)
110+
if mode == SecretMode.MULTI_KEY:
111+
key_to_fetch = (
112+
secret_path
113+
if credential_source == CredentialSource.AGENT
114+
else credential_guid
115+
)
116+
try:
117+
logger.debug(f"Fetching multi-key secret from '{key_to_fetch}'")
118+
secret_data = cls.get_secret(secret_key=key_to_fetch)
119+
except Exception as e:
120+
logger.warning(
121+
f"Failed to fetch secret bundle '{key_to_fetch}': {e}"
122+
)
123+
124+
# Single-key mode → per-field secret lookup
125+
else:
126+
secret_data = cls._fetch_single_key_secrets(credential_config)
68127

69-
# Resolve credentials
70-
credential_source = credential_config.get("credentialSource", "direct")
71-
if credential_source == "direct":
128+
# Merge or resolve references
129+
if credential_source == CredentialSource.DIRECT:
72130
credential_config.update(secret_data)
73131
return credential_config
74132
else:
@@ -78,12 +136,59 @@ async def _get_credentials_async(credential_guid: str) -> Dict[str, Any]:
78136
# Run async operations directly
79137
return await _get_credentials_async(credential_guid)
80138
except Exception as e:
81-
logger.error(f"Error resolving credentials: {str(e)}")
139+
logger.error(f"Error resolving credentials for {credential_guid}: {str(e)}")
82140
raise CommonError(
83141
CommonError.CREDENTIALS_RESOLUTION_ERROR,
84142
f"Failed to resolve credentials: {str(e)}",
85143
)
86144

145+
# Secret resolution helpers
146+
147+
@classmethod
148+
def _fetch_single_key_secrets(
149+
cls, credential_config: Dict[str, Any]
150+
) -> Dict[str, Any]:
151+
"""Fetch secrets in single-key mode by looking up each field individually.
152+
153+
Args:
154+
credential_config: The credential configuration dictionary
155+
156+
Returns:
157+
Dictionary containing collected secret values
158+
"""
159+
logger.debug("Single-key mode: fetching secrets per field")
160+
collected = {}
161+
for field, value in credential_config.items():
162+
if isinstance(value, str):
163+
try:
164+
single_secret = cls.get_secret(value)
165+
if single_secret:
166+
for k, v in single_secret.items():
167+
# Only filter out None and empty strings, not all falsy values.
168+
# This preserves valid secret values like False, 0, 0.0 which are
169+
# legitimate secret values that should not be excluded.
170+
if v is None or v == "":
171+
continue
172+
collected[k] = v
173+
except Exception as e:
174+
logger.debug(f"Skipping '{field}' → '{value}' ({e})")
175+
elif field == "extra" and isinstance(value, dict):
176+
# Recursively process string values in the extra dictionary
177+
for extra_key, extra_value in value.items():
178+
if isinstance(extra_value, str):
179+
try:
180+
single_secret = cls.get_secret(extra_value)
181+
if single_secret:
182+
for k, v in single_secret.items():
183+
if v is None or v == "":
184+
continue
185+
collected[k] = v
186+
except Exception as e:
187+
logger.debug(
188+
f"Skipping 'extra.{extra_key}' → '{extra_value}' ({e})"
189+
)
190+
return collected
191+
87192
@classmethod
88193
def resolve_credentials(
89194
cls, credential_config: Dict[str, Any], secret_data: Dict[str, Any]
@@ -106,7 +211,6 @@ def resolve_credentials(
106211
>>> secrets = {"db_password_key": "actual_secret_password"}
107212
>>> resolved = SecretStore.resolve_credentials(config, secrets)
108213
>>> print(resolved) # {"host": "db.example.com", "password": "actual_secret_password"}
109-
110214
>>> # Resolution with nested 'extra' fields
111215
>>> config = {
112216
... "host": "db.example.com",
@@ -125,11 +229,8 @@ def resolve_credentials(
125229
# Apply the same substitution to the 'extra' dictionary if it exists
126230
if "extra" in credentials and isinstance(credentials["extra"], dict):
127231
for key, value in list(credentials["extra"].items()):
128-
if isinstance(value, str):
129-
if value in secret_data:
130-
credentials["extra"][key] = secret_data[value]
131-
elif value in secret_data.get("extra", {}):
132-
credentials["extra"][key] = secret_data["extra"][value]
232+
if isinstance(value, str) and value in secret_data:
233+
credentials["extra"][key] = secret_data[value]
133234

134235
return credentials
135236

@@ -143,7 +244,7 @@ def get_deployment_secret(cls) -> Dict[str, Any]:
143244
144245
Returns:
145246
Dict[str, Any]: Deployment configuration data, or empty dict if
146-
component is unavailable or fetch fails.
247+
component is unavailable or fetch fails.
147248
148249
Examples:
149250
>>> # Get deployment configuration
@@ -153,15 +254,14 @@ def get_deployment_secret(cls) -> Dict[str, Any]:
153254
... print(f"Region: {config.get('region')}")
154255
>>> else:
155256
... print("No deployment configuration available")
156-
157257
>>> # Use in application initialization
158258
>>> deployment_config = SecretStore.get_deployment_secret()
159259
>>> if deployment_config.get('debug_mode'):
160260
... logging.getLogger().setLevel(logging.DEBUG)
161261
"""
162262
if not is_component_registered(DEPLOYMENT_SECRET_STORE_NAME):
163263
logger.warning(
164-
f"Deployment secret store component '{DEPLOYMENT_SECRET_STORE_NAME}' is not registered"
264+
f"Deployment secret store component '{DEPLOYMENT_SECRET_STORE_NAME}' not registered."
165265
)
166266
return {}
167267

@@ -182,7 +282,7 @@ def get_secret(
182282
183283
Args:
184284
secret_key (str): Key of the secret to fetch from the secret store.
185-
component_name (str, optional): Name of the Dapr component to fetch from.
285+
component_name (str): Name of the Dapr component to fetch from.
186286
Defaults to SECRET_STORE_NAME.
187287
188288
Returns:
@@ -199,7 +299,6 @@ def get_secret(
199299
>>> # Get database credentials
200300
>>> db_secret = SecretStore.get_secret("database-credentials")
201301
>>> print(f"Host: {db_secret.get('host')}")
202-
203302
>>> # Get from specific component
204303
>>> api_secret = SecretStore.get_secret(
205304
... "api-keys",
@@ -217,7 +316,7 @@ def get_secret(
217316
return cls._process_secret_data(dapr_secret_object.secret)
218317
except Exception as e:
219318
logger.error(
220-
f"Failed to fetch secret using component {component_name}: {str(e)}"
319+
f"Failed to fetch secret using component '{component_name}': {str(e)}"
221320
)
222321
raise
223322

@@ -235,17 +334,34 @@ def _process_secret_data(cls, secret_data: Any) -> Dict[str, Any]:
235334
if isinstance(secret_data, collections.abc.Mapping):
236335
secret_data = dict(secret_data)
237336

238-
# If the dict has a single key and its value is a JSON string, parse it
239-
if len(secret_data) == 1 and isinstance(next(iter(secret_data.values())), str):
337+
# Handle single-key secrets gracefully
338+
if len(secret_data) == 1:
339+
k, v = next(iter(secret_data.items()))
340+
return cls._handle_single_key_secret(k, v)
341+
342+
return secret_data
343+
344+
# Utility helpers
345+
346+
@classmethod
347+
def _handle_single_key_secret(cls, key: str, value: Any) -> Dict[str, Any]:
348+
"""Handle single-key secret by attempting to parse JSON value.
349+
350+
Args:
351+
key: The secret key.
352+
value: The secret value (may be a JSON string).
353+
354+
Returns:
355+
Dictionary with parsed JSON if value is valid JSON dict, otherwise {key: value}.
356+
"""
357+
if isinstance(value, str):
240358
try:
241-
parsed = json.loads(next(iter(secret_data.values())))
359+
parsed = json.loads(value)
242360
if isinstance(parsed, dict):
243-
secret_data = parsed
244-
except Exception as e:
245-
logger.error(f"Failed to parse secret data: {e}")
361+
return parsed
362+
except Exception:
246363
pass
247-
248-
return secret_data
364+
return {key: value}
249365

250366
@classmethod
251367
def apply_secret_values(
@@ -275,7 +391,6 @@ def apply_secret_values(
275391
... "db_password": "secure_db_password"
276392
... }
277393
>>> resolved = SecretStore.apply_secret_values(source, secrets)
278-
279394
>>> # With nested extra fields
280395
>>> source = {
281396
... "host": "api.example.com",
@@ -328,7 +443,6 @@ async def save_secret(cls, config: Dict[str, Any]) -> str:
328443
... }
329444
>>> guid = await SecretStore.save_secret(config)
330445
>>> print(f"Stored credentials with GUID: {guid}")
331-
332446
>>> # Later retrieve these credentials
333447
>>> retrieved = await SecretStore.get_credentials(guid)
334448
"""

0 commit comments

Comments
 (0)