botmaro-secrets-manager 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- botmaro_secrets_manager-0.1.0.dist-info/METADATA +578 -0
- botmaro_secrets_manager-0.1.0.dist-info/RECORD +11 -0
- botmaro_secrets_manager-0.1.0.dist-info/WHEEL +5 -0
- botmaro_secrets_manager-0.1.0.dist-info/entry_points.txt +2 -0
- botmaro_secrets_manager-0.1.0.dist-info/licenses/LICENSE +21 -0
- botmaro_secrets_manager-0.1.0.dist-info/top_level.txt +1 -0
- secrets_manager/__init__.py +13 -0
- secrets_manager/cli.py +482 -0
- secrets_manager/config.py +83 -0
- secrets_manager/core.py +313 -0
- secrets_manager/gsm.py +180 -0
secrets_manager/core.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Core secret management logic."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Dict, List, Optional, Tuple
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .config import SecretsConfig, EnvironmentConfig, ProjectConfig
|
|
7
|
+
from .gsm import GSMClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SecretsManager:
|
|
11
|
+
"""Main secrets manager class."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, config: Optional[SecretsConfig] = None):
|
|
14
|
+
"""
|
|
15
|
+
Initialize secrets manager.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
config: SecretsConfig instance or None to load from env/file
|
|
19
|
+
"""
|
|
20
|
+
self.config = config or SecretsConfig.from_env()
|
|
21
|
+
self._gsm_clients: Dict[str, GSMClient] = {}
|
|
22
|
+
|
|
23
|
+
def _get_gsm_client(self, project_id: str) -> GSMClient:
|
|
24
|
+
"""Get or create a GSM client for a project."""
|
|
25
|
+
if project_id not in self._gsm_clients:
|
|
26
|
+
self._gsm_clients[project_id] = GSMClient(project_id)
|
|
27
|
+
return self._gsm_clients[project_id]
|
|
28
|
+
|
|
29
|
+
def _get_secret_name(self, env: str, project: Optional[str], secret: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Generate the full secret name in GSM.
|
|
32
|
+
|
|
33
|
+
Uses double-hyphen (--) convention for hierarchical separation:
|
|
34
|
+
- Environment-scoped: {prefix}--{SECRET_NAME}
|
|
35
|
+
- Project-scoped: {prefix}--{project}--{SECRET_NAME}
|
|
36
|
+
|
|
37
|
+
This allows unambiguous parsing: secret_id.split('--')
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
env: Environment name
|
|
41
|
+
project: Optional project name
|
|
42
|
+
secret: Secret name
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Full secret ID for GSM
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> _get_secret_name("staging", None, "API_KEY")
|
|
49
|
+
"botmaro-staging--API_KEY"
|
|
50
|
+
>>> _get_secret_name("staging", "orchestrator", "DATABASE_URL")
|
|
51
|
+
"botmaro-staging--orchestrator--DATABASE_URL"
|
|
52
|
+
"""
|
|
53
|
+
env_config = self.config.get_environment(env)
|
|
54
|
+
if not env_config:
|
|
55
|
+
raise ValueError(f"Environment '{env}' not found in configuration")
|
|
56
|
+
|
|
57
|
+
prefix = env_config.prefix or f"botmaro-{env}"
|
|
58
|
+
|
|
59
|
+
if project:
|
|
60
|
+
return f"{prefix}--{project}--{secret}"
|
|
61
|
+
else:
|
|
62
|
+
return f"{prefix}--{secret}"
|
|
63
|
+
|
|
64
|
+
def bootstrap(
|
|
65
|
+
self,
|
|
66
|
+
env: str,
|
|
67
|
+
project: Optional[str] = None,
|
|
68
|
+
export_to_env: bool = True,
|
|
69
|
+
runtime_sa: Optional[str] = None,
|
|
70
|
+
deployer_sa: Optional[str] = None,
|
|
71
|
+
) -> Dict[str, str]:
|
|
72
|
+
"""
|
|
73
|
+
Bootstrap an environment by loading all secrets.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
env: Environment name (staging, prod, etc.)
|
|
77
|
+
project: Optional project name to scope to
|
|
78
|
+
export_to_env: Whether to export secrets to os.environ
|
|
79
|
+
runtime_sa: Optional runtime service account to grant access
|
|
80
|
+
deployer_sa: Optional deployer service account to grant access
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dict of secret names to values
|
|
84
|
+
"""
|
|
85
|
+
env_config = self.config.get_environment(env)
|
|
86
|
+
if not env_config:
|
|
87
|
+
raise ValueError(f"Environment '{env}' not found")
|
|
88
|
+
|
|
89
|
+
gsm = self._get_gsm_client(env_config.gcp_project)
|
|
90
|
+
secrets = {}
|
|
91
|
+
|
|
92
|
+
# Load global secrets
|
|
93
|
+
for secret_config in env_config.global_secrets:
|
|
94
|
+
secret_name = self._get_secret_name(env, None, secret_config.name)
|
|
95
|
+
value = gsm.get_secret_version(secret_name)
|
|
96
|
+
|
|
97
|
+
if value is None:
|
|
98
|
+
if secret_config.required and secret_config.default is None:
|
|
99
|
+
raise ValueError(f"Required secret '{secret_name}' not found")
|
|
100
|
+
value = secret_config.default or ""
|
|
101
|
+
|
|
102
|
+
secrets[secret_config.name] = value
|
|
103
|
+
|
|
104
|
+
if export_to_env:
|
|
105
|
+
os.environ[secret_config.name] = value
|
|
106
|
+
|
|
107
|
+
# Load project-specific secrets if project is specified
|
|
108
|
+
if project:
|
|
109
|
+
project_config = env_config.projects.get(project)
|
|
110
|
+
if not project_config:
|
|
111
|
+
raise ValueError(f"Project '{project}' not found in environment '{env}'")
|
|
112
|
+
|
|
113
|
+
for secret_config in project_config.secrets:
|
|
114
|
+
secret_name = self._get_secret_name(env, project, secret_config.name)
|
|
115
|
+
value = gsm.get_secret_version(secret_name)
|
|
116
|
+
|
|
117
|
+
if value is None:
|
|
118
|
+
if secret_config.required and secret_config.default is None:
|
|
119
|
+
raise ValueError(f"Required secret '{secret_name}' not found")
|
|
120
|
+
value = secret_config.default or ""
|
|
121
|
+
|
|
122
|
+
secrets[secret_config.name] = value
|
|
123
|
+
|
|
124
|
+
if export_to_env:
|
|
125
|
+
os.environ[secret_config.name] = value
|
|
126
|
+
|
|
127
|
+
# Grant access to service accounts if specified
|
|
128
|
+
if runtime_sa or deployer_sa:
|
|
129
|
+
for secret_name in secrets.keys():
|
|
130
|
+
full_secret_name = self._get_secret_name(env, project, secret_name)
|
|
131
|
+
if runtime_sa:
|
|
132
|
+
gsm.grant_access(full_secret_name, f"serviceAccount:{runtime_sa}")
|
|
133
|
+
if deployer_sa:
|
|
134
|
+
gsm.grant_access(full_secret_name, f"serviceAccount:{deployer_sa}")
|
|
135
|
+
|
|
136
|
+
return secrets
|
|
137
|
+
|
|
138
|
+
def set_secret(
|
|
139
|
+
self,
|
|
140
|
+
env: str,
|
|
141
|
+
secret: str,
|
|
142
|
+
value: str,
|
|
143
|
+
project: Optional[str] = None,
|
|
144
|
+
grant_to: Optional[List[str]] = None,
|
|
145
|
+
) -> Dict[str, str]:
|
|
146
|
+
"""
|
|
147
|
+
Set a secret value (create or update).
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
env: Environment name
|
|
151
|
+
secret: Secret name
|
|
152
|
+
value: Secret value
|
|
153
|
+
project: Optional project name
|
|
154
|
+
grant_to: Optional list of service accounts to grant access
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Dict with status information
|
|
158
|
+
"""
|
|
159
|
+
env_config = self.config.get_environment(env)
|
|
160
|
+
if not env_config:
|
|
161
|
+
raise ValueError(f"Environment '{env}' not found")
|
|
162
|
+
|
|
163
|
+
gsm = self._get_gsm_client(env_config.gcp_project)
|
|
164
|
+
secret_name = self._get_secret_name(env, project, secret)
|
|
165
|
+
|
|
166
|
+
result = gsm.ensure_secret(secret_name, value)
|
|
167
|
+
|
|
168
|
+
# Grant access to specified service accounts
|
|
169
|
+
if grant_to:
|
|
170
|
+
for sa in grant_to:
|
|
171
|
+
if not sa.startswith("serviceAccount:"):
|
|
172
|
+
sa = f"serviceAccount:{sa}"
|
|
173
|
+
gsm.grant_access(secret_name, sa)
|
|
174
|
+
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
def get_secret(
|
|
178
|
+
self, env: str, secret: str, project: Optional[str] = None, version: str = "latest"
|
|
179
|
+
) -> Optional[str]:
|
|
180
|
+
"""
|
|
181
|
+
Get a secret value.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
env: Environment name
|
|
185
|
+
secret: Secret name
|
|
186
|
+
project: Optional project name
|
|
187
|
+
version: Version to retrieve (default: latest)
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Secret value or None if not found
|
|
191
|
+
"""
|
|
192
|
+
env_config = self.config.get_environment(env)
|
|
193
|
+
if not env_config:
|
|
194
|
+
raise ValueError(f"Environment '{env}' not found")
|
|
195
|
+
|
|
196
|
+
gsm = self._get_gsm_client(env_config.gcp_project)
|
|
197
|
+
secret_name = self._get_secret_name(env, project, secret)
|
|
198
|
+
|
|
199
|
+
return gsm.get_secret_version(secret_name, version)
|
|
200
|
+
|
|
201
|
+
def delete_secret(self, env: str, secret: str, project: Optional[str] = None) -> bool:
|
|
202
|
+
"""
|
|
203
|
+
Delete a secret.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
env: Environment name
|
|
207
|
+
secret: Secret name
|
|
208
|
+
project: Optional project name
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True if deleted, False if not found
|
|
212
|
+
"""
|
|
213
|
+
env_config = self.config.get_environment(env)
|
|
214
|
+
if not env_config:
|
|
215
|
+
raise ValueError(f"Environment '{env}' not found")
|
|
216
|
+
|
|
217
|
+
gsm = self._get_gsm_client(env_config.gcp_project)
|
|
218
|
+
secret_name = self._get_secret_name(env, project, secret)
|
|
219
|
+
|
|
220
|
+
return gsm.delete_secret(secret_name)
|
|
221
|
+
|
|
222
|
+
def list_secrets(
|
|
223
|
+
self, env: str, project: Optional[str] = None
|
|
224
|
+
) -> List[Tuple[str, Optional[str]]]:
|
|
225
|
+
"""
|
|
226
|
+
List all secrets for an environment.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
env: Environment name
|
|
230
|
+
project: Optional project name to filter by
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
List of (secret_name, value) tuples
|
|
234
|
+
"""
|
|
235
|
+
env_config = self.config.get_environment(env)
|
|
236
|
+
if not env_config:
|
|
237
|
+
raise ValueError(f"Environment '{env}' not found")
|
|
238
|
+
|
|
239
|
+
gsm = self._get_gsm_client(env_config.gcp_project)
|
|
240
|
+
prefix = env_config.prefix or f"botmaro-{env}"
|
|
241
|
+
|
|
242
|
+
# Build filter - use double-hyphen convention
|
|
243
|
+
if project:
|
|
244
|
+
filter_str = f"name:{prefix}--{project}--"
|
|
245
|
+
else:
|
|
246
|
+
filter_str = f"name:{prefix}--"
|
|
247
|
+
|
|
248
|
+
secret_ids = gsm.list_secrets(filter_str)
|
|
249
|
+
|
|
250
|
+
results = []
|
|
251
|
+
for secret_id in secret_ids:
|
|
252
|
+
# Parse using double-hyphen separator
|
|
253
|
+
parts = secret_id.split("--")
|
|
254
|
+
|
|
255
|
+
if project:
|
|
256
|
+
# Expected format: prefix--project--secret
|
|
257
|
+
if len(parts) >= 3:
|
|
258
|
+
name = "--".join(parts[2:]) # Handle secrets with -- in name
|
|
259
|
+
else:
|
|
260
|
+
name = secret_id # Fallback
|
|
261
|
+
else:
|
|
262
|
+
# Expected format: prefix--secret
|
|
263
|
+
if len(parts) >= 2:
|
|
264
|
+
name = "--".join(parts[1:]) # Handle secrets with -- in name
|
|
265
|
+
else:
|
|
266
|
+
name = secret_id # Fallback
|
|
267
|
+
|
|
268
|
+
value = gsm.get_secret_version(secret_id)
|
|
269
|
+
results.append((name, value))
|
|
270
|
+
|
|
271
|
+
return results
|
|
272
|
+
|
|
273
|
+
def grant_access_bulk(
|
|
274
|
+
self,
|
|
275
|
+
env: str,
|
|
276
|
+
service_accounts: List[str],
|
|
277
|
+
project: Optional[str] = None,
|
|
278
|
+
) -> Dict[str, int]:
|
|
279
|
+
"""
|
|
280
|
+
Grant access to all secrets in an environment or project.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
env: Environment name
|
|
284
|
+
service_accounts: List of service account emails to grant access
|
|
285
|
+
project: Optional project name to scope to
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Dict with count of secrets updated
|
|
289
|
+
"""
|
|
290
|
+
env_config = self.config.get_environment(env)
|
|
291
|
+
if not env_config:
|
|
292
|
+
raise ValueError(f"Environment '{env}' not found")
|
|
293
|
+
|
|
294
|
+
gsm = self._get_gsm_client(env_config.gcp_project)
|
|
295
|
+
prefix = env_config.prefix or f"botmaro-{env}"
|
|
296
|
+
|
|
297
|
+
# Build filter - use double-hyphen convention
|
|
298
|
+
if project:
|
|
299
|
+
filter_str = f"name:{prefix}--{project}--"
|
|
300
|
+
else:
|
|
301
|
+
filter_str = f"name:{prefix}--"
|
|
302
|
+
|
|
303
|
+
secret_ids = gsm.list_secrets(filter_str)
|
|
304
|
+
|
|
305
|
+
count = 0
|
|
306
|
+
for secret_id in secret_ids:
|
|
307
|
+
for sa in service_accounts:
|
|
308
|
+
if not sa.startswith("serviceAccount:"):
|
|
309
|
+
sa = f"serviceAccount:{sa}"
|
|
310
|
+
gsm.grant_access(secret_id, sa)
|
|
311
|
+
count += 1
|
|
312
|
+
|
|
313
|
+
return {"secrets_updated": count, "service_accounts": len(service_accounts)}
|
secrets_manager/gsm.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Google Secret Manager integration."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, List, Dict, Any
|
|
4
|
+
from google.cloud import secretmanager
|
|
5
|
+
from google.api_core import exceptions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GSMClient:
|
|
9
|
+
"""Wrapper around Google Secret Manager client."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, project_id: str):
|
|
12
|
+
"""Initialize GSM client for a specific project."""
|
|
13
|
+
self.project_id = project_id
|
|
14
|
+
self.client = secretmanager.SecretManagerServiceClient()
|
|
15
|
+
self.project_path = f"projects/{project_id}"
|
|
16
|
+
|
|
17
|
+
def create_secret(self, secret_id: str, replication_policy: str = "automatic") -> bool:
|
|
18
|
+
"""
|
|
19
|
+
Create a new secret (without version).
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
secret_id: The ID of the secret to create
|
|
23
|
+
replication_policy: Replication policy (automatic or user-managed)
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if created, False if already exists
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
parent = self.project_path
|
|
30
|
+
secret: Dict[str, Any] = {"replication": {"automatic": {}}}
|
|
31
|
+
|
|
32
|
+
if replication_policy != "automatic":
|
|
33
|
+
# Allow custom replication policies in the future
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
self.client.create_secret(
|
|
37
|
+
request={
|
|
38
|
+
"parent": parent,
|
|
39
|
+
"secret_id": secret_id,
|
|
40
|
+
"secret": secret,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
return True
|
|
44
|
+
except exceptions.AlreadyExists:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def add_secret_version(self, secret_id: str, payload: str) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Add a new version to an existing secret.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
secret_id: The ID of the secret
|
|
53
|
+
payload: The secret value
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The version name
|
|
57
|
+
"""
|
|
58
|
+
parent = f"{self.project_path}/secrets/{secret_id}"
|
|
59
|
+
|
|
60
|
+
response = self.client.add_secret_version(
|
|
61
|
+
request={
|
|
62
|
+
"parent": parent,
|
|
63
|
+
"payload": {"data": payload.encode("UTF-8")},
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return response.name
|
|
68
|
+
|
|
69
|
+
def get_secret_version(self, secret_id: str, version: str = "latest") -> Optional[str]:
|
|
70
|
+
"""
|
|
71
|
+
Get a specific version of a secret.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
secret_id: The ID of the secret
|
|
75
|
+
version: Version number or 'latest'
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The secret value or None if not found
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
name = f"{self.project_path}/secrets/{secret_id}/versions/{version}"
|
|
82
|
+
response = self.client.access_secret_version(request={"name": name})
|
|
83
|
+
return response.payload.data.decode("UTF-8")
|
|
84
|
+
except exceptions.NotFound:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def list_secrets(self, filter_str: Optional[str] = None) -> List[str]:
|
|
88
|
+
"""
|
|
89
|
+
List all secrets in the project.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
filter_str: Optional filter string
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of secret IDs
|
|
96
|
+
"""
|
|
97
|
+
request = {"parent": self.project_path}
|
|
98
|
+
if filter_str:
|
|
99
|
+
request["filter"] = filter_str
|
|
100
|
+
|
|
101
|
+
secrets = []
|
|
102
|
+
for secret in self.client.list_secrets(request=request):
|
|
103
|
+
# Extract secret ID from full name
|
|
104
|
+
secret_id = secret.name.split("/")[-1]
|
|
105
|
+
secrets.append(secret_id)
|
|
106
|
+
|
|
107
|
+
return secrets
|
|
108
|
+
|
|
109
|
+
def delete_secret(self, secret_id: str) -> bool:
|
|
110
|
+
"""
|
|
111
|
+
Delete a secret and all its versions.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
secret_id: The ID of the secret to delete
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if deleted, False if not found
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
name = f"{self.project_path}/secrets/{secret_id}"
|
|
121
|
+
self.client.delete_secret(request={"name": name})
|
|
122
|
+
return True
|
|
123
|
+
except exceptions.NotFound:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def grant_access(
|
|
127
|
+
self, secret_id: str, member: str, role: str = "roles/secretmanager.secretAccessor"
|
|
128
|
+
):
|
|
129
|
+
"""
|
|
130
|
+
Grant IAM access to a secret.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
secret_id: The ID of the secret
|
|
134
|
+
member: The member to grant access to (e.g., 'serviceAccount:sa@project.iam.gserviceaccount.com')
|
|
135
|
+
role: The IAM role to grant
|
|
136
|
+
"""
|
|
137
|
+
name = f"{self.project_path}/secrets/{secret_id}"
|
|
138
|
+
|
|
139
|
+
policy = self.client.get_iam_policy(request={"resource": name})
|
|
140
|
+
|
|
141
|
+
# Check if binding already exists
|
|
142
|
+
binding_exists = False
|
|
143
|
+
for binding in policy.bindings:
|
|
144
|
+
if binding.role == role:
|
|
145
|
+
if member not in binding.members:
|
|
146
|
+
binding.members.append(member)
|
|
147
|
+
binding_exists = True
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
# Create new binding if it doesn't exist
|
|
151
|
+
if not binding_exists:
|
|
152
|
+
from google.iam.v1 import policy_pb2
|
|
153
|
+
|
|
154
|
+
new_binding = policy_pb2.Binding(role=role, members=[member])
|
|
155
|
+
policy.bindings.append(new_binding)
|
|
156
|
+
|
|
157
|
+
self.client.set_iam_policy(request={"resource": name, "policy": policy})
|
|
158
|
+
|
|
159
|
+
def ensure_secret(self, secret_id: str, value: str) -> Dict[str, str]:
|
|
160
|
+
"""
|
|
161
|
+
Ensure a secret exists with the given value (idempotent).
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
secret_id: The ID of the secret
|
|
165
|
+
value: The secret value
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Dict with 'status' and 'version'
|
|
169
|
+
"""
|
|
170
|
+
# Try to create the secret
|
|
171
|
+
created = self.create_secret(secret_id)
|
|
172
|
+
|
|
173
|
+
# Add the version
|
|
174
|
+
version = self.add_secret_version(secret_id, value)
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"status": "created" if created else "updated",
|
|
178
|
+
"version": version,
|
|
179
|
+
"secret_id": secret_id,
|
|
180
|
+
}
|