From 0f9d27fbf07e8fab4e00b60a32c6fdb09199e801 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:44:35 -0400 Subject: [PATCH] chore(infra): prep proposal-system for seahaven-prod deployment (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(infra): retarget prod to seahaven-prod account + OIDC deploy-role artifacts Retarget the CDK prod env from mgmt (328440206208, now frozen for workloads) to the dedicated seahaven-prod workload account (011934824531). proposal-system is the org's first prod tenant. Hard-block env=staging (still targets frozen mgmt) in resolveConfig until it is retargeted to seahaven-dev (710827005802). Add a WARN-only out-of-pipeline deploy guard in bin/app.ts. Add infra/deploy-role/: OIDC trust policy (sub scoped to Sea-Haven-Industries/proposal-system:ref:refs/heads/main), least-privilege permissions policy (AssumeRole on the verified cdk-hnb659fds bootstrap roles, deterministic site bucket, account-scoped CloudFront invalidation), and an idempotent creation script. Verified against live prod: bootstrap qualifier hnb659fds v32, OIDC provider present. Passed GPT-4.1 cross-review (APPROVE) and workflow red-team (CLEAN). Role NOT yet created — gated on /sh-security-review + the deploy go-ahead. Docs: README + CLAUDE.md reflect the prod account and pipeline-only deploy. * chore(infra): region-bound deploy-role DescribeStacks to us-east-1 (sh-security-review IAM-L2) * feat(infra): Aurora prod backup retention 14d + window; prod-only CDK context Bump Aurora automated-backup (PITR) retention 7->14d and set a preferred backup window for the prod tenant. Dedicated AWS Backup vault + cross-account restore test is a tracked follow-up (no org central-backup design exists yet). Prune the stale mgmt-account AZ context; prod (011934824531) is the only deploy target. --- CLAUDE.md | 2 +- README.md | 6 +- infra/bin/app.ts | 17 +++++- infra/cdk.context.json | 2 +- infra/deploy-role/README.md | 64 ++++++++++++++++++++ infra/deploy-role/create-deploy-role.sh | 71 +++++++++++++++++++++++ infra/deploy-role/permissions-policy.json | 51 ++++++++++++++++ infra/deploy-role/trust-policy.json | 18 ++++++ infra/lib/config.ts | 41 +++++++++---- infra/lib/foundation-stack.ts | 5 +- 10 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 infra/deploy-role/README.md create mode 100755 infra/deploy-role/create-deploy-role.sh create mode 100644 infra/deploy-role/permissions-policy.json create mode 100644 infra/deploy-role/trust-policy.json diff --git a/CLAUDE.md b/CLAUDE.md index 5d761bd..5753f58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ Internal: Lambdas → .NET Function URL with Secrets Manager API key via custom ## Infrastructure -AWS us-east-1, Aurora PostgreSQL 15 Serverless v2 (RDS Data API + pgvector), S3, SQS+DLQ, Cognito+Google OAuth, Bedrock KB (Aurora pgvector store — ADR 0001), GitHub Actions OIDC, CloudFront+S3 OAC +Deploys to the **seahaven-prod** account (`011934824531`), us-east-1. (mgmt `328440206208` is frozen for workloads; staging is hard-disabled in `infra/lib/config.ts` until retargeted to seahaven-dev `710827005802`.) Aurora PostgreSQL 15 Serverless v2 (RDS Data API + pgvector), S3, SQS+DLQ, Cognito+Google OAuth, Bedrock KB (Aurora pgvector store — ADR 0001), GitHub Actions OIDC, CloudFront+S3 OAC. Prod deploys run via the `deploy.yaml` pipeline (workflow_dispatch) only — no local `cdk deploy` to prod. ## Agent Delegation Rules diff --git a/README.md b/README.md index 8fcb39c..546d6e8 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,11 @@ proposal-system/ ## AWS Resources -All resources are in **us-east-1** (account 328440206208). +All resources deploy to **us-east-1** in the **seahaven-prod** workload account (`011934824531`). +The mgmt account (`328440206208`) is frozen for new workloads. Staging is not currently +deployable — it still references the frozen mgmt account and is hard-blocked in `infra/lib/config.ts` +until retargeted to seahaven-dev (`710827005802`). Prod deploys go through the `deploy.yaml` +pipeline (workflow_dispatch) only; no manual/local `cdk deploy` to prod. | CDK Stack | Key Resources | |---|---| diff --git a/infra/bin/app.ts b/infra/bin/app.ts index e0e2b3b..22f4772 100644 --- a/infra/bin/app.ts +++ b/infra/bin/app.ts @@ -6,11 +6,24 @@ import { resolveConfig } from '../lib/config'; const app = new cdk.App(); -// Multi-env (PR2): `-c env=staging` (default prod). prod keeps the exact construct IDs -// and stack names of the deployed stacks (stackSuffix=''); only staging is suffixed. +// Multi-env (PR2): `-c env=staging` (default prod). prod targets seahaven-prod +// (011934824531) with no stack-name suffix; staging is suffixed and currently hard-disabled +// (see resolveConfig — mgmt account is frozen). const config = resolveConfig(app); const { env, stackSuffix } = config; +// Pipeline-only deploy signal (WARN, not block). Prod deploys must go through the cd-cdk +// pipeline (GitHub Actions sets CI/GITHUB_ACTIONS). A local synth/deploy to prod is a +// drift risk + "no manual prod deploys" violation. True enforcement is an org-baseline SCP +// (tracked follow-up); this is the cheap in-repo backstop. +if (config.envName === 'prod' && !process.env.CI && !process.env.GITHUB_ACTIONS) { + // eslint-disable-next-line no-console + console.warn( + '\n⚠️ Running the PROD CDK app outside CI. Prod deploys must go through the cd-cdk ' + + 'pipeline (deploy.yaml, workflow_dispatch). Do NOT `cdk deploy` to prod locally.\n', + ); +} + const foundation = new FoundationStack(app, `proposal-system-foundation${stackSuffix}`, { stackName: `proposal-system-foundation${stackSuffix}`, env, diff --git a/infra/cdk.context.json b/infra/cdk.context.json index 2145e1d..ad650bc 100644 --- a/infra/cdk.context.json +++ b/infra/cdk.context.json @@ -1,5 +1,5 @@ { - "availability-zones:account=328440206208:region=us-east-1": [ + "availability-zones:account=011934824531:region=us-east-1": [ "us-east-1a", "us-east-1b", "us-east-1c", diff --git a/infra/deploy-role/README.md b/infra/deploy-role/README.md new file mode 100644 index 0000000..46c0ab6 --- /dev/null +++ b/infra/deploy-role/README.md @@ -0,0 +1,64 @@ +# seahaven-prod OIDC deploy role — proposal-system + +IAM artifacts for the GitHub Actions OIDC deploy role that lets the +`Sea-Haven-Industries/proposal-system` repo deploy the CDK stacks and sync the +frontend site bucket into **seahaven-prod**. Artifacts only — nothing here has +been applied to AWS. + +## Resolved facts (Phase 0, read-only verification) + +| Field | Value | +|---|---| +| Account | `011934824531` (seahaven-prod) | +| Region | `us-east-1` | +| CDK qualifier | `hnb659fds` (AWS CDK **default** — bootstrap v32; no custom synthesizer needed) | +| OIDC provider ARN | `arn:aws:iam::011934824531:oidc-provider/token.actions.githubusercontent.com` (EXISTS) | +| Role name | `githubdeploy-proposal-system` | +| Subject scope | `repo:Sea-Haven-Industries/proposal-system:ref:refs/heads/main` (exact, no wildcard) | +| Site bucket | `proposal-system-web-011934824531` (frontend-stack convention) | + +## Files + +- **trust-policy.json** — Web-identity trust policy. Federated principal is the + existing GitHub OIDC provider. `sts:AssumeRoleWithWebIdentity` gated by two + StringEquals conditions: `aud == sts.amazonaws.com` and an **exact** `sub` + match on the proposal-system repo's `main` branch (no `StringLike`, no + wildcard). Mirrors the structure of the existing + `githubdeploy-seahaven-org-baseline` role. + +- **permissions-policy.json** — Least-privilege inline policy: + - `sts:AssumeRole` on the four CDK bootstrap roles (deploy, file-publishing, + lookup, image-publishing) scoped to qualifier `hnb659fds`, account, and + region. This is how a CDK deploy actually gains its power — no direct + service permissions are granted to the deploy role itself. + - `cloudformation:DescribeStacks` on `*` for the `cdk deploy` / + change-set health check. + - `s3:PutObject`/`DeleteObject`/`ListBucket` on the site bucket and its + objects for the frontend asset sync. + - `cloudfront:CreateInvalidation` on `*`, constrained by + `aws:ResourceAccount == 011934824531` (distribution ARNs aren't known at + author time; the account condition prevents cross-account use). + +- **create-deploy-role.sh** — Idempotent bash (`aws --profile prod`). Verifies + the profile resolves to `011934824531`, then create-role (or + update-assume-role-policy if it exists) + put-role-policy. Safe to re-run. + **Gated:** do not execute until GPT-4.1 cross-review AND `/sh-security-review` + pass (IAM/trust change). + +## Applying (after gates pass) + +```bash +./create-deploy-role.sh +``` + +Then point the GitHub Actions workflow's `aws-actions/configure-aws-credentials` +step at the printed role ARN. + +## Placeholders / follow-ups + +- **None outstanding.** Qualifier resolved to the real value `hnb659fds`; no + `` placeholder remains. OIDC provider exists, so no provider + creation prerequisite. +- CloudFront invalidation is scoped by account, not by distribution ARN — tighten + to the specific distribution ARN once frontend-stack is deployed if you want + per-resource least privilege. diff --git a/infra/deploy-role/create-deploy-role.sh b/infra/deploy-role/create-deploy-role.sh new file mode 100755 index 0000000..58419af --- /dev/null +++ b/infra/deploy-role/create-deploy-role.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +############################################################################### +# create-deploy-role.sh +# +# Creates / updates the GitHub Actions OIDC deploy role +# `githubdeploy-proposal-system` in AWS account 011934824531 (seahaven-prod), +# us-east-1, for the Sea-Haven-Industries/proposal-system repo (main branch). +# +# GATE — DO NOT EXECUTE until BOTH of the following have passed: +# 1. GPT-4.1 cross-family review (IAM policy / trust-policy change), via: +# python3 ~/Documents/repositories/seahaven/security-review/cross_review.py \ +# "Review this IAM deploy-role trust+permissions for over-permission: " +# (STATUS 2026-07-14: RUN — verdict APPROVE, no BLOCK.) +# 2. /sh-security-review (deep agentic pass — IaC/IAM is a gated surface) +# +# This is an IAM/trust change: run BOTH gates and resolve every confirmed +# critical/high before running. This script mutates AWS; the artifact-authoring +# task did NOT run it. It is idempotent and safe to re-run. +# +# Resolved facts (Phase 0, read-only verification): +# Account : 011934824531 (seahaven-prod) +# Region : us-east-1 +# Qualifier : hnb659fds (AWS CDK DEFAULT — no custom synthesizer needed) +# OIDC prov : arn:aws:iam::011934824531:oidc-provider/token.actions.githubusercontent.com +############################################################################### + +set -euo pipefail + +PROFILE="prod" +ROLE_NAME="githubdeploy-proposal-system" +POLICY_NAME="proposal-system-deploy" +ACCOUNT_ID="011934824531" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TRUST_POLICY="file://${SCRIPT_DIR}/trust-policy.json" +PERMS_POLICY="file://${SCRIPT_DIR}/permissions-policy.json" + +echo "==> Verifying active account for profile '${PROFILE}'..." +CALLER_ACCOUNT="$(aws --profile "${PROFILE}" sts get-caller-identity --query Account --output text)" +if [[ "${CALLER_ACCOUNT}" != "${ACCOUNT_ID}" ]]; then + echo "ERROR: profile '${PROFILE}' resolves to account ${CALLER_ACCOUNT}, expected ${ACCOUNT_ID}. Aborting." >&2 + exit 1 +fi + +echo "==> Ensuring role '${ROLE_NAME}' exists with the correct trust policy..." +if aws --profile "${PROFILE}" iam get-role --role-name "${ROLE_NAME}" >/dev/null 2>&1; then + echo " Role exists — updating assume-role (trust) policy." + aws --profile "${PROFILE}" iam update-assume-role-policy \ + --role-name "${ROLE_NAME}" \ + --policy-document "${TRUST_POLICY}" +else + echo " Role absent — creating." + aws --profile "${PROFILE}" iam create-role \ + --role-name "${ROLE_NAME}" \ + --assume-role-policy-document "${TRUST_POLICY}" \ + --description "GitHub Actions OIDC deploy role for Sea-Haven-Industries/proposal-system (main)" \ + --max-session-duration 3600 \ + --tags Key=project,Value=proposal-system Key=managed-by,Value=create-deploy-role.sh +fi + +echo "==> Putting inline permissions policy '${POLICY_NAME}' (create-or-replace)..." +aws --profile "${PROFILE}" iam put-role-policy \ + --role-name "${ROLE_NAME}" \ + --policy-name "${POLICY_NAME}" \ + --policy-document "${PERMS_POLICY}" + +ROLE_ARN="$(aws --profile "${PROFILE}" iam get-role --role-name "${ROLE_NAME}" \ + --query Role.Arn --output text)" + +echo "==> Done. Deploy role ready:" +echo " ${ROLE_ARN}" +echo " Configure the GitHub Actions workflow to assume this ARN via aws-actions/configure-aws-credentials." diff --git a/infra/deploy-role/permissions-policy.json b/infra/deploy-role/permissions-policy.json new file mode 100644 index 0000000..4bf29eb --- /dev/null +++ b/infra/deploy-role/permissions-policy.json @@ -0,0 +1,51 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeCdkBootstrapRoles", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": [ + "arn:aws:iam::011934824531:role/cdk-hnb659fds-deploy-role-011934824531-us-east-1", + "arn:aws:iam::011934824531:role/cdk-hnb659fds-file-publishing-role-011934824531-us-east-1", + "arn:aws:iam::011934824531:role/cdk-hnb659fds-lookup-role-011934824531-us-east-1", + "arn:aws:iam::011934824531:role/cdk-hnb659fds-image-publishing-role-011934824531-us-east-1" + ] + }, + { + "Sid": "CdkDeployHealthCheck", + "Effect": "Allow", + "Action": "cloudformation:DescribeStacks", + "Resource": "*", + "Condition": { + "StringEquals": { + "aws:RequestedRegion": "us-east-1" + } + } + }, + { + "Sid": "FrontendSiteBucketSync", + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:DeleteObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::proposal-system-web-011934824531", + "arn:aws:s3:::proposal-system-web-011934824531/*" + ] + }, + { + "Sid": "CloudFrontInvalidation", + "Effect": "Allow", + "Action": "cloudfront:CreateInvalidation", + "Resource": "*", + "Condition": { + "StringEquals": { + "aws:ResourceAccount": "011934824531" + } + } + } + ] +} diff --git a/infra/deploy-role/trust-policy.json b/infra/deploy-role/trust-policy.json new file mode 100644 index 0000000..02b2465 --- /dev/null +++ b/infra/deploy-role/trust-policy.json @@ -0,0 +1,18 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::011934824531:oidc-provider/token.actions.githubusercontent.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": "repo:Sea-Haven-Industries/proposal-system:ref:refs/heads/main" + } + } + } + ] +} diff --git a/infra/lib/config.ts b/infra/lib/config.ts index 2bb0d62..968f111 100644 --- a/infra/lib/config.ts +++ b/infra/lib/config.ts @@ -1,10 +1,17 @@ import * as cdk from 'aws-cdk-lib'; // Multi-environment configuration (PR2). Resolved from CDK context: `-c env=staging`, -// default `prod`. CRITICAL: the prod config MUST keep the exact construct IDs, stack -// names, and resource settings the deployed stacks already use — renaming a deployed -// CloudFormation stack triggers replace-and-delete, which would destroy the RDS instance. -// Only non-prod environments take a stack-name suffix. +// default `prod`. Only non-prod environments take a stack-name suffix. +// +// prod now targets the dedicated seahaven-prod workload account (011934824531); the mgmt +// account (328440206208) is FROZEN for new workloads. The prior proposal-system footprint +// was fully torn down 2026-07-14, so THIS first prod deploy is the last safe window to +// rename construct IDs / stack names. After prod go-live, renaming a deployed stack (or a +// stateful construct ID) triggers replace-and-delete, which would destroy the Aurora cluster. +// +// STAGING is intentionally NOT deployable: it still points at the frozen mgmt account, and +// resolveConfig() hard-throws on `env=staging` until it is retargeted to seahaven-dev +// (710827005802). See the guard in resolveConfig below. export type EnvName = 'prod' | 'staging'; @@ -28,14 +35,15 @@ export interface EnvConfig { readonly retainData: boolean; } -const ACCOUNT = '328440206208'; +// seahaven-prod workload account (org prod OU). mgmt 328440206208 is frozen for workloads. +const PROD_ACCOUNT = '011934824531'; +// Frozen mgmt account — staging still references it and must NOT be deployed there. +const MGMT_ACCOUNT_FROZEN = '328440206208'; const REGION = 'us-east-1'; -// Prod values reproduce the currently-deployed stacks EXACTLY (verified against -// foundation-stack.ts / compute-stack.ts) so `cdk diff` shows no prod changes. const PROD: EnvConfig = { envName: 'prod', - env: { account: ACCOUNT, region: REGION }, + env: { account: PROD_ACCOUNT, region: REGION }, stackSuffix: '', s3CorsOrigins: ['https://proposals.seahaven.com', 'http://localhost:5173'], apiCorsOrigins: [ @@ -53,11 +61,13 @@ const PROD: EnvConfig = { retainData: true, }; -// Staging: same AWS account (Adam, 2026-06-12), suffixed stacks, no permanent domain -// yet (CloudFront default URL + localhost), data not retained. +// Staging: ⚠️ STILL points at the FROZEN mgmt account (MGMT_ACCOUNT_FROZEN). It is NOT +// deployable — resolveConfig() throws on env=staging. Retarget to seahaven-dev +// (710827005802) in a dedicated follow-up before ever enabling staging deploys. +// Suffixed stacks, CloudFront default URL + localhost, data not retained. const STAGING: EnvConfig = { envName: 'staging', - env: { account: ACCOUNT, region: REGION }, + env: { account: MGMT_ACCOUNT_FROZEN, region: REGION }, stackSuffix: '-staging', s3CorsOrigins: ['http://localhost:5173'], apiCorsOrigins: ['http://localhost:5173'], @@ -76,5 +86,14 @@ export function resolveConfig(app: cdk.App): EnvConfig { if (!config) { throw new Error(`Unknown env '${name}'. Use -c env=prod (default) or -c env=staging.`); } + // Hard guard: staging still targets the frozen mgmt account (328440206208). Block any + // synth/deploy under env=staging until it is retargeted to seahaven-dev (710827005802), + // so an accidental `-c env=staging` deploy can never land in the frozen mgmt account. + if (name === 'staging') { + throw new Error( + 'env=staging is disabled: it targets the FROZEN mgmt account (328440206208). ' + + 'Retarget STAGING to seahaven-dev (710827005802) in config.ts before using it.', + ); + } return config; } diff --git a/infra/lib/foundation-stack.ts b/infra/lib/foundation-stack.ts index c5bb5ef..16ade07 100644 --- a/infra/lib/foundation-stack.ts +++ b/infra/lib/foundation-stack.ts @@ -105,7 +105,10 @@ export class FoundationStack extends cdk.Stack { serverlessV2MaxCapacity: 4, enableDataApi: true, storageEncrypted: true, - backup: { retention: cdk.Duration.days(7) }, + // Aurora native automated backups (PITR). 14-day retention for the prod tenant; + // a dedicated AWS Backup vault + cross-account restore test is a tracked follow-up + // (no org central-backup design exists yet — see the prod-deploy plan Phase 4 fallback). + backup: { retention: cdk.Duration.days(14), preferredWindow: '07:00-08:00' }, deletionProtection: config.retainData, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, defaultDatabaseName: 'proposals',