From aa284e9bd4b86fa0933d7065d7f2dcd6a87d0bc9 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Fri, 10 Jul 2026 16:41:14 +0900 Subject: [PATCH] feat: add production deployment handoff controls --- .gitignore | 1 + docker-compose.control-panel.yml | 36 +++---- infra/production/README.md | 65 +++++++++++ infra/production/casan-prod.env.example | 24 +++++ infra/production/oauth2-proxy.env.example | 13 +++ infra/production/runtime.env.example | 11 ++ .../systemd/casan-audit-anchor.service | 17 +++ .../systemd/casan-audit-anchor.timer | 11 ++ infra/production/vault.env.example | 4 + packages/casan-control-panel/README.md | 10 +- .../scripts/prod-readiness-check.sh | 90 +--------------- .../scripts/bash/audit-ship-s3.sh | 78 ++++++++++++++ .../scripts/bash/production-preflight.sh | 101 ++++++++++++++++++ .../tests/phase-production-preflight-tests.sh | 56 ++++++++++ 14 files changed, 408 insertions(+), 109 deletions(-) create mode 100644 infra/production/README.md create mode 100644 infra/production/casan-prod.env.example create mode 100644 infra/production/oauth2-proxy.env.example create mode 100644 infra/production/runtime.env.example create mode 100644 infra/production/systemd/casan-audit-anchor.service create mode 100644 infra/production/systemd/casan-audit-anchor.timer create mode 100644 infra/production/vault.env.example create mode 100755 packages/casan-harness/scripts/bash/audit-ship-s3.sh create mode 100755 packages/casan-harness/scripts/bash/production-preflight.sh create mode 100755 packages/casan-harness/tests/phase-production-preflight-tests.sh diff --git a/.gitignore b/.gitignore index da29450..4bc66ee 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ frontend/node_modules/.vite/ .env.* !.env.example infra/local-prod/casan.local.env +infra/production/casan-prod.env backend/.env frontend/.env **/.env diff --git a/docker-compose.control-panel.yml b/docker-compose.control-panel.yml index 828484c..c3c855d 100644 --- a/docker-compose.control-panel.yml +++ b/docker-compose.control-panel.yml @@ -1,8 +1,7 @@ -# Production-oriented Control Panel scaffold. -# Requires: -# - TLS cert/key mounted at /opt/casan-control-panel/tls/tls.crt|tls.key -# - OIDC env file at /opt/casan-control-panel/oauth2-proxy.env -# - CASAN state/artifacts mounted read-write/read-only as appropriate +# Production Control Panel deployment. This file intentionally has no build +# directives: production must run the digest-pinned images attested by CI. +# `production-preflight.sh` validates all files, endpoints, Object Lock and +# images before `docker compose up` is allowed. # # oauth2-proxy must emit X-Auth-Request-User and X-Auth-Request-Groups. # Nginx overwrites X-CASAN-* headers before proxying to the API; the API maps @@ -10,11 +9,13 @@ services: control-panel-api: - build: - context: . - dockerfile: Dockerfile.control-panel-api - image: casan-control-panel-api:latest + image: ${CASAN_CP_API_IMAGE:?Set a digest-pinned CASAN_CP_API_IMAGE} restart: unless-stopped + env_file: + - path: ${CASAN_CP_RUNTIME_ENV:?Set CASAN_CP_RUNTIME_ENV} + required: true + - path: ${CASAN_CP_VAULT_ENV:?Set CASAN_CP_VAULT_ENV} + required: true environment: CASAN_PROFILE: prod CASAN_CP_STRICT: "1" @@ -23,26 +24,23 @@ services: CP_PORT: "3010" CASAN_APP_ROOT: /app volumes: - - ./.specify:/app/.specify - - ./docs/output:/app/docs/output:ro + - ${CASAN_CP_STATE_DIR:?Set CASAN_CP_STATE_DIR}:/app/.specify + - ${CASAN_CP_OUTPUT_DIR:?Set CASAN_CP_OUTPUT_DIR}:/app/docs/output:ro expose: - "3010" networks: - casan-control control-panel-ui: - build: - context: . - dockerfile: Dockerfile.control-panel-ui - image: casan-control-panel-ui:latest + image: ${CASAN_CP_UI_IMAGE:?Set a digest-pinned CASAN_CP_UI_IMAGE} restart: unless-stopped depends_on: - control-panel-api - oauth2-proxy ports: - - "8443:443" + - "${CASAN_CP_HTTPS_PORT:-443}:443" volumes: - - /opt/casan-control-panel/tls:/etc/nginx/tls:ro + - ${CASAN_CP_TLS_DIR:?Set CASAN_CP_TLS_DIR}:/etc/nginx/tls:ro networks: - casan-control @@ -50,8 +48,8 @@ services: image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 restart: unless-stopped env_file: - - path: /opt/casan-control-panel/oauth2-proxy.env - required: false + - path: ${CASAN_CP_OAUTH_ENV:?Set CASAN_CP_OAUTH_ENV} + required: true command: - --http-address=0.0.0.0:4180 - --reverse-proxy=true diff --git a/infra/production/README.md b/infra/production/README.md new file mode 100644 index 0000000..a5699fc --- /dev/null +++ b/infra/production/README.md @@ -0,0 +1,65 @@ +# CASAN Linux Production Handoff + +This directory turns the local production-like lab into a deployment that +fails closed. It is deliberately parameterized: a real DNS name, enterprise +OIDC tenant, Vault policy/token and S3 bucket are tenant-owned secrets and must +not be invented or committed. + +## One-time infrastructure + +1. Create the DNS record for `CASAN_PUBLIC_FQDN` to the Linux host and allow + TCP 443 only. Obtain a certificate whose SAN includes that exact name; place + it at `/opt/casan-control-panel/tls/tls.crt` and `tls.key` (mode `0600`). +2. Register `https:///oauth2/callback` with the enterprise IdP. Map the + `groups` claim to CASAN groups such as `casan-org-admin` and + `casan-approver`. Copy `oauth2-proxy.env.example` outside the repo and fill + it using the secret manager. +3. Create a Vault Transit key `casan-audit-key` with `exportable=false`; give a + workload identity only `transit/sign/casan-audit-key` and read-key metadata. + Render a short-lived token into `vault.env` outside Git. Never use Vault dev + mode or the `root` token. +4. Create a versioned S3 bucket with Object Lock enabled at creation time. + Require default COMPLIANCE retention, SSE-KMS with `CASAN_S3_KMS_KEY_ID` and deny delete/retention + bypass. The runtime identity requires `s3:PutObject` plus + `s3:GetObjectLockConfiguration`, scoped to the audit prefix. +5. Let CI publish the API/UI as immutable `@sha256` images, attest those + artifacts and put the resulting digests in `casan-prod.env`. + +## Deploy + +```bash +cp infra/production/casan-prod.env.example infra/production/casan-prod.env +# Fill non-secret paths, FQDN, image digests, S3 bucket and region. + +sudo install -d -m 0700 /opt/casan-control-panel/tls /srv/casan/state /srv/casan/output +sudo install -m 0600 /dev/null /opt/casan-control-panel/oauth2-proxy.env +sudo install -m 0600 /dev/null /opt/casan-control-panel/runtime.env +sudo install -m 0600 /dev/null /opt/casan-control-panel/vault.env + +bash packages/casan-harness/scripts/bash/production-preflight.sh infra/production/casan-prod.env +set -a; source infra/production/casan-prod.env; set +a +docker compose -f docker-compose.control-panel.yml up -d +``` + +The preflight performs a real Object Lock anchor write. It will reject local +URLs, image tags, expired/near-expiry TLS, dev/root Vault tokens, incomplete +OIDC, missing paths, and buckets without Object Lock. After login, run +`managed-prod-smoke.sh` with an authenticated cookie jar. + +## Operational anchors + +Run `audit-ship-s3.sh` after every audit seal from the same workload identity. +Use a systemd timer or an orchestrated job; it produces a unique immutable key +per audit head and asks S3 for COMPLIANCE retention on every write. + +Install the included timer after the first successful preflight (the runtime +identity must also have `s3:GetObject` so retries can be idempotent): + +```bash +sudo install -D -m 0644 infra/production/systemd/casan-audit-anchor.service /etc/systemd/system/casan-audit-anchor.service +sudo install -D -m 0644 infra/production/systemd/casan-audit-anchor.timer /etc/systemd/system/casan-audit-anchor.timer +sudo install -D -m 0600 infra/production/casan-prod.env /etc/casan/production.env +sudo systemctl daemon-reload +sudo systemctl enable --now casan-audit-anchor.timer +systemctl list-timers casan-audit-anchor.timer +``` diff --git a/infra/production/casan-prod.env.example b/infra/production/casan-prod.env.example new file mode 100644 index 0000000..bf37441 --- /dev/null +++ b/infra/production/casan-prod.env.example @@ -0,0 +1,24 @@ +# Public control-plane endpoint. Do not use localhost, an IP address, or example.com. +CASAN_PUBLIC_FQDN=control.casan.company.internal +CASAN_CP_HTTPS_PORT=443 + +# Host paths. These are provisioned outside the repository with restrictive permissions. +CASAN_CP_TLS_DIR=/opt/casan-control-panel/tls +CASAN_CP_OAUTH_ENV=/opt/casan-control-panel/oauth2-proxy.env +CASAN_CP_RUNTIME_ENV=/opt/casan-control-panel/runtime.env +CASAN_CP_VAULT_ENV=/opt/casan-control-panel/vault.env +CASAN_CP_STATE_DIR=/srv/casan/state +CASAN_CP_OUTPUT_DIR=/srv/casan/output + +# CI must publish immutable image references, not tags such as :latest. +CASAN_CP_API_IMAGE=registry.example.internal/casan/control-panel-api@sha256:replace-with-64-hex-digest +CASAN_CP_UI_IMAGE=registry.example.internal/casan/control-panel-ui@sha256:replace-with-64-hex-digest + +# WORM anchor destination. The deploy identity needs only PutObject and +# GetObjectLockConfiguration for this bucket/prefix; use workload identity, +# never static credentials in this file. +CASAN_S3_BUCKET=casan-production-audit +CASAN_S3_PREFIX=audit-anchors +CASAN_S3_REGION=ap-northeast-1 +CASAN_S3_RETENTION_DAYS=365 +CASAN_S3_KMS_KEY_ID=alias/casan-audit diff --git a/infra/production/oauth2-proxy.env.example b/infra/production/oauth2-proxy.env.example new file mode 100644 index 0000000..bdf5df5 --- /dev/null +++ b/infra/production/oauth2-proxy.env.example @@ -0,0 +1,13 @@ +# Enterprise OIDC values. Keep the real file at /opt/casan-control-panel with chmod 0600. +OAUTH2_PROXY_PROVIDER=oidc +OAUTH2_PROXY_OIDC_ISSUER_URL=https://id.example.internal/realms/casan +OAUTH2_PROXY_CLIENT_ID=casan-control-panel +OAUTH2_PROXY_CLIENT_SECRET=replace-from-secret-manager +OAUTH2_PROXY_COOKIE_SECRET=replace-with-32-byte-base64-secret +OAUTH2_PROXY_COOKIE_SECURE=true +OAUTH2_PROXY_REDIRECT_URL=https://control.casan.company.internal/oauth2/callback +OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups +OAUTH2_PROXY_SET_XAUTHREQUEST=true +OAUTH2_PROXY_PASS_ACCESS_TOKEN=false +OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=false +OAUTH2_PROXY_SCOPE="openid profile email groups" diff --git a/infra/production/runtime.env.example b/infra/production/runtime.env.example new file mode 100644 index 0000000..a083a1c --- /dev/null +++ b/infra/production/runtime.env.example @@ -0,0 +1,11 @@ +# Non-secret runtime configuration. The service refuses to run without the +# companion Vault file and the production preflight validation. +CASAN_PROFILE=prod +CASAN_CP_STRICT=1 +CASAN_CP_TRUST_AUTH_PROXY=1 +CP_BIND=0.0.0.0 +CP_PORT=3010 +CASAN_APP_ROOT=/app +CASAN_DASHBOARD_STALE_S=300 +CASAN_PREFLIGHT=1 +CASAN_CHAT_MODEL_MODE=deterministic diff --git a/infra/production/systemd/casan-audit-anchor.service b/infra/production/systemd/casan-audit-anchor.service new file mode 100644 index 0000000..daf0478 --- /dev/null +++ b/infra/production/systemd/casan-audit-anchor.service @@ -0,0 +1,17 @@ +[Unit] +Description=CASAN immutable audit-head anchor +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +User=casan +Group=casan +EnvironmentFile=/etc/casan/production.env +Environment=CASAN_PROFILE=prod +ExecStart=/srv/casan/repo/packages/casan-harness/scripts/bash/audit-ship-s3.sh /srv/casan/state/logs/audit/audit-head.txt +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/srv/casan/state diff --git a/infra/production/systemd/casan-audit-anchor.timer b/infra/production/systemd/casan-audit-anchor.timer new file mode 100644 index 0000000..58e824d --- /dev/null +++ b/infra/production/systemd/casan-audit-anchor.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Periodically anchor CASAN audit head to S3 Object Lock + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +Persistent=true +Unit=casan-audit-anchor.service + +[Install] +WantedBy=timers.target diff --git a/infra/production/vault.env.example b/infra/production/vault.env.example new file mode 100644 index 0000000..eadc3a2 --- /dev/null +++ b/infra/production/vault.env.example @@ -0,0 +1,4 @@ +# Store the real token through a workload identity/agent rendered file. Never commit it. +VAULT_ADDR=https://vault.service.consul:8200 +VAULT_TOKEN=replace-with-short-lived-transit-policy-token +VAULT_CACERT=/run/secrets/vault-ca.pem diff --git a/packages/casan-control-panel/README.md b/packages/casan-control-panel/README.md index 70db4d3..e0d3989 100644 --- a/packages/casan-control-panel/README.md +++ b/packages/casan-control-panel/README.md @@ -117,7 +117,6 @@ npm run console:build # backend tsc + frontend typecheck/vite build ## Production-Like Smoke ```bash -docker compose -f docker-compose.control-panel.yml config bash packages/casan-control-panel/scripts/local-prod-smoke.sh ``` @@ -131,12 +130,13 @@ asserts the Command Center returns all nine widgets with provenance envelopes an therefore emits both `CP_LOCAL_SMOKE_PASS ...` and `CP_MANAGED_SMOKE_PASS actor=oidc-ops role=org-admin widgets=9`. -Managed production readiness, once the host has real TLS files and an enterprise OIDC -env file: +Managed production readiness is intentionally a stronger, fail-closed contract: it +requires a DNS-matching TLS certificate, enterprise OIDC, non-dev Vault Transit, +an actual S3 Object Lock COMPLIANCE write, and CI-attested digest-pinned images. +Follow [the Linux handoff guide](../../infra/production/README.md), then run: ```bash -CASAN_CP_TLS_DIR=/opt/casan-control-panel/tls \ -CASAN_CP_OAUTH_ENV=/opt/casan-control-panel/oauth2-proxy.env \ +CASAN_CP_PROD_ENV=infra/production/casan-prod.env \ bash packages/casan-control-panel/scripts/prod-readiness-check.sh ``` diff --git a/packages/casan-control-panel/scripts/prod-readiness-check.sh b/packages/casan-control-panel/scripts/prod-readiness-check.sh index bb1b9f8..ed30c94 100755 --- a/packages/casan-control-panel/scripts/prod-readiness-check.sh +++ b/packages/casan-control-panel/scripts/prod-readiness-check.sh @@ -2,91 +2,11 @@ set -euo pipefail # Validate the managed-production Control Panel handoff without printing secrets. -# This does not contact the enterprise IdP; it proves the host has the required -# TLS/OIDC files and that values are not still local/mock placeholders. +# The harness preflight adds Vault Transit, S3 Object Lock and digest-pinned +# images to the original TLS/OIDC/nginx contract. ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -COMPOSE="${CASAN_CP_COMPOSE:-$ROOT/docker-compose.control-panel.yml}" -TLS_DIR="${CASAN_CP_TLS_DIR:-/opt/casan-control-panel/tls}" -OAUTH_ENV="${CASAN_CP_OAUTH_ENV:-/opt/casan-control-panel/oauth2-proxy.env}" +ENV_FILE="${CASAN_CP_PROD_ENV:-$ROOT/infra/production/casan-prod.env}" -fail() { - echo "CP_PROD_READINESS_FAIL $1" - exit 1 -} - -pass() { - echo "PASS: $1" -} - -value_of() { - local key="$1" - sed -n -E "s/^${key}=//p" "$OAUTH_ENV" | tail -1 -} - -require_file() { - local path="$1" - [[ -f "$path" ]] || fail "missing_file path=$path" - [[ -s "$path" ]] || fail "empty_file path=$path" -} - -require_env() { - local key="$1" - local value - value="$(value_of "$key")" - [[ -n "$value" ]] || fail "missing_env key=$key file=$OAUTH_ENV" - case "$value" in - *replace-with*|*example.com*|*localhost*|*127.0.0.1*|*idp:8080*) - fail "placeholder_env key=$key" - ;; - esac -} - -docker compose -f "$COMPOSE" config >/tmp/casan-cp-prod-compose-config.txt -pass "docker compose config" - -require_file "$TLS_DIR/tls.crt" -require_file "$TLS_DIR/tls.key" -openssl x509 -in "$TLS_DIR/tls.crt" -noout >/dev/null -pass "tls certificate/key present" - -require_file "$OAUTH_ENV" -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 - require_env "$key" -done - -[[ "$(value_of OAUTH2_PROXY_PROVIDER)" == "oidc" ]] || fail "provider_must_be_oidc" -[[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == "true" ]] || fail "cookie_secure_must_be_true" -[[ "$(value_of OAUTH2_PROXY_SET_XAUTHREQUEST)" == "true" ]] || fail "xauthrequest_must_be_true" -[[ "$(value_of OAUTH2_PROXY_PASS_ACCESS_TOKEN)" == "false" ]] || fail "pass_access_token_must_be_false" -[[ "$(value_of OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER)" == "false" ]] || fail "pass_authorization_header_must_be_false" -[[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "issuer_must_be_https" -[[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == https://*"/oauth2/callback" ]] || fail "redirect_url_must_be_https_callback" -[[ "$(value_of OAUTH2_PROXY_OIDC_GROUPS_CLAIM)" == "groups" ]] || fail "groups_claim_must_be_groups" -pass "oauth2-proxy env" - -tmp="$(mktemp -d)" -cp "$TLS_DIR/tls.crt" "$tmp/tls.crt" -cp "$TLS_DIR/tls.key" "$tmp/tls.key" -docker run --rm \ - --add-host oauth2-proxy:127.0.0.1 \ - --add-host control-panel-api:127.0.0.1 \ - -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 >/tmp/casan-cp-prod-nginx-test.log 2>&1 || { - cat /tmp/casan-cp-prod-nginx-test.log - rm -rf "$tmp" - fail "nginx_config" - } -rm -rf "$tmp" -pass "nginx config" - -echo "CP_PROD_READINESS_PASS compose=true tls=true oidc=true nginx=true" +bash "$ROOT/packages/casan-harness/scripts/bash/production-preflight.sh" "$ENV_FILE" +echo "CP_PROD_READINESS_PASS production_preflight=true" diff --git a/packages/casan-harness/scripts/bash/audit-ship-s3.sh b/packages/casan-harness/scripts/bash/audit-ship-s3.sh new file mode 100755 index 0000000..bb671a6 --- /dev/null +++ b/packages/casan-harness/scripts/bash/audit-ship-s3.sh @@ -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" diff --git a/packages/casan-harness/scripts/bash/production-preflight.sh b/packages/casan-harness/scripts/bash/production-preflight.sh new file mode 100755 index 0000000..1ecc1c2 --- /dev/null +++ b/packages/casan-harness/scripts/bash/production-preflight.sh @@ -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" diff --git a/packages/casan-harness/tests/phase-production-preflight-tests.sh b/packages/casan-harness/tests/phase-production-preflight-tests.sh new file mode 100755 index 0000000..708b415 --- /dev/null +++ b/packages/casan-harness/tests/phase-production-preflight-tests.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Contract tests for production handoff controls. Network calls are replaced by +# a narrow aws stub so we test fail-closed command construction without cloud credentials. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh" +SHIP="$CASAN_HARNESS_ROOT/scripts/bash/audit-ship-s3.sh" +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS+1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL+1)); } +rc() { set +e; "$@" >/dev/null 2>&1; local code=$?; set -e; echo "$code"; } + +mkdir -p "$WORK/bin" +cat > "$WORK/bin/aws" <<'AWS' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${AWS_LOG:?}" +if [[ "$1 $2" == "s3api get-object-lock-configuration" ]]; then + printf '%s\n' '{"ObjectLockConfiguration":{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":365}}}}' +fi +if [[ "$1 $2" == "s3api head-object" ]]; then + exit 1 +fi +AWS +chmod +x "$WORK/bin/aws" +printf '%064d\n' 0 > "$WORK/head.txt" + +AWS_LOG="$WORK/aws.log" PATH="$WORK/bin:$PATH" CASAN_PROFILE=prod \ +CASAN_S3_BUCKET=casan-production-audit CASAN_S3_REGION=ap-northeast-1 \ +CASAN_S3_RETENTION_DAYS=365 CASAN_S3_KMS_KEY_ID=alias/casan-audit bash "$SHIP" "$WORK/head.txt" >/dev/null 2>&1 \ + && grep -q -- '--object-lock-mode COMPLIANCE' "$WORK/aws.log" \ + && grep -q -- '--server-side-encryption aws:kms' "$WORK/aws.log" \ + && grep -q -- '--ssekms-key-id alias/casan-audit' "$WORK/aws.log" \ + && pass "S3 anchor uses COMPLIANCE Object Lock and SSE-KMS" || fail "S3 anchor missing immutable controls" + +[[ "$(rc env PATH="$WORK/bin:$PATH" CASAN_PROFILE=dev CASAN_S3_BUCKET=casan-production-audit CASAN_S3_REGION=ap-northeast-1 CASAN_S3_KMS_KEY_ID=alias/casan-audit bash "$SHIP" "$WORK/head.txt")" -ne 0 ]] \ + && pass "non-production profile is refused" || fail "non-production profile accepted" + +cat > "$WORK/bin/aws" <<'AWS' +#!/usr/bin/env bash +if [[ "$1 $2" == "s3api get-object-lock-configuration" ]]; then + printf '%s\n' '{"ObjectLockConfiguration":{"ObjectLockEnabled":"Disabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":365}}}}' +fi +AWS +chmod +x "$WORK/bin/aws" +[[ "$(rc env PATH="$WORK/bin:$PATH" CASAN_PROFILE=prod CASAN_S3_BUCKET=casan-production-audit CASAN_S3_REGION=ap-northeast-1 CASAN_S3_KMS_KEY_ID=alias/casan-audit bash "$SHIP" "$WORK/head.txt")" -ne 0 ]] \ + && pass "bucket without Object Lock is refused" || fail "Object-Lock-free bucket accepted" + +printf 'not-a-digest\n' > "$WORK/bad-head.txt" +[[ "$(rc env PATH="$WORK/bin:$PATH" CASAN_PROFILE=prod CASAN_S3_BUCKET=casan-production-audit CASAN_S3_REGION=ap-northeast-1 CASAN_S3_KMS_KEY_ID=alias/casan-audit bash "$SHIP" "$WORK/bad-head.txt")" -ne 0 ]] \ + && pass "malformed audit head is refused" || fail "malformed audit head accepted" + +echo "===== PRODUCTION HANDOFF SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]]