diff --git a/scripts/manage_secrets.py b/scripts/manage_secrets.py index d8ba63c..2a67c1c 100755 --- a/scripts/manage_secrets.py +++ b/scripts/manage_secrets.py @@ -1,18 +1,55 @@ #!/usr/bin/env python3 import subprocess -import argparse import sys import json +import os -# Configuration -PROJECT_ID = "haumdaucher" # Could fetch from gcloud config, but hardcoding for project context +# --- Configuration --- +PROJECT_ID = "haumdaucher" SECRETS = { - "haumdaucher-oauth-client-id": "Google OAuth Client ID", - "haumdaucher-oauth-client-secret": "Google OAuth Client Secret" + "haumdaucher-oauth-client-id": { + "desc": "OAuth 2.0 Client ID", + "instructions": ( + "1. Go to https://console.cloud.google.com/apis/credentials?project=haumdaucher\n" + "2. Look for 'OAuth 2.0 Client IDs' -> 'Haumdaucher Web' (or create one if missing).\n" + "3. Copy the 'Client ID' (looks like: 12345...apps.googleusercontent.com)" + ), + "validation_hint": "Must end with .apps.googleusercontent.com" + }, + "haumdaucher-oauth-client-secret": { + "desc": "OAuth 2.0 Client Secret", + "instructions": ( + "1. On the same credentials page, click the edit icon (pencil) or the name of the client.\n" + "2. On the right side, find 'Client secret'.\n" + "3. Copy the string (it is hidden by default)." + ), + "validation_hint": "Usually a random string of characters." + } } +# --- Helpers --- +class Colors: + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' + +def print_header(msg): + print(f"\n{Colors.HEADER}{Colors.BOLD}=== {msg} ==={Colors.ENDC}") + +def print_step(msg): + print(f"\n{Colors.OKBLUE}๐Ÿ‘‰ {msg}{Colors.ENDC}") + +def print_success(msg): + print(f"{Colors.OKGREEN}โœ… {msg}{Colors.ENDC}") + +def print_error(msg): + print(f"{Colors.FAIL}โŒ {msg}{Colors.ENDC}") + def run_command(command, check=True): - """Runs a shell command and returns the output.""" try: result = subprocess.run( command, @@ -24,28 +61,54 @@ def run_command(command, check=True): ) return result.stdout.strip() except subprocess.CalledProcessError as e: - print(f"โŒ Error running command: {command}") - print(f" Stderr: {e.stderr}") if check: + print_error(f"Command failed: {command}") + print(e.stderr) sys.exit(1) return None -def check_secret_exists(secret_name): - """Checks if a secret exists.""" - cmd = f"gcloud secrets describe {secret_name} --project={PROJECT_ID} --format=json" - result = run_command(cmd, check=False) - return result is not None +def verify_gcloud_login(): + """Ensures user is logged in to gcloud.""" + account = run_command("gcloud config get-value account", check=False) + if not account or account == "(unset)": + print_error("You are not logged in to gcloud.") + print(" Run: gcloud auth login") + sys.exit(1) + + project = run_command("gcloud config get-value project", check=False) + if project != PROJECT_ID: + print(f"{Colors.WARNING}โš ๏ธ Current gcloud project is '{project}', but this script targets '{PROJECT_ID}'.{Colors.ENDC}") + confirm = input(" Continue anyway? (y/n): ") + if confirm.lower() != 'y': + sys.exit(1) -def create_secret(secret_name): - """Creates a new secret.""" - print(f" Creating secret '{secret_name}'...") - cmd = f"gcloud secrets create {secret_name} --replication-policy=automatic --project={PROJECT_ID}" - run_command(cmd) +def enable_api(): + """Ensures Secret Manager API is enabled.""" + print("๐Ÿ” Checking API status...") + run_command(f"gcloud services enable secretmanager.googleapis.com --project={PROJECT_ID}", check=False) -def add_secret_version(secret_name, payload): - """Adds a new version to the secret.""" - print(f" Adding new version to '{secret_name}'...") - # Passing payload via stdin to avoid shell history logging +def get_secret_status(secret_id): + """Returns 'MISSING', 'EMPTY' (no versions), or 'READY'.""" + exists_cmd = f"gcloud secrets describe {secret_id} --project={PROJECT_ID} --format=json" + meta = run_command(exists_cmd, check=False) + + if not meta: + return "MISSING" + + # Check for versions + versions_cmd = f"gcloud secrets versions list {secret_id} --project={PROJECT_ID} --limit=1 --filter='state=ENABLED' --format=json" + versions = run_command(versions_cmd, check=False) + + if not versions or versions == "[]": + return "EMPTY" + + return "READY" + +def create_secret_resource(secret_id): + print(f" creating secret container '{secret_id}'...") + run_command(f"gcloud secrets create {secret_id} --replication-policy=automatic --project={PROJECT_ID}") + +def add_secret_version(secret_name, value): process = subprocess.Popen( f"gcloud secrets versions add {secret_name} --data-file=- --project={PROJECT_ID}", shell=True, @@ -54,84 +117,101 @@ def add_secret_version(secret_name, payload): stderr=subprocess.PIPE, text=True ) - stdout, stderr = process.communicate(input=payload) + stdout, stderr = process.communicate(input=value) if process.returncode != 0: - print(f"โŒ Failed to add version: {stderr}") + print_error(f"Failed to add version: {stderr}") sys.exit(1) - print(" โœ… Version added.") def cleanup_old_versions(secret_name): - """Destroys old versions, keeping only the latest enabled one.""" - print(f" Cleaning up old versions for '{secret_name}'...") - cmd = f"gcloud secrets versions list {secret_name} --project={PROJECT_ID} --limit=10 --format=json" - output = run_command(cmd) + cmd = f"gcloud secrets versions list {secret_name} --project={PROJECT_ID} --limit=10 --filter='state!=DESTROYED' --format=json" + output = run_command(cmd, check=False) if not output: return versions = json.loads(output) - # Sort by createTime desc (latest first) - # Filter for ENABLED versions usually, but we want to destroy everything except latest. - - # We keep the one with state ENABLED that is most recent? - # Usually the just-added one is ENABLED. - if len(versions) <= 1: - print(" No old versions to cleanup.") return - # Keep the latest one (index 0) - latest = versions[0] - to_destroy = versions[1:] - - for v in to_destroy: - state = v.get('state') - if state == 'DESTROYED': - continue - + # Keep latest enabled + # The API returns sorted list usually, but let's be safe: assume index 0 is latest + # Actually, we should just keep the one we just made. + + print(f" ๐Ÿงน Cleaning up old versions for clean state...") + # Skip the first one + for v in versions[1:]: version_id = v['name'].split('/')[-1] - print(f" destroying old version {version_id} ({state})...") - destroy_cmd = f"gcloud secrets versions destroy {version_id} --secret={secret_name} --project={PROJECT_ID} --quiet" - run_command(destroy_cmd) + run_command(f"gcloud secrets versions destroy {version_id} --secret={secret_name} --project={PROJECT_ID} --quiet", check=False) + + +# --- Main Logic --- def main(): - print(f"๐Ÿ” Haumdaucher Secret Manager Tool") - print(f" Project: {PROJECT_ID}") - - # Ensure API enabled - print("๐Ÿ” Checking Secret Manager API...") - run_command(f"gcloud services enable secretmanager.googleapis.com --project={PROJECT_ID}", check=False) + print_header("Haumdaucher Secret Manager Setup") + verify_gcloud_login() + enable_api() - for secret_id, description in SECRETS.items(): - print(f"\n๐Ÿ‘‰ Secret: {secret_id} ({description})") + # Main Loop ensuring state + while True: + all_ready = True - # Check existence - if not check_secret_exists(secret_id): - create_secret(secret_id) - - # Prompt for value - if secret_id == "haumdaucher-oauth-client-id": - print(f" โ„น๏ธ Go to GCP Console > Credentials > OAuth 2.0 Client IDs") - print(f" Copy the 'Client ID' (ends in .apps.googleusercontent.com)") - elif secret_id == "haumdaucher-oauth-client-secret": - print(f" โ„น๏ธ Copy the 'Client Secret' (shorter string, hidden in console)") - - print(f" Enter value for {description} (Input hidden): ") - # Use getpass logic via manual read to support all shells or just input() but masked? - # getpass in python is better. - import getpass - value = getpass.getpass(prompt=" > ") - - if not value: - print(" โš ๏ธ Skipping update (empty input).") - continue + # 1. Audit State + print_header("Current Status Audit") + status_map = {} + for secret_id in SECRETS.keys(): + status = get_secret_status(secret_id) + status_map[secret_id] = status - # Add version - add_secret_version(secret_id, value) + icon = "โœ…" if status == "READY" else "โŒ" + msg = f"{secret_id}: {status}" + if status == "READY": + print(f"{Colors.OKGREEN}{icon} {msg}{Colors.ENDC}") + else: + print(f"{Colors.FAIL}{icon} {msg}{Colors.ENDC}") + all_ready = False - # Lifecycle: Destroy old - cleanup_old_versions(secret_id) + if all_ready: + print_success("All secrets are configured correctly!") + break + + # 2. Fix Missing Items + print_header("Action Required") + + for secret_id, config in SECRETS.items(): + status = status_map[secret_id] + if status == "READY": + continue + + print_step(f"Configuring {config['desc']} ({secret_id})") + + if status == "MISSING": + create_secret_resource(secret_id) + + print(f"{Colors.WARNING}INSTRUCTIONS:{Colors.ENDC}") + print(config['instructions']) + print("") + + import getpass + value = getpass.getpass(prompt=f"{Colors.BOLD}Paste {config['desc']} here (hidden): {Colors.ENDC}") + + if not value.strip(): + print_error("Empty value provided. Retrying audit...") + continue + + add_secret_version(secret_id, value) + cleanup_old_versions(secret_id) + print_success("Secret updated.") + + print("\n๐Ÿ”„ Re-verifying state...") + # Loop continues to verify - print("\nโœ… All secrets updated successfully.") + print_header("Next Steps") + print("1. Infrastructure is ready.") + print("2. Run Terraform to apply the changes:") + print(f"\n {Colors.OKBLUE}cd terraform && terraform apply{Colors.ENDC}\n") if __name__ == "__main__": - main() + try: + main() + except KeyboardInterrupt: + print("\n\nโš ๏ธ Aborted by user.") + sys.exit(130) diff --git a/technical_details_auth.md b/technical_details_auth.md index 0ef7958..ad815d8 100644 --- a/technical_details_auth.md +++ b/technical_details_auth.md @@ -20,6 +20,11 @@ When you enable Firebase Authentication, Google creates an **OAuth 2.0 Client ID 1. At the moment of creation. 2. In the GCP Console UI (where you can "download JSON"). +> [!IMPORTANT] +> **"Browser key" vs "OAuth Client"**: +> You might see a "Browser key (auto created by Firebase)" in the console. This is an **API Key**, which is public and safe to share. +> **However**, for the Identity Platform backend configuration (`google_identity_platform_default_supported_idp_config`), we strictly need the **OAuth 2.0 Client Secret**, which acts as a password for the server. Do not confuse the two! + Because Terraform interacts via the API, it **cannot** fetch this secret on its own. If we tried to create a *new* Client ID via Terraform, we would hit another blocker: the **OAuth Consent Screen** requires manual configuration in the Console. ### 2. The Solution: "Bootstrap" Script