* 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.
11 KiB
Proposal System
Internal proposal management platform for Sea Haven Industries. Dispatchers submit proposal requests, AI generates draft line items from historical data via Bedrock RAG, admins review and approve in a pricing workspace, and the system produces branded PDFs for delivery.
Architecture Overview
Monorepo with five primary services:
- .NET 8 API -- Clean Architecture REST API hosted on Lambda behind API Gateway (JWT-authorized) with Function URL (AWS_IAM) for internal access
- React 19 Web -- MUI v9 admin/dispatcher workspace served via CloudFront + S3
- React Native Mobile -- iOS-first field app for dispatchers (offline-capable)
- Python Lambdas -- PDF extraction, PDF generation, library ingestion, AI suggestions, Aurora pgvector bootstrap
- CDK Infrastructure -- Three TypeScript stacks managing all AWS resources
Repository Structure
proposal-system/
├── api/ .NET 8 Web API (Lambda-hosted, EF Core + PostgreSQL)
├── web/ React 19 + MUI v9 + Vite frontend
├── mobile/ React Native 0.86 iOS app
├── lambdas/ Python 3.12 processing functions (arm64)
├── infra/ CDK TypeScript (3 stacks)
├── shared/ TypeScript API contracts (shared between web + mobile)
├── scripts/ Post-deploy and utility scripts
├── .github/ CI/CD workflows
└── docker-compose.yml
Tech Stack
| Component | Technologies |
|---|---|
| API | .NET 8, ASP.NET Core, EF Core + Npgsql, FluentValidation, Cognito JWT, Amazon.Lambda.AspNetCoreServer |
| Web | React 19, TypeScript, MUI v9, Vite, TanStack Query, react-hook-form + zod, axios |
| Mobile | React Native CLI 0.86, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), amazon-cognito-identity-js (SRP), Keychain, offline draft queue |
| Lambdas | Python 3.12, arm64, pdfplumber, reportlab, httpx, boto3 |
| Infrastructure | CDK TypeScript (aws-cdk-lib pinned exact, kept current by Dependabot) |
| AI/RAG | Bedrock Knowledge Base (Titan Embeddings v2), Aurora PostgreSQL + pgvector vector store (see ADR 0001), Claude Sonnet via Bedrock cross-region inference |
| Auth | Cognito User Pool + Google OAuth IdP (groups: dispatchers, admins, sysadmins) |
AWS Resources
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 |
|---|---|
proposal-system-foundation |
Aurora PostgreSQL 15 Serverless v2 (RDS Data API, pgvector), S3 buckets, SQS queue + DLQ, Cognito user pool, Secrets Manager |
proposal-system-compute |
API Gateway HTTP API (JWT authorizer + access logging), .NET 8 API Lambda + Function URL (AWS_IAM), Python Lambdas (pdf-extract, pdf-generate, library-ingest, suggestions, aurora-pgvector-init bootstrap), Bedrock KB (Aurora pgvector store) |
proposal-system-frontend |
CloudFront distribution (S3 OAC) |
| Resource Type | Names |
|---|---|
| S3 Buckets | proposal-system-uploads, proposal-system-generated, proposal-system-library, seahaven-ios-certificates |
| SQS | proposal-system-jobs (720s visibility, SQS-managed encryption, reportBatchItemFailures) + proposal-system-jobs-dlq (SQS-managed encryption, message body filtering by jobType) |
| Secrets | proposal-system/db-credentials, proposal-system/internal-api-key |
Local Development
Prerequisites
- .NET 8 SDK
- Node.js 24+
- Python 3.12
- PostgreSQL 16 (via docker-compose or native)
Database
docker compose up -d # starts PostgreSQL on port 5432
# database: proposalsystem, password: localdev
API
cd api
dotnet restore
dotnet run --project src/ProposalSystem.Api
# runs on http://localhost:5000
In development mode (DevMode=true in appsettings.Development.json):
- JWT auth uses a local symmetric HMAC key (no Cognito required)
- S3 service returns fake presigned URLs
- SQS publisher logs messages without sending
Web Frontend
cd web
npm install
npm run dev
# runs on http://localhost:5173, proxies /api to localhost:5000
When VITE_COGNITO_CLIENT_ID is not set, the login screen shows role-selector buttons for local development.
Infrastructure
cd infra
npm install
npx cdk synth
CI/CD
CI (on pull request to main)
Six parallel jobs calling org reusable workflows:
| Job | Workflow | What it checks |
|---|---|---|
| .NET Build & Test | ci-dotnet.yaml |
Restore, build, test the API solution (xUnit) |
| Web Frontend Check | ci-typescript-frontend.yaml |
Prettier format:check, build (includes tsc -b), vitest suite, Playwright chromium smoke (dev-login → proposal list, API mocked) |
| Mobile Typecheck | ci-typescript-cdk.yaml |
TypeScript typecheck for mobile |
| Python Lint | ci-python-sam.yaml |
ruff check + format on lambdas/ |
| Python Tests | inline job | pytest suite (37 tests — pdf-generate, suggestions, library-ingest, internal API signing) |
| CDK Synth | ci-typescript-cdk.yaml |
Synthesize CDK stacks (includes .NET publish) |
Deploy (manual)
Auto-deploy on push to main is currently disabled (PR #167) — both deploy workflows run via workflow_dispatch from the Actions tab. deploy.yaml calls the cd-cdk.yaml reusable workflow:
- Publishes .NET 8 API and Python Lambdas
- Runs
cdk deploy --all - Executes
scripts/post-deploy.sh(builds web, syncs to S3, invalidates CloudFront)
Deploy uses OIDC role githubdeploy-proposal-system. Concurrency group prevents parallel deploys.
Mobile Deploy
Workflow: deploy-mobile.yaml -- builds and uploads to TestFlight via cd-mobile-ios.yaml reusable workflow on macos-26.
Trigger: manual only (workflow_dispatch) while the app is pre-V1, to save macOS runner cost.
Mobile iOS
The iOS app uses Fastlane with match for code signing. Certificates and profiles are stored in the seahaven-ios-certificates S3 bucket (versioning enabled, public access blocked).
Build and upload to TestFlight is handled by the cd-mobile-ios.yaml reusable workflow. Required secrets:
| Secret | Purpose |
|---|---|
AWS_DEPLOY_ROLE_ARN |
OIDC role for match S3 access |
MATCH_PASSWORD |
Decryption passphrase for signing assets |
ASC_KEY_ID |
App Store Connect API key ID |
ASC_ISSUER_ID |
App Store Connect issuer |
ASC_KEY_CONTENT |
App Store Connect API key (base64) |
Authentication & Authorization
Two-layer auth architecture with defense-in-depth:
| Path | Authorizer | Authentication |
|---|---|---|
External clients → API Gateway /{proxy+} |
Cognito JWT authorizer (web + mobile client IDs) | .NET JWT middleware (ValidateAudience=true) |
/api/health |
None (public) | None |
/api/auth/callback, /api/auth/dev-login |
None (unauthenticated) | None (pre-auth endpoints) |
| Internal Lambdas → Function URL | AWS_IAM (grantInvokeUrl) | Internal API key (X-Internal-Api-Key header, value from Secrets Manager) |
Role-based access: Cognito groups (dispatchers, admins, sysadmins) map to API roles via cognito:groups claim. Dispatchers can only see their own proposals (ownership enforced in service layer). VendorProposals and GeneratedPdfs endpoints restricted to admins/sysadmins.
Internal API key: Python Lambdas call the .NET API via a Lambda Function URL with AWS_IAM auth (bypasses API Gateway JWT check). The InternalApiKeyMiddleware validates the X-Internal-Api-Key header and assigns the admins role to the synthetic identity. Lambdas cache the API key from Secrets Manager with a 5-minute TTL.
Optimistic concurrency (ADR 0004): proposal responses carry an opaque rowVersion token; mutations of the proposal aggregate (update, approve, return-to-review, send, revise, bulk line-item update) require proposalVersion in the body. Missing token → 422 ProposalVersionRequired, malformed → 422 InvalidRowVersion, stale → 409 { message, currentState } with the reloaded proposal embedded (unguarded races → 409 { status, message, code }). Line-item create/delete are token-less but bump the aggregate version. Audit rows commit atomically with their mutation (stage-then-single-SaveChanges).
Deploy note for schema changes: EF migrations auto-apply at API startup under a pg_advisory_lock. Before deploying a migration: take a manual RDS snapshot; additive-only migrations are backward-compatible with the previous Lambda version. Test the down-script against a snapshot-restored copy before any production rollback.
Data Flow
- Dispatcher submits proposal request (web or mobile)
- API creates proposal record (with advisory-locked number generation), publishes SQS message
- If vendor PDF attached:
pdf-extractLambda parses and structures data - Suggestions Lambda queries Bedrock KB for similar proposals, generates line items via Claude
- Admin reviews/edits line items in pricing workspace
- On approval:
pdf-generateLambda creates branded PDF - On send:
library-ingestLambda adds approved proposal to KB for future matching
Failed SQS messages are reported via batchItemFailures and retried up to 3 times before moving to the DLQ.
Testing
186 tests across three stacks, all run in CI on every PR:
| Suite | Framework | Count | Coverage |
|---|---|---|---|
| .NET API | xUnit | 123 | State machine transitions, authorization attributes, middleware, validators, ProposalNumberGenerator, LineItemService state guards |
| Web | vitest | 26 | ProtectedRoute, RoleGuard, API client interceptor (401 logout, token attachment) |
| Python Lambdas | pytest | 37 | pdf-generate, suggestions, library-ingest handler contracts, internal API signing |
cd api && dotnet test # .NET tests
cd web && npm test # vitest
cd lambdas && python -m pytest # pytest
Security
Hardening applied across all layers (see AUDIT-REPORT.md for full details):
- Auth: Cognito JWT validation with audience check, startup fails if auth not configured, DevMode gated to
IsDevelopment() - API: FluentValidation on all DTOs, generic error responses (no stack traces or config leaks), structured audit logging with before/after diffs
- Function URL: AWS_IAM auth + internal API key (two-layer defense)
- Infrastructure: S3
enforceSSL+BLOCK_ALL, SQS managed encryption, Aurora in private subnets, Cognito optional TOTP MFA, API Gateway access logging - Lambdas: Prompt injection sanitization, PDF size limits, numeric validation on AI suggestions, S3 key sanitization, idempotent SQS processing
- CI/CD: OIDC (no long-lived credentials), org reusable workflows (
@main),--require-approval broadeningon local deploys - Web: sessionStorage for tokens (not localStorage), error boundaries, role guards on all admin routes, 401 interceptor clears auth state