Skip to content

Commit d712765

Browse files
authored
Merge pull request #8 from phasehq/feat--app-id
Feat: added app-id & service account support
2 parents 41d1785 + 78c40c2 commit d712765

File tree

7 files changed

+154
-78
lines changed

7 files changed

+154
-78
lines changed

β€ŽREADME.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -111,21 +111,6 @@ result = phase.delete_secret(delete_options)
111111
print(f"Delete result: {result}")
112112
```
113113

114-
### Resolve Secret References
115-
116-
Resolve references in secret values:
117-
118-
```python
119-
get_options = GetAllSecretsOptions(
120-
env_name="Development",
121-
app_name="Your App Name"
122-
)
123-
secrets = phase.get_all_secrets(get_options)
124-
resolved_secrets = phase.resolve_references(secrets, "Development", "Your App Name")
125-
for secret in resolved_secrets:
126-
print(f"Key: {secret.key}, Resolved Value: {secret.value}")
127-
```
128-
129114
## Error Handling
130115

131116
The SDK methods may raise exceptions for various error conditions. It's recommended to wrap SDK calls in try-except blocks to handle potential errors:

β€Žpyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "phase_dev"
7-
version = "2.0.1"
7+
version = "2.1.0"
88
description = "Python SDK for Phase secrets manager"
99
readme = "README.md"
1010
requires-python = ">=3.10"

β€Žsrc/phase/phase.py

Lines changed: 115 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,68 @@
66
@dataclass
77
class GetSecretOptions:
88
env_name: str
9-
app_name: str
9+
app_name: Optional[str] = None
10+
app_id: Optional[str] = None
1011
key_to_find: Optional[str] = None
1112
tag: Optional[str] = None
1213
secret_path: str = "/"
1314

15+
def __post_init__(self):
16+
if not self.app_name and not self.app_id:
17+
raise ValueError("Either app_name or app_id must be provided")
18+
1419
@dataclass
1520
class GetAllSecretsOptions:
1621
env_name: str
17-
app_name: str
22+
app_name: Optional[str] = None
23+
app_id: Optional[str] = None
1824
tag: Optional[str] = None
1925
secret_path: str = "/"
2026

27+
def __post_init__(self):
28+
if not self.app_name and not self.app_id:
29+
raise ValueError("Either app_name or app_id must be provided")
30+
2131
@dataclass
2232
class CreateSecretsOptions:
2333
env_name: str
24-
app_name: str
2534
key_value_pairs: List[Dict[str, str]]
35+
app_name: Optional[str] = None
36+
app_id: Optional[str] = None
2637
secret_path: str = "/"
2738

39+
def __post_init__(self):
40+
if not self.app_name and not self.app_id:
41+
raise ValueError("Either app_name or app_id must be provided")
42+
2843
@dataclass
2944
class UpdateSecretOptions:
3045
env_name: str
31-
app_name: str
3246
key: str
3347
value: Optional[str] = None
48+
app_name: Optional[str] = None
49+
app_id: Optional[str] = None
3450
secret_path: str = "/"
3551
destination_path: Optional[str] = None
3652
override: bool = False
3753
toggle_override: bool = False
3854

55+
def __post_init__(self):
56+
if not self.app_name and not self.app_id:
57+
raise ValueError("Either app_name or app_id must be provided")
58+
3959
@dataclass
4060
class DeleteSecretOptions:
4161
env_name: str
42-
app_name: str
4362
key_to_delete: str
63+
app_name: Optional[str] = None
64+
app_id: Optional[str] = None
4465
secret_path: str = "/"
4566

67+
def __post_init__(self):
68+
if not self.app_name and not self.app_id:
69+
raise ValueError("Either app_name or app_id must be provided")
70+
4671
@dataclass
4772
class PhaseSecret:
4873
key: str
@@ -51,49 +76,127 @@ class PhaseSecret:
5176
path: str = "/"
5277
tags: List[str] = field(default_factory=list)
5378
overridden: bool = False
79+
application: Optional[str] = None
80+
environment: Optional[str] = None
5481

5582
class Phase:
5683
def __init__(self, init=True, pss=None, host=None):
5784
self._phase_io = PhaseIO(init=init, pss=pss, host=host)
5885

86+
def _resolve_secret_values(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> List[PhaseSecret]:
87+
"""
88+
Utility function to resolve secret references within secret values.
89+
90+
Args:
91+
secrets (List[PhaseSecret]): List of secrets to process
92+
env_name (str): Environment name for secret resolution
93+
app_name (str): Application name for secret resolution
94+
95+
Returns:
96+
List[PhaseSecret]: List of secrets with resolved values
97+
"""
98+
# Convert PhaseSecret objects to dict format expected by resolve_all_secrets
99+
all_secrets = [
100+
{
101+
'environment': secret.environment or env_name,
102+
'path': secret.path,
103+
'key': secret.key,
104+
'value': secret.value
105+
}
106+
for secret in secrets
107+
]
108+
109+
# Create new list of secrets with resolved values
110+
resolved_secrets = []
111+
for secret in secrets:
112+
resolved_value = resolve_all_secrets(
113+
value=secret.value,
114+
all_secrets=all_secrets,
115+
phase=self._phase_io,
116+
current_application_name=secret.application or app_name,
117+
current_env_name=secret.environment or env_name
118+
)
119+
120+
resolved_secrets.append(PhaseSecret(
121+
key=secret.key,
122+
value=resolved_value,
123+
comment=secret.comment,
124+
path=secret.path,
125+
tags=secret.tags,
126+
overridden=secret.overridden,
127+
application=secret.application,
128+
environment=secret.environment
129+
))
130+
131+
return resolved_secrets
132+
59133
def get_secret(self, options: GetSecretOptions) -> Optional[PhaseSecret]:
60134
secrets = self._phase_io.get(
61135
env_name=options.env_name,
62136
keys=[options.key_to_find] if options.key_to_find else None,
63137
app_name=options.app_name,
138+
app_id=options.app_id,
64139
tag=options.tag,
65140
path=options.secret_path
66141
)
67142
if secrets:
68143
secret = secrets[0]
69-
return PhaseSecret(
144+
phase_secret = PhaseSecret(
70145
key=secret['key'],
71146
value=secret['value'],
72147
comment=secret.get('comment', ''),
73148
path=secret.get('path', '/'),
74149
tags=secret.get('tags', []),
75-
overridden=secret.get('overridden', False)
150+
overridden=secret.get('overridden', False),
151+
application=secret.get('application'),
152+
environment=secret.get('environment')
153+
)
154+
155+
# Resolve any secret references in the value
156+
resolved_secrets = self._resolve_secret_values(
157+
[phase_secret],
158+
options.env_name,
159+
secret.get('application', options.app_name)
76160
)
161+
162+
return resolved_secrets[0] if resolved_secrets else None
77163
return None
78164

79165
def get_all_secrets(self, options: GetAllSecretsOptions) -> List[PhaseSecret]:
80166
secrets = self._phase_io.get(
81167
env_name=options.env_name,
82168
app_name=options.app_name,
169+
app_id=options.app_id,
83170
tag=options.tag,
84171
path=options.secret_path
85172
)
86-
return [
173+
174+
if not secrets:
175+
return []
176+
177+
# Get the application name from the first secret
178+
app_name = secrets[0].get('application', options.app_name)
179+
180+
phase_secrets = [
87181
PhaseSecret(
88182
key=secret['key'],
89183
value=secret['value'],
90184
comment=secret.get('comment', ''),
91185
path=secret.get('path', '/'),
92186
tags=secret.get('tags', []),
93-
overridden=secret.get('overridden', False)
187+
overridden=secret.get('overridden', False),
188+
application=secret.get('application'),
189+
environment=secret.get('environment')
94190
)
95191
for secret in secrets
96192
]
193+
194+
# Resolve any secret references in the values
195+
return self._resolve_secret_values(
196+
phase_secrets,
197+
options.env_name,
198+
app_name
199+
)
97200

98201
def create_secrets(self, options: CreateSecretsOptions) -> str:
99202
# Convert the list of dictionaries to a list of tuples
@@ -103,6 +206,7 @@ def create_secrets(self, options: CreateSecretsOptions) -> str:
103206
key_value_pairs=key_value_tuples,
104207
env_name=options.env_name,
105208
app_name=options.app_name,
209+
app_id=options.app_id,
106210
path=options.secret_path
107211
)
108212
return "Success" if response.status_code == 200 else f"Error: {response.status_code}"
@@ -113,6 +217,7 @@ def update_secret(self, options: UpdateSecretOptions) -> str:
113217
key=options.key,
114218
value=options.value,
115219
app_name=options.app_name,
220+
app_id=options.app_id,
116221
source_path=options.secret_path,
117222
destination_path=options.destination_path,
118223
override=options.override,
@@ -124,29 +229,6 @@ def delete_secret(self, options: DeleteSecretOptions) -> List[str]:
124229
env_name=options.env_name,
125230
keys_to_delete=[options.key_to_delete],
126231
app_name=options.app_name,
232+
app_id=options.app_id,
127233
path=options.secret_path
128234
)
129-
130-
def resolve_references(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> List[PhaseSecret]:
131-
all_secrets = [
132-
{
133-
'environment': env_name,
134-
'application': app_name,
135-
'key': secret.key,
136-
'value': secret.value,
137-
'path': secret.path
138-
}
139-
for secret in secrets
140-
]
141-
142-
for secret in secrets:
143-
resolved_value = resolve_all_secrets(
144-
secret.value,
145-
all_secrets,
146-
self._phase_io,
147-
app_name,
148-
env_name
149-
)
150-
secret.value = resolved_value
151-
152-
return secrets

β€Žsrc/phase/utils/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import re
33

4-
__version__ = "2.0.1"
4+
__version__ = "2.1.0"
55
__ph_version__ = "v1"
66

77

β€Žsrc/phase/utils/misc.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,44 +42,43 @@ def get_default_user_token() -> str:
4242
raise ValueError("Default user not found in the config file.")
4343

4444

45-
def phase_get_context(user_data, app_name=None, env_name=None):
45+
def phase_get_context(user_data, app_name=None, env_name=None, app_id=None):
4646
"""
4747
Get the context (ID, name, and publicKey) for a specified application and environment or the default application and environment.
4848
4949
Parameters:
5050
- user_data (dict): The user data from the API response.
5151
- app_name (str, optional): The name (or partial name) of the desired application.
5252
- env_name (str, optional): The name (or partial name) of the desired environment.
53+
- app_id (str, optional): The explicit application ID to use. Takes precedence over app_name if both are provided.
5354
5455
Returns:
5556
- tuple: A tuple containing the application's name, application's ID, environment's name, environment's ID, and publicKey.
5657
5758
Raises:
5859
- ValueError: If no matching application or environment is found.
5960
"""
60-
61-
# 2. If env_name isn't explicitly provided, use the default
61+
# 1. Set default environment name
6262
default_env_name = "Development"
63-
app_id = None
6463
env_name = env_name or default_env_name
6564

66-
# 3. Match the application using app_id or find the best match for partial app_name
65+
# 2. Match the application using app_id first, then fall back to app_name if app_id is not provided
6766
try:
68-
if app_name:
67+
if app_id: # app_id takes precedence
68+
application = next((app for app in user_data["apps"] if app["id"] == app_id), None)
69+
if not application:
70+
raise ValueError(f"πŸ” No application found with ID: '{app_id}'.")
71+
elif app_name: # only check app_name if app_id is not provided
6972
matching_apps = [app for app in user_data["apps"] if app_name.lower() in app["name"].lower()]
7073
if not matching_apps:
7174
raise ValueError(f"πŸ” No application found with the name '{app_name}'.")
7275
# Sort matching applications by the length of their names, shorter names are likely to be more specific matches
7376
matching_apps.sort(key=lambda app: len(app["name"]))
7477
application = matching_apps[0]
75-
elif app_id:
76-
application = next((app for app in user_data["apps"] if app["id"] == app_id), None)
77-
if not application:
78-
raise ValueError(f"πŸ” No application found with the name '{app_name_from_config}' and ID: '{app_id}'.")
7978
else:
80-
raise ValueError("πŸ€” No application context provided. Please run 'phase init' or pass the '--app' flag followed by your application name.")
79+
raise ValueError("πŸ€” No application context provided. Please provide either app_name or app_id.")
8180

82-
# 4. Attempt to match environment with the exact name or a name that contains the env_name string
81+
# 3. Attempt to match environment with the exact name or a name that contains the env_name string
8382
environment = next((env for env in application["environment_keys"] if env_name.lower() in env["environment"]["name"].lower()), None)
8483

8584
if not environment:

β€Žsrc/phase/utils/network.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def construct_http_headers(token_type: str, app_token: str) -> Dict[str, str]:
7777
Dict[str, str]: The common headers including User-Agent.
7878
"""
7979
return {
80-
"Authorization": f"Bearer {token_type.capitalize()} {app_token}",
80+
"Authorization": f"Bearer {token_type} {app_token}",
8181
"User-Agent": get_user_agent()
8282
}
8383

0 commit comments

Comments
Β (0)