diff --git a/README.md b/README.md index cac0b15..9f2a0f8 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,12 @@ recovery point cross-region into a governance-locked vault. | Service role | `seahaven-backup-service-role` | **Backup-only** (Backup + S3-Backup managed policies); restore perms intentionally deferred | **Phase-1 scope** (selected by explicit ARN, not tags, to avoid drifting other -stacks): RDS `database-1`, RDS `proposal-system-db`, DynamoDB `PaymentsDashboard`, +stacks): RDS `proposal-system-db`, DynamoDB `PaymentsDashboard`, DynamoDB `purchase-orders`, S3 `accounting.seahaven.com`, `seahaven-payments-csv-328440206208`, `google-workspace-seahavenind.com`. +*(RDS `database-1` was originally in this set but was retired 2026-06-03 — +audit H-19, idle 0 conn/60d — and removed from the selection; its final +encrypted recovery point is retained in `seahaven-offsite` for 7 years.)* **Coexists with** existing EBS DLM snapshots and DynamoDB PITR — it supplements them with the missing offsite + immutable leg; it does not replace them. @@ -84,6 +87,35 @@ them with the missing offsite + immutable leg; it does not replace them. it to us-west-2 first; if the copy fails, encrypt it or drop it from the copy. 3. Enable DynamoDB PITR (H-7) on the two tables for between-window recovery. +### AWS Backup phase 2 (audit Day 4) + +Expands the same `seahaven-critical-daily` plan to every remaining data store, so +all of DynamoDB + EBS get the offsite + immutable leg ("offsite for everything"). + +| Resource | Logical ID | Notes | +|---|---|---| +| Phase-2 selection | `Plan/Phase2Resources` (`phase2-offsite-everything`) | Same plan, same `seahaven-backup-service-role`, same daily + cross-region copy rule | + +**Phase-2 scope:** the 15 remaining DynamoDB tables (all except the two phase-1 +financial tables + the deleted ledgerflow tables) and all 9 in-use EBS volumes, +again **by explicit ARN** — tag-based selection was deliberately avoided because +the file-share volumes are standalone-managed and the tables are owned by other +stacks, so tagging here would drift them. + +**No IAM change:** `AWSBackupServiceRolePolicyForBackup` already grants the +DynamoDB/RDS/EBS backup actions, so phase 2 reuses the phase-1 role unchanged +(cross-reviewed, no BLOCK). + +**Known tradeoff (→ Jira INFRA-31):** explicit-ARN EBS entries go stale if a +volume is replaced (new volume id), silently dropping it from backup. Migrating +the EBS portion to tag-based selection (with the tag codified in each owning +stack) is the resilient follow-up; scheduled drift detection is the interim +backstop. + +**Also enabled outside this stack (audit H-7, via CLI — codify per stack → +INFRA-30):** PITR + `DeletionProtectionEnabled` on 12 more DynamoDB tables +(account-wide PITR now 19/21). + ### Detective controls + budget (audit Day 1) Account-level detective layer, in `lib/detective-controls.ts`, plus the cost @@ -184,8 +216,9 @@ aws cloudwatch put-metric-alarm --alarm-name forgejo-backup-verification-errors ## Roadmap (same stack) Detective layer multi-region expansion (GuardDuty/Config/Security Hub beyond -us-east-1). Backup phase 2: expand past the phase-1 set via tag-based selection -and graduate the offsite vault to compliance mode. +us-east-1). Backup: phase 2 is deployed (see above); remaining is migrating the +phase-2 EBS entries to tag-based selection (INFRA-31) and graduating the offsite +vault to compliance mode. ## Deploy @@ -214,9 +247,11 @@ aws backup describe-backup-vault --backup-vault-name seahaven-offsite --region u aws backup get-backup-plan --backup-plan-id # daily rule + CopyAction # Smoke test: on-demand backup of one resource, then confirm the cross-region copy lands aws backup start-backup-job --backup-vault-name seahaven-primary \ - --resource-arn arn:aws:rds:us-east-1:328440206208:db:database-1 \ + --resource-arn arn:aws:rds:us-east-1:328440206208:db:proposal-system-db \ --iam-role-arn arn:aws:iam::328440206208:role/seahaven-backup-service-role aws backup list-copy-jobs --region us-west-2 # copy to offsite present + COMPLETED +# Phase-2 selections live on the plan: +aws backup list-backup-selections --backup-plan-id --query 'BackupSelectionsList[].SelectionName' # critical-data + phase2-offsite-everything ``` Detective layer + governance (Day 1): diff --git a/scripts/iam-user-delete.sh b/scripts/iam-user-delete.sh new file mode 100755 index 0000000..dbd80a2 --- /dev/null +++ b/scripts/iam-user-delete.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# iam-user-delete.sh — fully delete one or more IAM users (account 328440206208). +# +# IAM refuses to delete a user that still has attached/inline policies, access +# keys, group memberships, MFA devices, a login profile, signing certs, SSH keys, +# or service-specific credentials. This tears all of that down in order, then +# deletes the user. Built from the Day 4 audit cleanup (frappe/termius/ledgerflow, +# then office_mac/home_desktop/Personal-laptop). See reference memory +# `reference_identity_center_workmail` for the SSO context. +# +# Usage: +# scripts/iam-user-delete.sh [--profile NAME] [--yes] USER [USER ...] +# +# --profile NAME AWS CLI profile (default: $AWS_PROFILE or the default chain). +# Post-SSO-cutover this is normally `amoussa-seahaven`. +# --yes Skip the per-user confirmation prompt. +# +# Safety: +# * Refuses to delete the user the current credentials authenticate as. +# * Deactivates access keys before deleting them (a brief, reversible window +# if you remove --yes and inspect between users). +# * Prints each user's attachments before deleting so there is a record. +# +set -euo pipefail + +PROFILE_ARG=() +ASSUME_YES=0 +USERS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) PROFILE_ARG=(--profile "$2"); shift 2 ;; + --yes|-y) ASSUME_YES=1; shift ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + -*) echo "unknown flag: $1" >&2; exit 2 ;; + *) USERS+=("$1"); shift ;; + esac +done +[[ ${#USERS[@]} -eq 0 ]] && { echo "usage: $0 [--profile NAME] [--yes] USER [USER ...]" >&2; exit 2; } + +aws_() { aws "${PROFILE_ARG[@]}" "$@"; } + +# Guard: never delete the identity we're running as. +SELF_ARN="$(aws_ sts get-caller-identity --query Arn --output text)" +echo "running as: $SELF_ARN" + +delete_user() { + local u="$1" + if ! aws_ iam get-user --user-name "$u" >/dev/null 2>&1; then + echo " $u: does not exist, skipping"; return 0 + fi + if [[ "$SELF_ARN" == *":user/$u" ]]; then + echo " $u: REFUSING — this is the identity you are authenticated as" >&2; return 1 + fi + + echo "== $u ==" + echo " keys: $(aws_ iam list-access-keys --user-name "$u" --query 'AccessKeyMetadata[].AccessKeyId' --output text)" + echo " attached: $(aws_ iam list-attached-user-policies --user-name "$u" --query 'AttachedPolicies[].PolicyName' --output text)" + echo " inline: $(aws_ iam list-user-policies --user-name "$u" --query 'PolicyNames' --output text)" + echo " groups: $(aws_ iam list-groups-for-user --user-name "$u" --query 'Groups[].GroupName' --output text)" + + if [[ $ASSUME_YES -eq 0 ]]; then + read -r -p " delete user '$u'? [y/N] " ans + [[ "$ans" =~ ^[Yy]$ ]] || { echo " skipped"; return 0; } + fi + + # access keys: deactivate (reversible) then delete + for k in $(aws_ iam list-access-keys --user-name "$u" --query 'AccessKeyMetadata[].AccessKeyId' --output text); do + aws_ iam update-access-key --user-name "$u" --access-key-id "$k" --status Inactive + aws_ iam delete-access-key --user-name "$u" --access-key-id "$k" + done + # detach managed policies + for p in $(aws_ iam list-attached-user-policies --user-name "$u" --query 'AttachedPolicies[].PolicyArn' --output text); do + aws_ iam detach-user-policy --user-name "$u" --policy-arn "$p" + done + # delete inline policies + for ip in $(aws_ iam list-user-policies --user-name "$u" --query 'PolicyNames[]' --output text); do + aws_ iam delete-user-policy --user-name "$u" --policy-name "$ip" + done + # remove from groups + for g in $(aws_ iam list-groups-for-user --user-name "$u" --query 'Groups[].GroupName' --output text); do + aws_ iam remove-user-from-group --user-name "$u" --group-name "$g" + done + # MFA devices + for m in $(aws_ iam list-mfa-devices --user-name "$u" --query 'MFADevices[].SerialNumber' --output text); do + aws_ iam deactivate-mfa-device --user-name "$u" --serial-number "$m" + done + # login profile (console password) + aws_ iam delete-login-profile --user-name "$u" 2>/dev/null || true + # signing certs + for c in $(aws_ iam list-signing-certificates --user-name "$u" --query 'Certificates[].CertificateId' --output text 2>/dev/null); do + aws_ iam delete-signing-certificate --user-name "$u" --certificate-id "$c" + done + # SSH public keys (CodeCommit) + for s in $(aws_ iam list-ssh-public-keys --user-name "$u" --query 'SSHPublicKeys[].SSHPublicKeyId' --output text 2>/dev/null); do + aws_ iam delete-ssh-public-key --user-name "$u" --ssh-public-key-id "$s" + done + # service-specific credentials + for sc in $(aws_ iam list-service-specific-credentials --user-name "$u" --query 'ServiceSpecificCredentials[].ServiceSpecificCredentialId' --output text 2>/dev/null); do + aws_ iam delete-service-specific-credential --user-name "$u" --service-specific-credential-id "$sc" + done + + aws_ iam delete-user --user-name "$u" + echo " $u: deleted" +} + +rc=0 +for u in "${USERS[@]}"; do delete_user "$u" || rc=1; done +exit $rc