feat: add production deployment handoff controls

This commit is contained in:
thanhnv
2026-07-10 16:41:14 +09:00
parent 7cea023dce
commit aa284e9bd4
14 changed files with 408 additions and 109 deletions
+78
View File
@@ -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"
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
# Fail-closed production handoff validator for Linux deployment. It deliberately
# rejects the local lab, image tags, HTTP IdPs/Vaults and Object-Lock-free S3.
# Usage: production-preflight.sh [path-to-casan-prod.env]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
ROOT="$CASAN_APP_ROOT"
ENV_FILE="${1:-$ROOT/infra/production/casan-prod.env}"
fail() { echo "PRODUCTION_PREFLIGHT_FAIL $*" >&2; exit 1; }
pass() { echo "PASS: $*"; }
[[ -f "$ENV_FILE" ]] || fail "missing_env_file path=$ENV_FILE"
# Parse only dotenv assignments. Sourcing a deployment env file would execute
# arbitrary shell code before the security checks themselves run.
while IFS= read -r raw || [[ -n "$raw" ]]; do
line="${raw#"${raw%%[![:space:]]*}"}"
[[ -z "$line" || "$line" == \#* ]] && continue
[[ "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]] || fail "invalid_env_syntax"
key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
case "$key" in
CASAN_PUBLIC_FQDN|CASAN_CP_HTTPS_PORT|CASAN_CP_TLS_DIR|CASAN_CP_OAUTH_ENV|CASAN_CP_RUNTIME_ENV|CASAN_CP_VAULT_ENV|CASAN_CP_STATE_DIR|CASAN_CP_OUTPUT_DIR|CASAN_CP_API_IMAGE|CASAN_CP_UI_IMAGE|CASAN_S3_BUCKET|CASAN_S3_PREFIX|CASAN_S3_REGION|CASAN_S3_RETENTION_DAYS|CASAN_S3_KMS_KEY_ID) export "$key=$value" ;;
*) fail "unexpected_env_key key=$key" ;;
esac
done < "$ENV_FILE"
required=(CASAN_PUBLIC_FQDN CASAN_CP_TLS_DIR CASAN_CP_OAUTH_ENV CASAN_CP_RUNTIME_ENV CASAN_CP_VAULT_ENV CASAN_CP_STATE_DIR CASAN_CP_OUTPUT_DIR CASAN_CP_API_IMAGE CASAN_CP_UI_IMAGE CASAN_S3_BUCKET CASAN_S3_REGION CASAN_S3_KMS_KEY_ID)
for key in "${required[@]}"; do [[ -n "${!key:-}" ]] || fail "missing_env key=$key"; done
case "$CASAN_PUBLIC_FQDN" in *localhost*|*127.0.0.1*|*example.com*|*replace-with*|*/*|[0-9]* ) fail "invalid_fqdn";; esac
[[ "$CASAN_PUBLIC_FQDN" == *.* ]] || fail "fqdn_required"
for image in "$CASAN_CP_API_IMAGE" "$CASAN_CP_UI_IMAGE"; do
[[ "$image" =~ @sha256:[a-f0-9]{64}$ ]] || fail "image_must_be_digest_pinned image=$image"
done
pass "public FQDN and images are production-safe"
for file in "$CASAN_CP_TLS_DIR/tls.crt" "$CASAN_CP_TLS_DIR/tls.key" "$CASAN_CP_OAUTH_ENV" "$CASAN_CP_RUNTIME_ENV" "$CASAN_CP_VAULT_ENV"; do
[[ -s "$file" ]] || fail "missing_or_empty path=$file"
done
openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout >/dev/null || fail "invalid_tls_certificate"
openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout -checkend 2592000 >/dev/null || fail "tls_expires_within_30_days"
openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout -checkhost "$CASAN_PUBLIC_FQDN" >/dev/null || fail "tls_hostname_mismatch"
cert_pub="$(openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}')"
key_pub="$(openssl pkey -in "$CASAN_CP_TLS_DIR/tls.key" -pubout -outform DER | openssl dgst -sha256 | awk '{print $NF}')"
[[ "$cert_pub" == "$key_pub" ]] || fail "tls_key_does_not_match_certificate"
pass "TLS certificate is valid for at least 30 days"
value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_OAUTH_ENV" | tail -1; }
for key in OAUTH2_PROXY_PROVIDER OAUTH2_PROXY_OIDC_ISSUER_URL OAUTH2_PROXY_CLIENT_ID OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET OAUTH2_PROXY_REDIRECT_URL OAUTH2_PROXY_OIDC_GROUPS_CLAIM; do
value="$(value_of "$key")"; [[ -n "$value" ]] || fail "oidc_missing key=$key"
case "$value" in *replace-with*|*localhost*|*127.0.0.1*|*example.com*|local-secret|root) fail "oidc_placeholder key=$key";; esac
done
[[ "$(value_of OAUTH2_PROXY_PROVIDER)" == oidc ]] || fail "oidc_provider_required"
[[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "oidc_issuer_https_required"
[[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == "https://$CASAN_PUBLIC_FQDN/oauth2/callback" ]] || fail "oidc_redirect_mismatch"
[[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == true ]] || fail "oidc_secure_cookie_required"
pass "enterprise OIDC configuration"
vault_addr="$(sed -n -E 's/^VAULT_ADDR=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
vault_token="$(sed -n -E 's/^VAULT_TOKEN=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
vault_cacert="$(sed -n -E 's/^VAULT_CACERT=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
[[ "$vault_addr" == https://* ]] || fail "vault_https_required"
case "$vault_token" in ''|root|*replace-with*|*dev*) fail "vault_short_lived_token_required";; esac
[[ -s "$vault_cacert" ]] || fail "vault_ca_required"
vault_lookup="$(curl --fail --silent --show-error --connect-timeout 5 --cacert "$vault_cacert" -H "X-Vault-Token: $vault_token" "$vault_addr/v1/auth/token/lookup-self")" \
|| fail "vault_token_lookup_failed"
python3 - "$vault_lookup" <<'PY' || fail "vault_token_not_short_lived_or_privileged"
import json, sys
data = json.loads(sys.argv[1]).get("data", {})
ttl = int(data.get("ttl", 0))
policies = set(data.get("policies", []))
if not 0 < ttl <= 3600 or "root" in policies:
raise SystemExit(1)
PY
pass "Vault HTTPS endpoint and short-lived non-root token"
[[ -d "$CASAN_CP_STATE_DIR" && -d "$CASAN_CP_OUTPUT_DIR" ]] || fail "state_or_output_directory_missing"
CASAN_PROFILE=prod CASAN_S3_BUCKET="$CASAN_S3_BUCKET" CASAN_S3_REGION="$CASAN_S3_REGION" \
CASAN_S3_PREFIX="${CASAN_S3_PREFIX:-audit-anchors}" CASAN_S3_RETENTION_DAYS="${CASAN_S3_RETENTION_DAYS:-365}" \
CASAN_S3_KMS_KEY_ID="$CASAN_S3_KMS_KEY_ID" \
"$SCRIPT_DIR/audit-ship-s3.sh" "$CASAN_CP_STATE_DIR/logs/audit/audit-head.txt" >/dev/null \
|| fail "s3_object_lock_anchor_write_failed"
pass "S3 Object Lock compliance anchor write"
COMPOSE="$ROOT/docker-compose.control-panel.yml"
CASAN_CP_TLS_DIR="$CASAN_CP_TLS_DIR" CASAN_CP_OAUTH_ENV="$CASAN_CP_OAUTH_ENV" \
CASAN_CP_RUNTIME_ENV="$CASAN_CP_RUNTIME_ENV" CASAN_CP_VAULT_ENV="$CASAN_CP_VAULT_ENV" \
CASAN_CP_STATE_DIR="$CASAN_CP_STATE_DIR" CASAN_CP_OUTPUT_DIR="$CASAN_CP_OUTPUT_DIR" \
CASAN_CP_API_IMAGE="$CASAN_CP_API_IMAGE" CASAN_CP_UI_IMAGE="$CASAN_CP_UI_IMAGE" \
docker compose -f "$COMPOSE" config >/dev/null || fail "compose_config_invalid"
pass "production compose config"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
cp "$CASAN_CP_TLS_DIR/tls.crt" "$tmp/tls.crt"
cp "$CASAN_CP_TLS_DIR/tls.key" "$tmp/tls.key"
docker run --rm -v "$ROOT/nginx/control-panel.conf:/etc/nginx/conf.d/default.conf:ro" -v "$tmp:/etc/nginx/tls:ro" nginx:1.27-alpine nginx -t >/dev/null \
|| fail "nginx_config_invalid"
pass "nginx TLS/auth proxy configuration"
echo "PRODUCTION_PREFLIGHT_PASS fqdn=$CASAN_PUBLIC_FQDN oidc=true vault=true s3_object_lock=true images=true"