phase: 8, status: plan-as-execute, persona: platform-engineer+lead-developer, task: T-8.5..T-8.7
---ci--- project: acdl phase: 8 milestone: v1.1 status: plan-as-execute persona: platform-engineer+lead-developer task: [T-8.5, T-8.6, T-8.7] requirements.covered: [REQ-23] ---/ci--- Waves 3+4: rotation script + verify script + README + .gitignore. - T-8.5 (platform): scripts/rotate_spike_key.sh - boto3 with bootstrap root key from env (ACDL_BOOTSTRAP_AWS_*); creates new key for acdl-spike-runner, deactivates+deletes old, writes new to gitignored .env.secrets (chmod 600); idempotent (re-run ends with exactly 1 active key); optional Gitea secret upload if ACDL_GITEA_TOKEN set; does NOT rotate the root key (D-034 closure = manual user step). - T-8.6 (lead): scripts/verify_phase08.sh - loads rotated key from .env.secrets, asserts caller identity is acdl-spike-runner (not root), S3 bucket + DynamoDB table + IAM user + scoped policy with DenyEverythingElse all present, .env.secrets + .bootstrap_state.json gitignored. Uses heredoc python to avoid bash quoting issues. - T-8.7 (lead): terraform/bootstrap/README.md runbook (6 steps incl. manual D-034 root-key rotation) + .gitignore (.env.secrets + .bootstrap_state.json). Spike vs v1.2 boundary table. bash -n + gitignore checks pass.
This commit is contained in:
Executable
+98
@@ -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
|
||||
Executable
+79
@@ -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)"
|
||||
Reference in New Issue
Block a user