feat: add production deployment handoff controls
This commit is contained in:
@@ -15,6 +15,7 @@ frontend/node_modules/.vite/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
infra/local-prod/casan.local.env
|
infra/local-prod/casan.local.env
|
||||||
|
infra/production/casan-prod.env
|
||||||
backend/.env
|
backend/.env
|
||||||
frontend/.env
|
frontend/.env
|
||||||
**/.env
|
**/.env
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# Production-oriented Control Panel scaffold.
|
# Production Control Panel deployment. This file intentionally has no build
|
||||||
# Requires:
|
# directives: production must run the digest-pinned images attested by CI.
|
||||||
# - TLS cert/key mounted at /opt/casan-control-panel/tls/tls.crt|tls.key
|
# `production-preflight.sh` validates all files, endpoints, Object Lock and
|
||||||
# - OIDC env file at /opt/casan-control-panel/oauth2-proxy.env
|
# images before `docker compose up` is allowed.
|
||||||
# - CASAN state/artifacts mounted read-write/read-only as appropriate
|
|
||||||
#
|
#
|
||||||
# oauth2-proxy must emit X-Auth-Request-User and X-Auth-Request-Groups.
|
# 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
|
# Nginx overwrites X-CASAN-* headers before proxying to the API; the API maps
|
||||||
@@ -10,11 +9,13 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
control-panel-api:
|
control-panel-api:
|
||||||
build:
|
image: ${CASAN_CP_API_IMAGE:?Set a digest-pinned CASAN_CP_API_IMAGE}
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.control-panel-api
|
|
||||||
image: casan-control-panel-api:latest
|
|
||||||
restart: unless-stopped
|
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:
|
environment:
|
||||||
CASAN_PROFILE: prod
|
CASAN_PROFILE: prod
|
||||||
CASAN_CP_STRICT: "1"
|
CASAN_CP_STRICT: "1"
|
||||||
@@ -23,26 +24,23 @@ services:
|
|||||||
CP_PORT: "3010"
|
CP_PORT: "3010"
|
||||||
CASAN_APP_ROOT: /app
|
CASAN_APP_ROOT: /app
|
||||||
volumes:
|
volumes:
|
||||||
- ./.specify:/app/.specify
|
- ${CASAN_CP_STATE_DIR:?Set CASAN_CP_STATE_DIR}:/app/.specify
|
||||||
- ./docs/output:/app/docs/output:ro
|
- ${CASAN_CP_OUTPUT_DIR:?Set CASAN_CP_OUTPUT_DIR}:/app/docs/output:ro
|
||||||
expose:
|
expose:
|
||||||
- "3010"
|
- "3010"
|
||||||
networks:
|
networks:
|
||||||
- casan-control
|
- casan-control
|
||||||
|
|
||||||
control-panel-ui:
|
control-panel-ui:
|
||||||
build:
|
image: ${CASAN_CP_UI_IMAGE:?Set a digest-pinned CASAN_CP_UI_IMAGE}
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.control-panel-ui
|
|
||||||
image: casan-control-panel-ui:latest
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- control-panel-api
|
- control-panel-api
|
||||||
- oauth2-proxy
|
- oauth2-proxy
|
||||||
ports:
|
ports:
|
||||||
- "8443:443"
|
- "${CASAN_CP_HTTPS_PORT:-443}:443"
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/casan-control-panel/tls:/etc/nginx/tls:ro
|
- ${CASAN_CP_TLS_DIR:?Set CASAN_CP_TLS_DIR}:/etc/nginx/tls:ro
|
||||||
networks:
|
networks:
|
||||||
- casan-control
|
- casan-control
|
||||||
|
|
||||||
@@ -50,8 +48,8 @@ services:
|
|||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- path: /opt/casan-control-panel/oauth2-proxy.env
|
- path: ${CASAN_CP_OAUTH_ENV:?Set CASAN_CP_OAUTH_ENV}
|
||||||
required: false
|
required: true
|
||||||
command:
|
command:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
|
|||||||
@@ -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://<fqdn>/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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -117,7 +117,6 @@ npm run console:build # backend tsc + frontend typecheck/vite build
|
|||||||
|
|
||||||
## Production-Like Smoke
|
## Production-Like Smoke
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.control-panel.yml config
|
|
||||||
bash packages/casan-control-panel/scripts/local-prod-smoke.sh
|
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
|
therefore emits both `CP_LOCAL_SMOKE_PASS ...` and
|
||||||
`CP_MANAGED_SMOKE_PASS actor=oidc-ops role=org-admin widgets=9`.
|
`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
|
Managed production readiness is intentionally a stronger, fail-closed contract: it
|
||||||
env file:
|
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
|
```bash
|
||||||
CASAN_CP_TLS_DIR=/opt/casan-control-panel/tls \
|
CASAN_CP_PROD_ENV=infra/production/casan-prod.env \
|
||||||
CASAN_CP_OAUTH_ENV=/opt/casan-control-panel/oauth2-proxy.env \
|
|
||||||
bash packages/casan-control-panel/scripts/prod-readiness-check.sh
|
bash packages/casan-control-panel/scripts/prod-readiness-check.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,91 +2,11 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Validate the managed-production Control Panel handoff without printing secrets.
|
# Validate the managed-production Control Panel handoff without printing secrets.
|
||||||
# This does not contact the enterprise IdP; it proves the host has the required
|
# The harness preflight adds Vault Transit, S3 Object Lock and digest-pinned
|
||||||
# TLS/OIDC files and that values are not still local/mock placeholders.
|
# images to the original TLS/OIDC/nginx contract.
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
COMPOSE="${CASAN_CP_COMPOSE:-$ROOT/docker-compose.control-panel.yml}"
|
ENV_FILE="${CASAN_CP_PROD_ENV:-$ROOT/infra/production/casan-prod.env}"
|
||||||
TLS_DIR="${CASAN_CP_TLS_DIR:-/opt/casan-control-panel/tls}"
|
|
||||||
OAUTH_ENV="${CASAN_CP_OAUTH_ENV:-/opt/casan-control-panel/oauth2-proxy.env}"
|
|
||||||
|
|
||||||
fail() {
|
bash "$ROOT/packages/casan-harness/scripts/bash/production-preflight.sh" "$ENV_FILE"
|
||||||
echo "CP_PROD_READINESS_FAIL $1"
|
echo "CP_PROD_READINESS_PASS production_preflight=true"
|
||||||
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"
|
|
||||||
|
|||||||
+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"
|
||||||
+101
@@ -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"
|
||||||
@@ -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 ]]
|
||||||
Reference in New Issue
Block a user