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.
@@ -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
+ }