|
| 1 | +""" |
| 2 | +Copyright (C) 2026 Garudex Labs. All Rights Reserved. |
| 3 | +Caracal, a product of Garudex Labs |
| 4 | +
|
| 5 | +CLI commands for secret vault management. |
| 6 | +
|
| 7 | +Commands: |
| 8 | + caracal secrets list — list secret refs in (org, env) |
| 9 | + caracal secrets rotate — rotate the CaracalVault master key (Starter only) |
| 10 | + caracal secrets migrate — plan and execute a CaracalVault → AWS SM migration |
| 11 | +""" |
| 12 | + |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +import sys |
| 16 | + |
| 17 | +import click |
| 18 | + |
| 19 | + |
| 20 | +@click.group(name="secrets") |
| 21 | +def secrets_group(): |
| 22 | + """Manage secrets in the tier-appropriate vault backend.""" |
| 23 | + |
| 24 | + |
| 25 | +@secrets_group.command(name="list") |
| 26 | +@click.option("--org-id", required=True, help="Organisation ID.") |
| 27 | +@click.option("--env-id", default="default", show_default=True, help="Environment ID.") |
| 28 | +@click.option("--tier", required=True, |
| 29 | + type=click.Choice(["starter", "growth", "scale", "enterprise"], case_sensitive=False), |
| 30 | + help="Subscription tier (determines backend).") |
| 31 | +def list_secrets(org_id: str, env_id: str, tier: str) -> None: |
| 32 | + """List secret refs in the vault for (org, env).""" |
| 33 | + try: |
| 34 | + from caracal.sdk.secrets import SecretsAdapter |
| 35 | + adapter = SecretsAdapter(tier=tier, org_id=org_id, env_id=env_id) |
| 36 | + refs = adapter.list_refs() |
| 37 | + if not refs: |
| 38 | + click.echo(f"No secrets found for org={org_id} env={env_id} (backend: {adapter.backend_name}).") |
| 39 | + return |
| 40 | + click.echo(f"Secrets in {adapter.backend_name} for org={org_id} env={env_id}:\n") |
| 41 | + for ref in refs: |
| 42 | + click.echo(f" {ref}") |
| 43 | + click.echo(f"\nTotal: {len(refs)}") |
| 44 | + except Exception as exc: |
| 45 | + click.echo(f"Error: {exc}", err=True) |
| 46 | + sys.exit(1) |
| 47 | + |
| 48 | + |
| 49 | +@secrets_group.command(name="rotate") |
| 50 | +@click.option("--org-id", required=True, help="Organisation ID.") |
| 51 | +@click.option("--env-id", default="default", show_default=True, help="Environment ID.") |
| 52 | +@click.option("--confirm", is_flag=True, help="Confirm the rotation without prompting.") |
| 53 | +def rotate_key(org_id: str, env_id: str, confirm: bool) -> None: |
| 54 | + """ |
| 55 | + Rotate the CaracalVault master key for (org, env). |
| 56 | +
|
| 57 | + Available only for Starter tier. Re-encrypts all DEKs under a new MEK |
| 58 | + version. No secret values are exposed during rotation. |
| 59 | + """ |
| 60 | + if not confirm: |
| 61 | + click.confirm( |
| 62 | + f"Rotate master key for org={org_id} env={env_id}? " |
| 63 | + "All DEKs will be re-wrapped under a new key version.", |
| 64 | + abort=True, |
| 65 | + ) |
| 66 | + try: |
| 67 | + from caracal.core.vault import get_vault, gateway_context |
| 68 | + vault = get_vault() |
| 69 | + with gateway_context(): |
| 70 | + result = vault.rotate_master_key(org_id, env_id, actor="cli") |
| 71 | + click.echo( |
| 72 | + f"Key rotation complete.\n" |
| 73 | + f" Secrets rotated : {result.secrets_rotated}\n" |
| 74 | + f" Secrets failed : {result.secrets_failed}\n" |
| 75 | + f" New key version : {result.new_key_version}\n" |
| 76 | + f" Duration : {result.duration_seconds}s" |
| 77 | + ) |
| 78 | + if result.secrets_failed > 0: |
| 79 | + click.echo("WARNING: Some secrets failed to rotate. Check logs.", err=True) |
| 80 | + sys.exit(1) |
| 81 | + except Exception as exc: |
| 82 | + click.echo(f"Error: {exc}", err=True) |
| 83 | + sys.exit(1) |
| 84 | + |
| 85 | + |
| 86 | +@secrets_group.command(name="migrate") |
| 87 | +@click.option("--org-id", required=True, help="Organisation ID.") |
| 88 | +@click.option("--env-id", default="default", show_default=True, help="Environment ID.") |
| 89 | +@click.option( |
| 90 | + "--to-tier", |
| 91 | + required=True, |
| 92 | + type=click.Choice(["growth", "scale", "enterprise"], case_sensitive=False), |
| 93 | + help="Target tier for migration (CaracalVault → AWS SM).", |
| 94 | +) |
| 95 | +@click.option("--rotate-credentials", is_flag=True, help="Rotate credentials during migration.") |
| 96 | +@click.option("--dry-run", is_flag=True, help="Show migration plan without executing.") |
| 97 | +@click.option("--confirm", is_flag=True, help="Confirm migration without prompting.") |
| 98 | +def migrate_secrets( |
| 99 | + org_id: str, env_id: str, to_tier: str, |
| 100 | + rotate_credentials: bool, dry_run: bool, confirm: bool, |
| 101 | +) -> None: |
| 102 | + """ |
| 103 | + Migrate secrets from CaracalVault (Starter) to AWS Secrets Manager (Growth+). |
| 104 | +
|
| 105 | + Shows cost estimate and impact summary before confirming. |
| 106 | + """ |
| 107 | + try: |
| 108 | + from caracalEnterprise.services.gateway.vault_migration import MigrationOrchestrator |
| 109 | + orchestrator = MigrationOrchestrator() |
| 110 | + plan = orchestrator.plan_upgrade( |
| 111 | + org_id=org_id, env_id=env_id, |
| 112 | + source_tier="starter", target_tier=to_tier, |
| 113 | + rotate_credentials=rotate_credentials, |
| 114 | + ) |
| 115 | + except Exception as exc: |
| 116 | + click.echo(f"Failed to build migration plan: {exc}", err=True) |
| 117 | + sys.exit(1) |
| 118 | + |
| 119 | + # Show plan |
| 120 | + click.echo(f"\n{'=' * 60}") |
| 121 | + click.echo(f" Migration Plan ({plan.plan_id})") |
| 122 | + click.echo(f"{'=' * 60}") |
| 123 | + click.echo(f" Direction : {plan.source_tier} → {plan.target_tier}") |
| 124 | + click.echo(f" Secrets : {plan.total_secrets}") |
| 125 | + click.echo(f" Rotation : {'yes' if rotate_credentials else 'no'}") |
| 126 | + if plan.cost_estimate: |
| 127 | + e = plan.cost_estimate |
| 128 | + click.echo(f"\n AWS Estimated Cost:") |
| 129 | + click.echo(f" Per month : ${e.total_per_month_usd:.4f} USD") |
| 130 | + click.echo(f" Per year : ${e.total_per_year_usd:.4f} USD") |
| 131 | + click.echo(f" (${e.cost_per_month_usd:.4f}/secret/month + ${e.api_call_cost_per_month_usd:.4f} API calls)") |
| 132 | + for k, v in plan.impact_summary.items(): |
| 133 | + if k not in ("aws_region",): |
| 134 | + click.echo(f" {k.replace('_', ' ').title():30s}: {v}") |
| 135 | + click.echo(f"{'=' * 60}\n") |
| 136 | + |
| 137 | + if dry_run: |
| 138 | + click.echo("Dry run — no changes made.") |
| 139 | + return |
| 140 | + |
| 141 | + if not confirm: |
| 142 | + click.confirm("Proceed with migration?", abort=True) |
| 143 | + |
| 144 | + try: |
| 145 | + result = orchestrator.execute_upgrade(plan, actor="cli") |
| 146 | + click.echo( |
| 147 | + f"\nMigration {'complete' if result.status.value == 'completed' else 'FAILED'}.\n" |
| 148 | + f" Status : {result.status.value}\n" |
| 149 | + f" Migrated : {result.secrets_migrated}/{result.secrets_total}\n" |
| 150 | + f" Validated : {result.secrets_validated}\n" |
| 151 | + f" Failed : {result.secrets_failed}\n" |
| 152 | + f" Duration : {result.duration_seconds}s" |
| 153 | + ) |
| 154 | + if result.error: |
| 155 | + click.echo(f"\nError: {result.error}", err=True) |
| 156 | + sys.exit(1) |
| 157 | + except Exception as exc: |
| 158 | + click.echo(f"Migration execution failed: {exc}", err=True) |
| 159 | + sys.exit(1) |
0 commit comments