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.
secrets_manager/cli.py ADDED
@@ -0,0 +1,482 @@
1
+ """Command-line interface for secrets management."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional, List
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich import print as rprint
11
+
12
+ from .core import SecretsManager
13
+ from .config import SecretsConfig
14
+
15
+ app = typer.Typer(
16
+ name="secrets-manager",
17
+ help="Botmaro Secrets Manager - Multi-environment secret management with Google Secret Manager",
18
+ add_completion=False,
19
+ )
20
+ console = Console()
21
+
22
+
23
+ def parse_target(target: str) -> tuple[str, Optional[str], Optional[str]]:
24
+ """
25
+ Parse target string into (env, project, secret).
26
+
27
+ Examples:
28
+ 'staging.myproject' -> ('staging', 'myproject', None)
29
+ 'staging' -> ('staging', None, None)
30
+ 'staging.myproject.MY_SECRET' -> ('staging', 'myproject', 'MY_SECRET')
31
+ 'staging.MY_SECRET' -> ('staging', None, 'MY_SECRET')
32
+ """
33
+ parts = target.split(".")
34
+
35
+ if len(parts) == 1:
36
+ # Just environment
37
+ return parts[0], None, None
38
+ elif len(parts) == 2:
39
+ # Could be env.project or env.secret
40
+ # Heuristic: if second part is uppercase, it's a secret
41
+ if parts[1].isupper() or "_" in parts[1]:
42
+ return parts[0], None, parts[1]
43
+ else:
44
+ return parts[0], parts[1], None
45
+ elif len(parts) >= 3:
46
+ # env.project.secret
47
+ return parts[0], parts[1], ".".join(parts[2:])
48
+ else:
49
+ raise ValueError(f"Invalid target format: {target}")
50
+
51
+
52
+ @app.command()
53
+ def bootstrap(
54
+ env: str = typer.Argument(..., help="Environment name (e.g., staging, prod)"),
55
+ project: Optional[str] = typer.Option(
56
+ None, "--project", "-p", help="Project name to scope secrets"
57
+ ),
58
+ config: Optional[str] = typer.Option(
59
+ None, "--config", "-c", help="Path to secrets config file"
60
+ ),
61
+ export: bool = typer.Option(
62
+ True, "--export/--no-export", help="Export secrets to environment variables"
63
+ ),
64
+ runtime_sa: Optional[str] = typer.Option(
65
+ None, "--runtime-sa", help="Runtime service account to grant access"
66
+ ),
67
+ deployer_sa: Optional[str] = typer.Option(
68
+ None, "--deployer-sa", help="Deployer service account to grant access"
69
+ ),
70
+ output: Optional[str] = typer.Option(
71
+ None, "--output", "-o", help="Output file for .env format"
72
+ ),
73
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
74
+ ):
75
+ """
76
+ Bootstrap environment by loading all required secrets.
77
+
78
+ This command loads all secrets defined in the configuration for the specified
79
+ environment and optionally exports them to the current shell environment.
80
+
81
+ Examples:
82
+ \b
83
+ # Bootstrap staging environment
84
+ secrets-manager bootstrap staging
85
+
86
+ \b
87
+ # Bootstrap with project scope
88
+ secrets-manager bootstrap staging --project myapp
89
+
90
+ \b
91
+ # Save to .env file
92
+ secrets-manager bootstrap staging --output .env.staging
93
+ """
94
+ try:
95
+ # Load config
96
+ if config:
97
+ os.environ["SECRETS_CONFIG_PATH"] = config
98
+
99
+ manager = SecretsManager()
100
+
101
+ with console.status(f"[bold green]Loading secrets for {env}..."):
102
+ secrets = manager.bootstrap(
103
+ env=env,
104
+ project=project,
105
+ export_to_env=export,
106
+ runtime_sa=runtime_sa,
107
+ deployer_sa=deployer_sa,
108
+ )
109
+
110
+ # Display results
111
+ if verbose:
112
+ table = Table(title=f"Loaded Secrets - {env}" + (f".{project}" if project else ""))
113
+ table.add_column("Secret", style="cyan")
114
+ table.add_column("Value", style="green")
115
+
116
+ for key, value in secrets.items():
117
+ # Mask value for security
118
+ masked = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***"
119
+ table.add_row(key, masked)
120
+
121
+ console.print(table)
122
+ else:
123
+ console.print(f"[green]✓[/green] Loaded {len(secrets)} secrets for [bold]{env}[/bold]")
124
+
125
+ # Write to output file if specified
126
+ if output:
127
+ output_path = Path(output)
128
+ with open(output_path, "w") as f:
129
+ for key, value in secrets.items():
130
+ f.write(f"{key}={value}\n")
131
+ console.print(f"[green]✓[/green] Secrets written to {output_path}")
132
+
133
+ except Exception as e:
134
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
135
+ raise typer.Exit(code=1)
136
+
137
+
138
+ @app.command()
139
+ def set(
140
+ target: str = typer.Argument(..., help="Target in format 'env[.project].SECRET_NAME'"),
141
+ value: Optional[str] = typer.Option(None, "--value", "-v", help="Secret value (or use stdin)"),
142
+ config: Optional[str] = typer.Option(
143
+ None, "--config", "-c", help="Path to secrets config file"
144
+ ),
145
+ grant: Optional[List[str]] = typer.Option(
146
+ None, "--grant", "-g", help="Service accounts to grant access"
147
+ ),
148
+ ):
149
+ """
150
+ Set a secret value (create or update).
151
+
152
+ Examples:
153
+ \b
154
+ # Set an environment-scoped secret
155
+ secrets-manager set staging.API_KEY --value "sk-123456"
156
+
157
+ \b
158
+ # Set a project-scoped secret
159
+ secrets-manager set staging.myapp.DATABASE_URL --value "postgres://..."
160
+
161
+ \b
162
+ # Read value from stdin
163
+ echo "secret-value" | secrets-manager set staging.MY_SECRET
164
+
165
+ \b
166
+ # Grant access to service account
167
+ secrets-manager set staging.API_KEY --value "sk-123" --grant bot@project.iam.gserviceaccount.com
168
+ """
169
+ try:
170
+ # Load config
171
+ if config:
172
+ os.environ["SECRETS_CONFIG_PATH"] = config
173
+
174
+ manager = SecretsManager()
175
+
176
+ # Parse target
177
+ env, project, secret = parse_target(target)
178
+
179
+ if not secret:
180
+ console.print("[red]✗ Error:[/red] Secret name required in target", style="bold red")
181
+ raise typer.Exit(code=1)
182
+
183
+ # Get value from stdin if not provided
184
+ if value is None:
185
+ if not sys.stdin.isatty():
186
+ value = sys.stdin.read().strip()
187
+ else:
188
+ value = typer.prompt("Enter secret value", hide_input=True)
189
+
190
+ # Set the secret
191
+ with console.status(f"[bold green]Setting secret..."):
192
+ result = manager.set_secret(
193
+ env=env,
194
+ secret=secret,
195
+ value=value,
196
+ project=project,
197
+ grant_to=grant,
198
+ )
199
+
200
+ target_str = f"{env}.{project}.{secret}" if project else f"{env}.{secret}"
201
+ console.print(f"[green]✓[/green] Secret [bold]{target_str}[/bold] {result['status']}")
202
+ console.print(f" Version: {result['version']}")
203
+
204
+ except Exception as e:
205
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
206
+ raise typer.Exit(code=1)
207
+
208
+
209
+ @app.command()
210
+ def get(
211
+ target: str = typer.Argument(..., help="Target in format 'env[.project].SECRET_NAME'"),
212
+ version: str = typer.Option("latest", "--version", help="Secret version to retrieve"),
213
+ config: Optional[str] = typer.Option(
214
+ None, "--config", "-c", help="Path to secrets config file"
215
+ ),
216
+ reveal: bool = typer.Option(False, "--reveal", help="Show the full secret value"),
217
+ ):
218
+ """
219
+ Get a secret value.
220
+
221
+ Examples:
222
+ \b
223
+ # Get latest version of a secret
224
+ secrets-manager get staging.API_KEY --reveal
225
+
226
+ \b
227
+ # Get specific version
228
+ secrets-manager get staging.API_KEY --version 2
229
+ """
230
+ try:
231
+ # Load config
232
+ if config:
233
+ os.environ["SECRETS_CONFIG_PATH"] = config
234
+
235
+ manager = SecretsManager()
236
+
237
+ # Parse target
238
+ env, project, secret = parse_target(target)
239
+
240
+ if not secret:
241
+ console.print("[red]✗ Error:[/red] Secret name required in target", style="bold red")
242
+ raise typer.Exit(code=1)
243
+
244
+ # Get the secret
245
+ value = manager.get_secret(env=env, secret=secret, project=project, version=version)
246
+
247
+ if value is None:
248
+ console.print(f"[yellow]![/yellow] Secret not found", style="bold yellow")
249
+ raise typer.Exit(code=1)
250
+
251
+ if reveal:
252
+ console.print(value)
253
+ else:
254
+ masked = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***"
255
+ console.print(f"Value: {masked} (use --reveal to show full value)")
256
+
257
+ except Exception as e:
258
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
259
+ raise typer.Exit(code=1)
260
+
261
+
262
+ @app.command()
263
+ def delete(
264
+ target: str = typer.Argument(..., help="Target in format 'env[.project].SECRET_NAME'"),
265
+ config: Optional[str] = typer.Option(
266
+ None, "--config", "-c", help="Path to secrets config file"
267
+ ),
268
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
269
+ ):
270
+ """
271
+ Delete a secret.
272
+
273
+ Examples:
274
+ \b
275
+ # Delete a secret
276
+ secrets-manager delete staging.OLD_API_KEY
277
+
278
+ \b
279
+ # Force delete without confirmation
280
+ secrets-manager delete staging.OLD_API_KEY --force
281
+ """
282
+ try:
283
+ # Load config
284
+ if config:
285
+ os.environ["SECRETS_CONFIG_PATH"] = config
286
+
287
+ manager = SecretsManager()
288
+
289
+ # Parse target
290
+ env, project, secret = parse_target(target)
291
+
292
+ if not secret:
293
+ console.print("[red]✗ Error:[/red] Secret name required in target", style="bold red")
294
+ raise typer.Exit(code=1)
295
+
296
+ target_str = f"{env}.{project}.{secret}" if project else f"{env}.{secret}"
297
+
298
+ # Confirm deletion
299
+ if not force:
300
+ confirm = typer.confirm(f"Delete secret '{target_str}'?")
301
+ if not confirm:
302
+ console.print("Cancelled")
303
+ raise typer.Exit(code=0)
304
+
305
+ # Delete the secret
306
+ deleted = manager.delete_secret(env=env, secret=secret, project=project)
307
+
308
+ if deleted:
309
+ console.print(f"[green]✓[/green] Secret [bold]{target_str}[/bold] deleted")
310
+ else:
311
+ console.print(f"[yellow]![/yellow] Secret not found", style="bold yellow")
312
+
313
+ except Exception as e:
314
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
315
+ raise typer.Exit(code=1)
316
+
317
+
318
+ @app.command()
319
+ def list(
320
+ env: str = typer.Argument(..., help="Environment name"),
321
+ project: Optional[str] = typer.Option(
322
+ None, "--project", "-p", help="Project name to filter by"
323
+ ),
324
+ config: Optional[str] = typer.Option(
325
+ None, "--config", "-c", help="Path to secrets config file"
326
+ ),
327
+ reveal: bool = typer.Option(False, "--reveal", help="Show secret values"),
328
+ ):
329
+ """
330
+ List all secrets for an environment.
331
+
332
+ Examples:
333
+ \b
334
+ # List all secrets for staging
335
+ secrets-manager list staging
336
+
337
+ \b
338
+ # List secrets for a specific project
339
+ secrets-manager list staging --project myapp
340
+ """
341
+ try:
342
+ # Load config
343
+ if config:
344
+ os.environ["SECRETS_CONFIG_PATH"] = config
345
+
346
+ manager = SecretsManager()
347
+
348
+ # List secrets
349
+ with console.status(f"[bold green]Loading secrets..."):
350
+ secrets = manager.list_secrets(env=env, project=project)
351
+
352
+ # Display results
353
+ table = Table(title=f"Secrets - {env}" + (f".{project}" if project else ""))
354
+ table.add_column("Secret Name", style="cyan")
355
+ table.add_column("Value", style="green")
356
+
357
+ for name, value in secrets:
358
+ if value and reveal:
359
+ table.add_row(name, value)
360
+ elif value:
361
+ masked = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***"
362
+ table.add_row(name, masked)
363
+ else:
364
+ table.add_row(name, "[red]<not found>[/red]")
365
+
366
+ console.print(table)
367
+ console.print(f"\nTotal: {len(secrets)} secrets")
368
+
369
+ except Exception as e:
370
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
371
+ raise typer.Exit(code=1)
372
+
373
+
374
+ @app.command()
375
+ def grant_access(
376
+ target: str = typer.Argument(
377
+ ..., help="Target in format 'env' or 'env.project' to grant access to all secrets"
378
+ ),
379
+ service_account: List[str] = typer.Option(
380
+ ..., "--sa", help="Service account email(s) to grant access (can be repeated)"
381
+ ),
382
+ config: Optional[str] = typer.Option(
383
+ None, "--config", "-c", help="Path to secrets config file"
384
+ ),
385
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
386
+ ):
387
+ """
388
+ Grant access to all secrets in an environment or project.
389
+
390
+ This grants secretAccessor role to the specified service account(s) for
391
+ all secrets in the given scope.
392
+
393
+ Examples:
394
+ \b
395
+ # Grant access to all staging secrets
396
+ secrets-manager grant-access staging --sa bot@project.iam.gserviceaccount.com
397
+
398
+ \b
399
+ # Grant access to multiple service accounts
400
+ secrets-manager grant-access staging \\
401
+ --sa bot@project.iam.gserviceaccount.com \\
402
+ --sa deployer@project.iam.gserviceaccount.com
403
+
404
+ \b
405
+ # Grant access to project-specific secrets
406
+ secrets-manager grant-access staging.myapp --sa runtime@project.iam.gserviceaccount.com
407
+ """
408
+ try:
409
+ # Load config
410
+ if config:
411
+ os.environ["SECRETS_CONFIG_PATH"] = config
412
+
413
+ manager = SecretsManager()
414
+
415
+ # Parse target
416
+ env, project, secret = parse_target(target)
417
+
418
+ if secret:
419
+ console.print(
420
+ "[red]✗ Error:[/red] grant-access works on environment or project level, not individual secrets",
421
+ style="bold red",
422
+ )
423
+ console.print(
424
+ "Use 'secrets-manager set <target> --value ... --grant ...' for individual secrets"
425
+ )
426
+ raise typer.Exit(code=1)
427
+
428
+ # Show what will be affected
429
+ target_str = f"{env}.{project}" if project else env
430
+ scope = f"project '{project}' in environment '{env}'" if project else f"environment '{env}'"
431
+
432
+ console.print(f"[bold]Will grant access to all secrets in {scope}[/bold]")
433
+ console.print(f"Service accounts:")
434
+ for sa in service_account:
435
+ console.print(f" - {sa}")
436
+
437
+ # List affected secrets
438
+ with console.status(f"[bold green]Finding secrets..."):
439
+ secrets = manager.list_secrets(env=env, project=project)
440
+
441
+ console.print(f"\nAffected secrets ({len(secrets)}):")
442
+ for name, _ in secrets[:10]: # Show first 10
443
+ console.print(f" - {name}")
444
+ if len(secrets) > 10:
445
+ console.print(f" ... and {len(secrets) - 10} more")
446
+
447
+ # Confirm
448
+ if not force:
449
+ console.print()
450
+ confirm = typer.confirm(
451
+ f"Grant access to {len(service_account)} service account(s) for {len(secrets)} secret(s)?"
452
+ )
453
+ if not confirm:
454
+ console.print("Cancelled")
455
+ raise typer.Exit(code=0)
456
+
457
+ # Grant access
458
+ with console.status(f"[bold green]Granting access..."):
459
+ result = manager.grant_access_bulk(
460
+ env=env, service_accounts=service_account, project=project
461
+ )
462
+
463
+ console.print(
464
+ f"[green]✓[/green] Granted access to {result['secrets_updated']} secrets "
465
+ f"for {result['service_accounts']} service account(s)"
466
+ )
467
+
468
+ except Exception as e:
469
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
470
+ raise typer.Exit(code=1)
471
+
472
+
473
+ @app.command()
474
+ def version():
475
+ """Show version information."""
476
+ from . import __version__
477
+
478
+ console.print(f"Botmaro Secrets Manager v{__version__}")
479
+
480
+
481
+ if __name__ == "__main__":
482
+ app()
@@ -0,0 +1,83 @@
1
+ """Configuration models and loaders for secrets management."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional, Union
6
+ import yaml
7
+ import json
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+
11
+ class SecretConfig(BaseModel):
12
+ """Configuration for a single secret."""
13
+
14
+ name: str
15
+ description: Optional[str] = None
16
+ required: bool = True
17
+ default: Optional[str] = None
18
+
19
+
20
+ class ProjectConfig(BaseModel):
21
+ """Configuration for a project within an environment."""
22
+
23
+ project_id: str
24
+ secrets: List[SecretConfig] = Field(default_factory=list)
25
+
26
+
27
+ class EnvironmentConfig(BaseModel):
28
+ """Configuration for an environment (staging, prod, etc.)."""
29
+
30
+ name: str
31
+ gcp_project: str
32
+ prefix: Optional[str] = None
33
+ projects: Dict[str, ProjectConfig] = Field(default_factory=dict)
34
+ global_secrets: List[SecretConfig] = Field(default_factory=list)
35
+
36
+ @field_validator("prefix", mode="before")
37
+ @classmethod
38
+ def set_prefix(cls, v: Optional[str], info) -> str:
39
+ """Auto-generate prefix if not provided."""
40
+ if v is None and "name" in info.data:
41
+ return f"botmaro-{info.data['name']}"
42
+ return v or ""
43
+
44
+
45
+ class SecretsConfig(BaseModel):
46
+ """Root configuration for all environments and secrets."""
47
+
48
+ version: str = "1.0"
49
+ environments: Dict[str, EnvironmentConfig] = Field(default_factory=dict)
50
+
51
+ @classmethod
52
+ def from_file(cls, path: Union[str, Path]) -> "SecretsConfig":
53
+ """Load configuration from YAML or JSON file."""
54
+ path = Path(path)
55
+ if not path.exists():
56
+ raise FileNotFoundError(f"Config file not found: {path}")
57
+
58
+ with open(path, "r") as f:
59
+ if path.suffix in [".yaml", ".yml"]:
60
+ data = yaml.safe_load(f)
61
+ elif path.suffix == ".json":
62
+ data = json.load(f)
63
+ else:
64
+ raise ValueError(f"Unsupported file type: {path.suffix}")
65
+
66
+ return cls(**data)
67
+
68
+ @classmethod
69
+ def from_env(cls) -> "SecretsConfig":
70
+ """Load configuration from environment variables."""
71
+ config_path = os.getenv("SECRETS_CONFIG_PATH", "secrets.yml")
72
+ return cls.from_file(config_path)
73
+
74
+ def get_environment(self, env_name: str) -> Optional[EnvironmentConfig]:
75
+ """Get configuration for a specific environment."""
76
+ return self.environments.get(env_name)
77
+
78
+ def get_project(self, env_name: str, project_name: str) -> Optional[ProjectConfig]:
79
+ """Get configuration for a specific project in an environment."""
80
+ env = self.get_environment(env_name)
81
+ if env:
82
+ return env.projects.get(project_name)
83
+ return None