feat: add production deployment handoff controls
This commit is contained in:
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Ship one immutable audit-head anchor to a real S3 Object Lock bucket.
|
||||
# The key contains the head hash, so retries are idempotent and no mutable
|
||||
# "latest" object is trusted. AWS credentials are resolved by the runtime
|
||||
# workload identity; this script neither accepts nor prints credential values.
|
||||
#
|
||||
# Required: CASAN_S3_BUCKET, CASAN_S3_REGION, CASAN_S3_KMS_KEY_ID
|
||||
# Optional: CASAN_S3_PREFIX=audit-anchors, CASAN_S3_RETENTION_DAYS=365
|
||||
# Usage: audit-ship-s3.sh [audit-head-file]
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/casan-paths.sh"
|
||||
|
||||
fail() { echo "AUDIT_S3_SHIP_FAIL $*" >&2; exit 1; }
|
||||
[[ "${CASAN_PROFILE:-}" == "prod" ]] || fail "profile_must_be_prod"
|
||||
command -v aws >/dev/null 2>&1 || fail "aws_cli_required"
|
||||
|
||||
BUCKET="${CASAN_S3_BUCKET:-}"
|
||||
REGION="${CASAN_S3_REGION:-}"
|
||||
KMS_KEY_ID="${CASAN_S3_KMS_KEY_ID:-}"
|
||||
PREFIX="${CASAN_S3_PREFIX:-audit-anchors}"
|
||||
RETENTION_DAYS="${CASAN_S3_RETENTION_DAYS:-365}"
|
||||
HEAD_FILE="${1:-$CASAN_STATE_ROOT/logs/audit/audit-head.txt}"
|
||||
|
||||
[[ "$BUCKET" =~ ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$ ]] || fail "invalid_bucket"
|
||||
[[ "$REGION" =~ ^[a-z]{2}-[a-z]+-[0-9]+$ ]] || fail "invalid_region"
|
||||
case "$KMS_KEY_ID" in ''|*replace-with*|*example.com*) fail "invalid_kms_key_id";; esac
|
||||
[[ "$RETENTION_DAYS" =~ ^[1-9][0-9]*$ ]] || fail "invalid_retention_days"
|
||||
[[ -s "$HEAD_FILE" ]] || fail "missing_head file=$HEAD_FILE"
|
||||
|
||||
HEAD="$(tr -d '[:space:]' < "$HEAD_FILE")"
|
||||
[[ "$HEAD" =~ ^[a-f0-9]{64}$ ]] || fail "invalid_head"
|
||||
|
||||
LOCK_JSON="$(aws s3api get-object-lock-configuration --bucket "$BUCKET" --region "$REGION" --output json 2>/dev/null)" \
|
||||
|| fail "object_lock_configuration_unavailable"
|
||||
python3 - "$LOCK_JSON" "$RETENTION_DAYS" <<'PY' || fail "bucket_compliance_retention_not_sufficient"
|
||||
import json, sys
|
||||
cfg = json.loads(sys.argv[1]).get("ObjectLockConfiguration", {})
|
||||
if cfg.get("ObjectLockEnabled") != "Enabled":
|
||||
raise SystemExit(1)
|
||||
rule = cfg.get("Rule", {}).get("DefaultRetention", {})
|
||||
if rule.get("Mode") != "COMPLIANCE":
|
||||
raise SystemExit(1)
|
||||
days = int(rule.get("Days", 0)) + int(rule.get("Years", 0)) * 365
|
||||
if days < int(sys.argv[2]):
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
RETAIN_UNTIL="$(python3 - "$RETENTION_DAYS" <<'PY'
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import sys
|
||||
print((datetime.now(timezone.utc) + timedelta(days=int(sys.argv[1]))).strftime('%Y-%m-%dT%H:%M:%SZ'))
|
||||
PY
|
||||
)"
|
||||
DAY="$(date -u +%Y/%m/%d)"
|
||||
KEY="${PREFIX%/}/${DAY}/${HEAD}.json"
|
||||
# An unchanged head must not attempt to overwrite a COMPLIANCE-retained object.
|
||||
# A successful lookup is a durable idempotency proof for timer retries.
|
||||
if aws s3api head-object --bucket "$BUCKET" --key "$KEY" --region "$REGION" >/dev/null 2>&1; then
|
||||
echo "AUDIT_S3_ANCHOR_ALREADY_PRESENT bucket=$BUCKET key=$KEY mode=COMPLIANCE"
|
||||
exit 0
|
||||
fi
|
||||
TMP="$(mktemp)"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
python3 - "$HEAD" "$RETAIN_UNTIL" > "$TMP" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({"audit_head": sys.argv[1], "retention_until": sys.argv[2]}, sort_keys=True))
|
||||
PY
|
||||
|
||||
aws s3api put-object \
|
||||
--bucket "$BUCKET" --key "$KEY" --body "$TMP" --region "$REGION" \
|
||||
--object-lock-mode COMPLIANCE --object-lock-retain-until-date "$RETAIN_UNTIL" \
|
||||
--server-side-encryption aws:kms --ssekms-key-id "$KMS_KEY_ID" >/dev/null \
|
||||
|| fail "put_object_failed"
|
||||
|
||||
echo "AUDIT_S3_ANCHOR_SHIPPED bucket=$BUCKET key=$KEY retention_until=$RETAIN_UNTIL mode=COMPLIANCE"
|
||||
Reference in New Issue
Block a user