diff --git a/.gitignore b/.gitignore index d3b37ad..23b879b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ state.json audit.json *.tmp .DS_Store -runner-data/ \ No newline at end of file +runner-data/ +.env.secrets +terraform/bootstrap/.bootstrap_state.json \ No newline at end of file diff --git a/scripts/rotate_spike_key.sh b/scripts/rotate_spike_key.sh new file mode 100755 index 0000000..6bd9a87 --- /dev/null +++ b/scripts/rotate_spike_key.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# scripts/rotate_spike_key.sh - rotate the acdl-spike-runner IAM access key. +# +# Uses the bootstrap root key (ACDL_BOOTSTRAP_AWS_*) from the env to: +# 1. List acdl-spike-runner's access keys. +# 2. Create a new key. +# 3. Deactivate + delete the old key(s). +# 4. Write the new key to gitignored .env.secrets (chmod 600). +# 5. Optionally upload to Gitea secrets if ACDL_GITEA_TOKEN is set. +# +# Idempotent: re-running always ends with exactly 1 active key for the user. +# Does NOT rotate the bootstrap root key (D-034 closure = manual user step). +# +# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is +# v1.2 (blocked on go-gitea/gitea#36988). +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +ENV_FILE="$ROOT/.env.secrets" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +: "${ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID:?set ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID to the root key}" +: "${ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:?set ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY to the root key}" +REGION="${AWS_DEFAULT_REGION:-us-east-1}" +USER_NAME="acdl-spike-runner" + +# Confirm .env.secrets is gitignored before writing to it. +git check-ignore -q "$ENV_FILE" || fail "$ENV_FILE is not gitignored — refusing to write the key" + +python3 - <<'PY' +import os +import sys +import json +import boto3 + +region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") +user = "acdl-spike-runner" +env_file = os.path.join(os.getcwd(), ".env.secrets") + +session = boto3.Session( + aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"], + region_name=region, +) +iam = session.client("iam") + +# List current keys. +keys = iam.list_access_keys(UserName=user).get("AccessKeyMetadata", []) +active = [k for k in keys if k["Status"] == "Active"] + +# Create a new key first (so the user always has a working key during rotation). +new = iam.create_access_key(UserName=user)["AccessKey"] +new_id = new["AccessKeyId"] +new_secret = new["SecretAccessKey"] +print(f"iam: created new key {new_id} for {user}", file=sys.stderr) + +# Deactivate + delete the old keys. +for k in active: + old_id = k["AccessKeyId"] + if old_id == new_id: + continue + iam.update_access_key(UserName=user, AccessKeyId=old_id, Status="Inactive") + iam.delete_access_key(UserName=user, AccessKeyId=old_id) + print(f"iam: deactivated+deleted old key {old_id}", file=sys.stderr) + +# Write the new key to gitignored .env.secrets (chmod 600). +with open(env_file, "w") as fh: + fh.write(f"ACDL_AWS_ACCESS_KEY_ID={new_id}\n") + fh.write(f"ACDL_AWS_SECRET_ACCESS_KEY={new_secret}\n") + fh.write(f"AWS_DEFAULT_REGION={region}\n") +os.chmod(env_file, 0o600) +print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr) + +# Optionally upload to Gitea secrets. +gitea_token = os.environ.get("ACDL_GITEA_TOKEN") +if gitea_token: + import urllib.request + base = "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/actions/secrets" + for name, value in [("ACDL_AWS_ACCESS_KEY_ID", new_id), + ("ACDL_AWS_SECRET_ACCESS_KEY", new_secret)]: + req = urllib.request.Request( + f"{base}/{name}", + data=json.dumps({"value": value}).encode(), + method="PUT", + headers={"Authorization": f"token {gitea_token}", + "Content-Type": "application/json"}, + ) + try: + urllib.request.urlopen(req).read() + print(f"gitea: secret {name} uploaded", file=sys.stderr) + except Exception as e: + print(f"gitea: secret {name} upload FAILED: {e}", file=sys.stderr) +else: + print("gitea: ACDL_GITEA_TOKEN not set; Gitea secret upload skipped (v1.2 hardening)", file=sys.stderr) + +print(f"OK: {user} now has exactly 1 active key: {new_id}") +PY \ No newline at end of file diff --git a/scripts/verify_phase08.sh b/scripts/verify_phase08.sh new file mode 100755 index 0000000..65b25e0 --- /dev/null +++ b/scripts/verify_phase08.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# scripts/verify_phase08.sh - Phase 08 aws-bootstrap gate. +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +fail() { echo "FAIL: $*" >&2; exit 1; } +ok() { echo "ok: $*"; } + +ENV_FILE="$ROOT/.env.secrets" +[ -f "$ENV_FILE" ] || fail ".env.secrets missing (run scripts/rotate_spike_key.sh first)" + +# Confirm .env.secrets + .bootstrap_state.json are gitignored. +git check-ignore -q "$ENV_FILE" || fail ".env.secrets is not gitignored" +git check-ignore -q terraform/bootstrap/.bootstrap_state.json || \ + fail "terraform/bootstrap/.bootstrap_state.json is not gitignored" +ok ".env.secrets + .bootstrap_state.json are gitignored" + +# Source the rotated spike key. +set -a +. "$ENV_FILE" +set +a +: "${ACDL_AWS_ACCESS_KEY_ID:?ACDL_AWS_ACCESS_KEY_ID missing in .env.secrets}" +: "${ACDL_AWS_SECRET_ACCESS_KEY:?ACDL_AWS_SECRET_ACCESS_KEY missing in .env.secrets}" +: "${AWS_DEFAULT_REGION:?AWS_DEFAULT_REGION missing in .env.secrets}" + +# --- Check 1: caller identity is acdl-spike-runner (NOT root) --- +CALLER=$(AWS_ACCESS_KEY_ID="$ACDL_AWS_ACCESS_KEY_ID" \ + AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY" \ + AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" \ + python3 -c " +import boto3, json +s = boto3.Session(region_name='$AWS_DEFAULT_REGION') +print(json.dumps(s.client('sts').get_caller_identity())) +") +ARN=$(echo "$CALLER" | python3 -c "import sys, json; print(json.load(sys.stdin)['Arn'])") +[ "$ARN" = "arn:aws:iam::581513795199:user/acdl-spike-runner" ] \ + || fail "caller identity is $ARN, expected arn:aws:iam::581513795199:user/acdl-spike-runner" +ok "caller identity is acdl-spike-runner (NOT root)" + +# --- Check 2: S3 state bucket exists --- +AWS_ACCESS_KEY_ID="$ACDL_AWS_ACCESS_KEY_ID" \ +AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY" \ +AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" \ +python3 -c " +import boto3 +s = boto3.Session(region_name='$AWS_DEFAULT_REGION') +s.client('s3').head_bucket(Bucket='acdl-tfstate-581513795199-us-east-1') +" || fail "S3 state bucket acdl-tfstate-581513795199-us-east-1 not accessible" +ok "S3 state bucket exists" + +# --- Check 3: DynamoDB outbox table exists --- +AWS_ACCESS_KEY_ID="$ACDL_AWS_ACCESS_KEY_ID" \ +AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY" \ +AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" \ +python3 -c " +import boto3 +s = boto3.Session(region_name='$AWS_DEFAULT_REGION') +s.client('dynamodb').describe_table(TableName='acdl-outbox') +" || fail "DynamoDB table acdl-outbox not accessible" +ok "DynamoDB outbox table exists" + +# --- Check 4: IAM user exists with the scoped inline policy containing the Deny statement --- +AWS_ACCESS_KEY_ID="$ACDL_AWS_ACCESS_KEY_ID" \ +AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY" \ +AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" \ +python3 <<'PY' || fail "IAM user acdl-spike-runner missing or policy lacks DenyEverythingElse" +import boto3, json, urllib.parse +s = boto3.Session(region_name='us-east-1') +iam = s.client('iam') +iam.get_user(UserName='acdl-spike-runner') +doc = iam.get_user_policy(UserName='acdl-spike-runner', + PolicyName='acdl-spike-runner-policy')['PolicyDocument'] +parsed = json.loads(urllib.parse.unquote(doc)) +sids = [st.get('Sid', '') for st in parsed['Statement']] +assert 'DenyEverythingElse' in sids, 'DenyEverythingElse statement missing' +PY +ok "IAM user acdl-spike-runner exists with the scoped Deny-everything-else policy" + +echo "VERIFIED — Phase 08: AWS bootstrap complete; spike key rotated; D-034 closed (user must rotate the root key manually now)" \ No newline at end of file diff --git a/terraform/bootstrap/README.md b/terraform/bootstrap/README.md new file mode 100644 index 0000000..7762c3d --- /dev/null +++ b/terraform/bootstrap/README.md @@ -0,0 +1,71 @@ +# ACDL v1.1 Spike — AWS Bootstrap Runbook + +Phase 08 bootstraps the AWS substrate for the v1.1 spike. It uses the +**root account credential for account 581513795199 exactly once**, then +closes D-034 by having the user manually rotate the root key afterward. + +> **Spike scope (D-039):** the spike uses a per-run-rotated IAM *user* key +> (`acdl-spike-runner`), NOT OIDC. Real OIDC federation is deferred to +> v1.2 (blocked on go-gitea/gitea#36988 — Gitea Actions does not support +> `id-token: write`). The `acdl-spike-runner` user + its key are deleted +> in v1.2 cleanup when the OIDC role lands. + +## Steps + +1. **Set the bootstrap root key in env** (never commit, never echo): + ```bash + export ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID="" + export ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="" + export AWS_DEFAULT_REGION="us-east-1" + ``` + +2. **Create the state backend** (S3 bucket + DynamoDB outbox table): + ```bash + python3 terraform/bootstrap/create_state_backend.py + ``` + Idempotent; writes `terraform/bootstrap/.bootstrap_state.json` marker. + +3. **Create the IAM user + scoped policy + initial key**: + ```bash + python3 terraform/bootstrap/create_iam_user.py + ``` + Prints `ACDL_AWS_ACCESS_KEY_ID=<...>` + `ACDL_AWS_SECRET_ACCESS_KEY=<...>` + to stdout (capture if you want the initial key; `rotate_spike_key.sh` + creates a fresh one anyway). + +4. **Rotate the spike key** (creates a new key, deactivates+deletes old, + writes the new key to gitignored `.env.secrets`): + ```bash + bash scripts/rotate_spike_key.sh + ``` + Optionally uploads to Gitea Actions secrets if `ACDL_GITEA_TOKEN` is set. + +5. **Verify**: + ```bash + bash scripts/verify_phase08.sh + ``` + Asserts: caller identity is `acdl-spike-runner` (not root); S3 bucket + + DynamoDB table + IAM user + scoped policy all exist; `.env.secrets` + + `.bootstrap_state.json` are gitignored. + +6. **MANUAL — D-034 closure:** rotate/deactivate the **root** key in the + AWS IAM console (the user does this, not the script). The bootstrap + root key has now served its one-shot purpose; the spike uses the + rotated `acdl-spike-runner` key for Phases 09-10. + +## What the spike uses for Phases 09-10 + +- **State backend:** S3 bucket `acdl-tfstate-581513795199-us-east-1` + + DynamoDB table `acdl-outbox` (one table for both lock + outbox, D-P08-1). +- **Auth:** the rotated `acdl-spike-runner` key in `.env.secrets` + (gitignored, chmod 600). Re-rotate after each spike run via + `rotate_spike_key.sh` (D-039). + +## Spike scope vs v1.2 boundary + +| Concern | Spike (Phase 08) | v1.2 | +|---------|------------------|------| +| AWS auth | per-run-rotated long-lived key (D-039 waiver) | real OIDC federation (go-gitea/gitea#36988) | +| IAM | minimal user `acdl-spike-runner` + scoped policy | OIDC role + trust policy (no user, no key) | +| State backend | S3 + DynamoDB single-region (us-east-1) | multi-region | +| Secret storage | gitignored `.env.secrets` + optional Gitea secret | Gitea OIDC-issued web-identity token (no secret) | \ No newline at end of file