diff --git a/.coveragerc b/.coveragerc index 4246bb0..658f138 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,4 +1,3 @@ [run] omit = */tests/* - */cdk.out/* diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e09e5ac..ec6764a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,5 @@ version: 2 updates: - - package-ecosystem: "pip" - directory: "/cdk" - schedule: - interval: "weekly" - groups: - minor-and-patch: - update-types: - - "minor" - - "patch" - commit-message: - prefix: "chore(deps)" - package-ecosystem: "pip" directory: "/lambdas/po/email_processor" schedule: @@ -99,3 +88,14 @@ updates: - "patch" commit-message: prefix: "chore(deps)" + - package-ecosystem: "terraform" + directory: "/terraform" + schedule: + interval: "weekly" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + commit-message: + prefix: "chore(deps)" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b2542db..1c21a73 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,9 +10,10 @@ jobs: ci: uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3 with: - source-dirs: "lambdas cdk tests scripts" + source-dirs: "lambdas tests scripts" run-tests: true - run-cdk-synth: true + # Deploy path is HCP Terraform; reusable name still covers Python lint/tests. + run-cdk-synth: false run-sam-validate: false # The published OpenAPI contract must stay compliant with redocly.yaml; # this is the CI mirror of the local `npm run lint:api`. diff --git a/.gitignore b/.gitignore index 1bf4d63..4af88db 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ build/ .venv/ venv/ node_modules/ -cdk.out/ .env *.eml # Test fixtures: scrubbed Hexagon sample emails are committed so the WO template diff --git a/README.md b/README.md index f646767..6967c4c 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ A read-only REST API (API Gateway + the `procurement-api` Lambda, `lambdas/api/` - **Spec is source of truth:** `lambdas/api/openapi.json` (OpenAPI 3.1). Its top-level `webhooks` section documents the outbound SHOC work-order feed, so one page describes both directions (call + be-called). `tests/test_api_spec_drift.py` pins the spec's paths to the router table, so spec and implementation cannot drift. - **Auth:** data routes use API Gateway `AWS_IAM` (SigV4) plus a resource policy allowing exactly `arn:aws:iam::396287094661:role/shoc-backend-dev` on `GET/*`; same-account admin callers authorize via identity policy (Postman signs SigV4 natively). Docs routes are auth `NONE` at the gateway (resource-policy carve-out for exactly those two GETs) but the handler fails closed on the shared token (`lambdas/shared/web_ui_auth.py`, secret `procurement-ingest/web-ui-auth-token`) — not an unauthenticated data path (INFRA-74 posture). -- **Custom domain:** `https://procurement-api.seahaven.com` (REGIONAL API Gateway domain, TLS 1.2, empty base-path mapping to the `prod` stage, so callers hit `/work-orders` with no `/prod` segment). The stable SHOC-facing endpoint; the raw `*.execute-api.us-east-1.amazonaws.com/prod` URL still works. **Cross-account DNS:** the `seahaven.com` public zone is in the mgmt account (`328440206208`), so the ACM cert's validation record and the A-alias are added there out of band — `scripts/setup_procurement_api_domain.sh cert` issues the cert (DNS-validated against the mgmt zone) and writes its ARN to prod SSM `/procurement-api/custom-domain/certificate-arn`, which the stack reads (`value_for_string_parameter`, CFN-resolved at deploy); after `cdk deploy procurement-api`, `scripts/setup_procurement_api_domain.sh alias` adds the A-alias from the stack's `ProcurementApiAliasTarget`/`ProcurementApiAliasHostedZoneId` outputs. SigV4 is unaffected (same underlying API id + resource policy); the docs page injects whichever host served the request into `servers[0].url`. +- **Custom domain:** `https://procurement-api.seahaven.com` (REGIONAL API Gateway domain, TLS 1.2, empty base-path mapping to the `prod` stage, so callers hit `/work-orders` with no `/prod` segment). The stable SHOC-facing endpoint; the raw `*.execute-api.us-east-1.amazonaws.com/prod` URL still works. **Cross-account DNS:** the `seahaven.com` public zone is in the mgmt account (`328440206208`), so the ACM cert's validation record and the A-alias are added there out of band — `scripts/setup_procurement_api_domain.sh cert` issues the cert (DNS-validated against the mgmt zone) and writes its ARN to prod SSM `/procurement-api/custom-domain/certificate-arn`, which Terraform reads (`data.aws_ssm_parameter` in `terraform/api.tf`); after an HCP apply that owns the API Gateway DomainName, `scripts/setup_procurement_api_domain.sh alias` adds the A-alias from API Gateway `get-domain-name` (`regionalDomainName` / `regionalHostedZoneId`). SigV4 is unaffected (same underlying API id + resource policy); the docs page injects whichever host served the request into `servers[0].url`. - **KMS:** `purchase-orders` is CMK-encrypted; the imported-by-name table doesn't carry the key association, so the stack grants `kms:Decrypt`/`DescribeKey` on the CMK from SSM `/seahaven/dynamodb/cmk-arn` explicitly (the INFRA-104 failure class). - **No access logging in v1** (keeps the `?token=` shim out of any log and avoids the account-level API Gateway CloudWatch role); rotate the docs token before ever enabling it. No CORS (server-to-server + Postman callers). - **Alarms:** `procurement-api-errors`/`-throttles`/`-duration` (p99 ≥ 22.5 s) + gateway `procurement-api-5xx`, all ALARM-only → `site-alerts`. No 4XX alarm (401/403/404 are expected traffic). @@ -119,37 +119,31 @@ A read-only REST API (API Gateway + the `procurement-api` Lambda, `lambdas/api/` ## Architecture -**IaC:** Terraform under `terraform/` (HCP remote apply, Manual until sealed). Workspace `procurement-ingest-prod` in project `seahaven-prod` is the sole mutate path for live resources in seahaven-prod `011934824531` / `us-east-1` (PLAT-86 import-in-place). Former CDK stacks (`po-ingest`, `WorkorderIngestStack`, `procurement-api`) are historical; `cdk/` remains for reference/synth until a follow-up hygiene pass. Never apply against mgmt `328440206208` — PLAT-67 left cold-archive RETAIN leftovers there only. +**IaC:** Terraform under `terraform/` (HCP remote apply, Manual until sealed). Workspace `procurement-ingest-prod` in project `seahaven-prod` is the sole mutate path for live resources in seahaven-prod `011934824531` / `us-east-1` (PLAT-86 import-in-place). Former CDK stacks (`po-ingest`, `WorkorderIngestStack`, `procurement-api`) are historical and removed from the tree (PLAT-89). Never apply against mgmt `328440206208` — PLAT-67 left cold-archive RETAIN leftovers there only. All Lambdas: Python 3.12, ARM64, 60-day log retention. **LLM provider — Amazon Bedrock.** Both email processors call Claude Haiku 4.5 through the Bedrock inference profile `us.anthropic.claude-haiku-4-5-20251001-v1:0` (`bedrock-runtime` `InvokeModel`), configured via the `BEDROCK_MODEL_ID` env var. There is **no Anthropic API key and no Secrets Manager secret** any more. Each processor role is granted `bedrock:InvokeModel` + `bedrock:InvokeModelWithResponseStream` on **both** the inference-profile ARN **and** the per-region foundation-model ARNs for `us-east-1` / `us-east-2` / `us-west-2` (empty-account foundation-model ARNs) — the `us.*` profile can route cross-region, so a profile-only grant would `AccessDenied` at runtime under load. -> **Retired secrets (manual cleanup outstanding):** the former secrets `po-ingest/anthropic-api-key` and `workorder-ingest/anthropic-api-key` had `RemovalPolicy.RETAIN`, so removing them from the CDK stacks **orphans** them rather than deleting them. Delete both by hand post-deploy and revoke the stored keys at the provider. The processors no longer read any `ANTHROPIC_API_KEY_SECRET_ARN` — authentication to Bedrock is via the Lambda execution-role IAM grant, so there is no provider API key or Secrets Manager fetch on the parse path. +> **Retired secrets (manual cleanup outstanding):** the former secrets `po-ingest/anthropic-api-key` and `workorder-ingest/anthropic-api-key` were retained when removed from IaC, so they were **orphaned** rather than deleted. Delete both by hand and revoke the stored keys at the provider. The processors no longer read any `ANTHROPIC_API_KEY_SECRET_ARN` — authentication to Bedrock is via the Lambda execution-role IAM grant, so there is no provider API key or Secrets Manager fetch on the parse path. -**SES:** Both stacks add rules to the shared `INBOUND_MAIL` receipt rule set on `int.seahaven.com`. +**SES:** Both pipelines add rules to the shared `INBOUND_MAIL` receipt rule set on `int.seahaven.com`. -### Shared CDK helpers (`cdk/common.py`, Phase 4) +### Shared alarms and packaging helpers -The ~379 lines that `po_stack.py` and `wo_stack.py` both defined identically (DynamoDB alarms, the sender-auth-rejected metric filter + alarm, the standard per-Lambda alarm set, the Bedrock `InvokeModel` grant, the raw-email bucket, the async-invoke DLQ, and the template-fallback-rate math alarm) are collapsed into `cdk/common.py`. +DynamoDB alarms, the sender-auth-rejected metric filter + alarm, the standard per-Lambda alarm set, the Bedrock `InvokeModel` grant, the raw-email bucket, the async-invoke DLQ, and the template-fallback-rate math alarm live under `terraform/` (`po_*.tf`, `wo_*.tf`, `api*.tf`). Lambda zip packaging is `terraform/build_packages.sh` (non-recursive `copy_py_dir` + `copy_shared_all` / selective `copy_src_file`), gated by `tests/test_bundle_consistency.py`. -**Logical-ID-safety rule (load-bearing).** Every helper is a **plain function** taking `(scope, id, ...)` — never a `Construct` subclass. Each stack calls a helper with its **own Stack instance as `scope`** and the **exact same literal construct id** it used inline before the extraction, so every synthesized logical ID is byte-stable. A `Construct` subclass would insert an extra tree node, reparent every child's logical ID, and CloudFormation would attempt to **replace** the `RemovalPolicy.RETAIN`-protected `purchase-orders` / `WorkOrders` / `WorkOrderComments` tables and the named S3 buckets — a data-loss event. Nothing in `common.py` subclasses `Construct`, and it imports only the CDK constructs its helpers touch (`dynamodb`, `cloudwatch`/`cw_actions`, `iam`, `logs`, `s3`, `sqs` — no `kms`, `ssm`, or Lambda event-source imports). - -Extracted helpers: `add_ddb_alarms`, `add_sender_auth_rejected_alarm`, `add_standard_lambda_alarms` (bespoke `descriptions=` dict passed through verbatim per call site — no alarm text is generated or homogenized), `make_bedrock_invoke_statement`, `make_email_bucket`, `make_processor_dlq`, `make_fallback_rate_alarm`. Per-function alarm variance is preserved exactly through call-site arguments, not baked into the helpers: PO's email-processor duration alarm uses `p99`, WO's uses `p95`; `po-web-ui` gets throttles + duration only (no errors, no DLQ); `po-ingest-site-extractor` has no DLQ alarm (it's a DynamoDB-stream consumer, not async-invoked); `workorder-web-ui` gets **zero** alarms — the helper is simply never called for it, so it cannot silently add any. The two "AI-fallback-rejected" alarms (PO's 6-hour count-floor `IF(FILL(rej,0)>=1,...)`, WO's 5-minute/2-of-6 sparse idiom) are a different shape from `make_fallback_rate_alarm` and stay as distinct inline call sites in each stack rather than being forced through the shared helper. - -**Bedrock ARN, now account/region-derived.** `make_bedrock_invoke_statement(scope)` builds the inference-profile ARN and the `us-east-1` foundation-model ARN from `Stack.of(scope).account` / `.region` instead of the previous hardcoded `328440206208`/`us-east-1` literals (the `us-east-2`/`us-west-2` foundation-model ARNs stay literal — they're fixed cross-region reach targets, not the stack's own region). Because the environment is account-agnostic (above), `stack.account` is the `AWS::AccountId` pseudo-parameter, so the derived ARN synthesizes as an `Fn::Sub`/`Ref` that **resolves at deploy time to the same ARN** the hardcoded literal named in-account. Versus the deployed stack this is a benign **in-place** IAM policy update (IAM policies never require replacement) — and it makes the grant account-portable instead of pinned to the management account. This IAM PolicyStatement change is the surface the mandatory GPT-4.1 cross-family review covers. - -**New `CfnOutput`s.** Each stack now exports its Lambda function ARNs and the DynamoDB table names it owns/consumes, all net-new/additive (no existing output changes): `po-ingest` — `EmailProcessorFunctionArn`, `WebUiFunctionArn`, `SiteExtractorFunctionArn`, `PurchaseOrdersTableName`, `PendingSiteReviewTableName` (the existing `VerifiedSitesTableName` output is unchanged); `workorder-ingest` — `EmailProcessorFunctionArn`, `WebUiFunctionArn`, `WorkOrdersTableName`, `WorkOrderCommentsTableName`. +Per-function alarm variance is preserved: PO's email-processor duration alarm uses `p99`, WO's uses `p95`; `po-web-ui` gets throttles + duration only (no errors, no DLQ); `po-ingest-site-extractor` has no DLQ alarm (it's a DynamoDB-stream consumer, not async-invoked); `workorder-web-ui` gets **zero** alarms. ### Sender authentication (INFRA-107) -The `From` header and any `Authentication-Results` header inside the raw MIME are attacker-forgeable, so neither is trusted. Instead, both email processors authenticate the sender against the verdicts SES itself stamps at delivery time, failing closed. The authenticator is **single-sourced** at `lambdas/shared/ses_auth.py` (Phase 3) — previously duplicated byte-for-byte in each pipeline's `email_processor/` dir and kept in sync by a fixture-hygiene test; now one copy, so a future hardening fix to the fail-closed logic lands **once** instead of needing two identical edits. The CDK bundling `cp`s it flat beside each handler so the handlers' unchanged `from ses_auth import authenticate_inbound_email` still resolves at runtime (see [`lambdas/shared/`](#deploy-pipeline-guards-phase-0)). The gate: +The `From` header and any `Authentication-Results` header inside the raw MIME are attacker-forgeable, so neither is trusted. Instead, both email processors authenticate the sender against the verdicts SES itself stamps at delivery time, failing closed. The authenticator is **single-sourced** at `lambdas/shared/ses_auth.py` (Phase 3) — previously duplicated byte-for-byte in each pipeline's `email_processor/` dir and kept in sync by a fixture-hygiene test; now one copy, so a future hardening fix to the fail-closed logic lands **once** instead of needing two identical edits. Packaging copies it flat beside each handler (`copy_shared_all` in `terraform/build_packages.sh`) so the handlers' unchanged `from ses_auth import authenticate_inbound_email` still resolves at runtime (see [`lambdas/shared/`](#deploy-pipeline-guards-phase-0)). The gate: 1. Take **only the topmost** `Authentication-Results` header (SES prepends its trace headers; any lower copies arrived inside the message and are ignored). 2. Require its authserv-id to be `amazonses.com`. 3. Require a `dkim=pass` clause whose `header.d=`/`header.i=` domain is in the pipeline's allowlist. -The allowlist is the `ALLOWED_DKIM_DOMAINS` Lambda environment variable (comma-separated, set per stack in CDK — no code change needed to adjust): +The allowlist is the `ALLOWED_DKIM_DOMAINS` Lambda environment variable (comma-separated, set per pipeline in Terraform — no code change needed to adjust): | Pipeline | `ALLOWED_DKIM_DOMAINS` | Why | |---|---|---| @@ -162,11 +156,11 @@ The allowlist is the `ALLOWED_DKIM_DOMAINS` Lambda environment variable (comma-s On any failure (env var unset, header missing/unparseable, verdict fail, unaligned domain) the processor logs a structured `sender_auth_rejected` warning with the reason and S3 key, skips the email, and returns normally — rejected mail never triggers Lambda retries or DLQ messages, but the `-sender-auth-rejected` CloudWatch alarm (see [CloudWatch alarms](#cloudwatch-alarms)) pages on a rejection spike so a drift-induced outage is not silent. Unit tests live in `tests/test_ses_auth.py`. -**Failure handling (INFRA-41):** Each email-processor is async-invoked (S3 → Lambda). Both have a CDK-managed SQS dead-letter queue (`dead_letter_queue=`, 14-day retention, SSL-enforced) so a failed parse is captured rather than silently dropped after Lambda's retries. +**Failure handling (INFRA-41):** Each email-processor is async-invoked (S3 → Lambda). Both have a Terraform-managed SQS dead-letter queue (14-day retention, SSL-enforced) so a failed parse is captured rather than silently dropped after Lambda's retries. ### CloudWatch alarms -Every alarm is **ALARM-only** (no OK action), sends to the shared `site-alerts` SNS topic (imported once per stack via `Topic.from_topic_arn`), and uses `TreatMissingData.NOT_BREACHING`. +Every alarm is **ALARM-only** (no OK action), sends to the shared `site-alerts` SNS topic, and uses treat-missing-data not-breaching. **Lambda alarms** (`AWS/Lambda`, `FunctionName` dimension): @@ -177,7 +171,7 @@ Every alarm is **ALARM-only** (no OK action), sends to the shared `site-alerts` | `-duration` | `po-email-processor`, `po-ingest-site-extractor`, `po-web-ui`, `workorder-shoc-emitter`, `workorder-shoc-hmac-rotator` (p99); `workorder-email-processor` (p95) | `Duration` percentile, 5 min, `>= 45000` ms (75% of the 60s timeout), eval 3 / datapoints 2 | | `-sender-auth-rejected` | `po-email-processor`, `workorder-email-processor` | Log-metric-filter count (namespace `Seahaven/ProcurementIngest`, `default_value=0`) on `sender_auth_rejected` warnings, `Sum` 5 min, `>= 1`, eval 3 / datapoints 2 | -The `-sender-auth-rejected` alarm closes the silent-drop gap in INFRA-107: a rejected email returns normally (no error, no retry, no DLQ message), so without a log-metric filter a signing-domain drift or a wrong allowlist would discard 100% of legitimate mail while every other alarm stayed green. It counts `sender_auth_rejected` warnings per 5-minute period (`default_value=0` keeps the series continuous) and pages when 2 of the last 3 periods each see at least one rejection — a lone stray spoof probe to the internal ingest address self-clears, but a sustained false-reject storm pages within ~10–15 minutes even at low mail volume; the config is easy to tune in the CDK helper. (A residual gap remains for a *very* sparse total-reject outage — see the SES-AR-01/02 hardening issue.) +The `-sender-auth-rejected` alarm closes the silent-drop gap in INFRA-107: a rejected email returns normally (no error, no retry, no DLQ message), so without a log-metric filter a signing-domain drift or a wrong allowlist would discard 100% of legitimate mail while every other alarm stayed green. It counts `sender_auth_rejected` warnings per 5-minute period (`default_value=0` keeps the series continuous) and pages when 2 of the last 3 periods each see at least one rejection — a lone stray spoof probe to the internal ingest address self-clears, but a sustained false-reject storm pages within ~10–15 minutes even at low mail volume; the config is easy to tune in Terraform. (A residual gap remains for a *very* sparse total-reject outage — see the SES-AR-01/02 hardening issue.) The `-duration` and `-throttles` alarms for `po-email-processor` and `workorder-email-processor` supersede the orphaned, CLI-created `Lambda-Duration-*` / `Lambda-Throttles-*` alarms (deleted post-deploy). @@ -219,22 +213,7 @@ This placement is deliberate, not incidental: real mail always arrives as an S3 The script runs `set -euo pipefail` and exits non-zero on any invoke failure, any `FunctionError`, or a payload mismatch, failing the apply gate. -**PO bundling: glob replaces the hand-maintained allowlist.** `cdk/po_stack.py`'s asset bundling command ships PO's Lambda source with a non-recursive glob instead of a hand-maintained list of filenames (`cp handler.py ses_auth.py template_parser.py derived_fields.py /asset-output/`). The glob is functionally identical for today's file set — non-recursive, so `tests/` and other subdirectories are still excluded — but structurally eliminates the failure mode that shipped a broken bundle twice (PR #105 omitted `template_parser.py`; PR #2 nearly omitted `derived_fields.py`): a new sibling module the handler imports now ships automatically instead of requiring someone to remember to add it to the list. - -**Phase 2: widened asset root, both processors on the glob.** Both `cdk/po_stack.py` and `cdk/wo_stack.py` widen their bundled email-processor's `Code.from_asset` root from the per-pipeline dir (`../lambdas/po/email_processor`, `../lambdas/wo/email_processor`) to the shared parent, `../lambdas` — the prerequisite for the Phase 3 `lambdas/shared/` extraction, which needs a bundling root able to reach a sibling `shared/` package outside either pipeline's own dir (this move ships **zero handler code changes** — `git diff -- lambdas/` is empty for this PR). With bundling present, CDK mounts the asset root as the container's working directory, so both the `pip install -r` path and the `cp` source operand became repo-relative to `lambdas/`: `-r po/email_processor/requirements.txt` (resp. `wo/email_processor/requirements.txt`) and `cp po/email_processor/*.py /asset-output/` (resp. `cp wo/email_processor/*.py /asset-output/`). The pip `--platform manylinux2014_aarch64 --only-binary=:all:` pin — removing it once shipped x86 wheels into the ARM64 function and caused a total outage (PR #34) — is preserved byte-for-byte on both. - -Both bundled `from_asset` calls also gain `exclude=['**/__pycache__/**', '**/tests/**', '**/package/**']`. This is load-bearing, not cosmetic: `Code.from_asset` does not honor `.gitignore`, and widening the root to `../lambdas` means the untracked, 44 MB `lambdas/po/email_processor/package/` dir (a stale vendored dependency tree; deletion is a separate, deliberate call — not part of this change) would otherwise be staged into the *source fingerprint* `from_asset` hashes to decide whether to re-bundle. Because CI never has that local-only directory, an un-excluded root would diverge the local vs. CI asset hash on every synth/deploy and force spurious redeploys; the `**/tests/**` and `__pycache__` excludes keep the hash stable for the same reason. Note the exclude does **not** decouple the two pipelines' asset hashes: `from_asset` hashes with its default `AssetHashType.SOURCE`, so the fingerprint is computed over *all* of `../lambdas` minus only the excluded `__pycache__`/`tests`/`package` paths — PO's and WO's first-party source (both `email_processor` trees, plus the two `web_ui`s and the `site_extractor`) therefore both feed **both** email-processors' hash. Editing any non-excluded file under `lambdas/` changes both email-processors' source fingerprint and redeploys both functions with byte-identical bundles. That coupling is an accepted cost of the shared-root design (the bundling `cp` glob still copies only each pipeline's own `*.py` into the zip); the excludes exist solely to strip local-only/irrelevant cruft that would diverge local vs. CI, not to isolate PO's tree from WO's — which `SOURCE` hashing cannot do here. - -**WO bundling: glob replaces the whole-dir copy — deliberate prod-zip shrinkage.** WO's bundling command changes from a recursive `cp -r . /asset-output/` (the entire `wo/email_processor/` source dir, copied into the deployed zip) to the same scoped, non-recursive glob PO uses: `cp wo/email_processor/*.py /asset-output/`. This intentionally drops from the production zip: -- `requirements.txt` — needed only at bundle time (`pip install -r ...`), never at runtime; -- the entire `tests/` tree (`lambdas/wo/email_processor/tests/`) — real scrubbed `.eml` fixtures, golden JSON, and test modules; -- any first-party `__pycache__/*.pyc` a local `cp -r .` would have picked up (the currently-deployed zip carries none, but the exclude keeps future local builds equally clean). - -This is cleanup, not a regression: none of those file classes are imported at runtime by `handler.handler`, so the acceptance bar for this change on WO is "the runtime-imported module set is unchanged, plus a post-deploy smoke pass" — not a byte-identical zip diff (that stricter bar applies to PO only, whose deployed zip was already this tight before this change). All four first-party top-level `.py` files WO's handler needs — `__init__.py`, `handler.py`, `ses_auth.py`, `template_parser.py` — are still shipped; the glob retains `__init__.py` because it is itself a top-level `.py` file, not a special case requiring a separate copy rule. - -**Plain (non-bundled) `from_asset` calls gain `exclude` too.** The three non-bundled Lambda assets — `po-web-ui`, `po-ingest-site-extractor`, `workorder-web-ui` — each add `exclude=['**/__pycache__/**']`. Nothing else about these three changes: each keeps its own scoped asset path (`../lambdas/po/web_ui`, etc.) rather than widening to `../lambdas`, and none gains bundling. Without the exclude, a developer's local `__pycache__` — again invisible to `from_asset`'s `.gitignore`-blind staging — makes that function's asset hash nondeterministic across machines and forces spurious redeploys. - -`tests/test_bundle_consistency.py` guards all of the above with a pure-AST check (no synth, no boto3, no handler import): it parses each handler.py's top-level first-party sibling imports, extracts the bundling `command=[...]` string from the corresponding CDK stack file, and asserts every required sibling module is guaranteed to ship. It recognizes both the scoped glob (`cp po/email_processor/*.py` / `cp wo/email_processor/*.py`, with or without a path prefix) and a whole-dir recursive copy (`cp -r . /asset-output/`) as unconditionally-safe shapes, and falls back to literal filename matching for any other (allowlist-style) shape. It pins each stack's command to the scoped-glob form specifically — a future revert to a narrowed single-file copy, a commented-out glob, or a filename allowlist missing a sibling all fail CI loudly instead of silently shipping a broken bundle. Runs in the existing pytest step, before synth. +**Lambda packaging (`terraform/build_packages.sh`).** HCP plan/apply packages each function via non-recursive `copy_py_dir` (top-level `*.py` only, so `tests/` is excluded) plus `copy_shared_all` for the email processors and selective `copy_src_file` for web UI auth / API static assets. That eliminates the failure mode that shipped a broken bundle twice (PR #105 omitted `template_parser.py`; PR #2 nearly omitted `derived_fields.py`): a new sibling module the handler imports ships automatically when added under the pipeline dir, and `tests/test_bundle_consistency.py` pins the exact-set + ships-all contracts against the packaging script (no Terraform plan required). Pip installs for third-party deps still use `--platform manylinux2014_aarch64 --only-binary=:all:` — removing that pin once shipped x86 wheels into the ARM64 function and caused a total outage (PR #34). ### Phase 3: shared module extraction (`lambdas/shared/`) @@ -247,17 +226,17 @@ Four first-party modules that were previously duplicated per pipeline (or inline | `email_parsing.py` | `parse_raw_email` (the WO superset that returns `cc` unconditionally; PO simply ignores `cc`) | both email processors | | `emf.py` | generic CloudWatch EMF emitter (`emit_metric`, `emit_parse_outcome`) parameterized by namespace / dimension-sets / properties | both email processors (PO also uses `emit_metric` for `DerivedFieldAgreement`) | -**Flat-landing import rule.** The shared dir has **no `__init__.py`** — the modules are consumed by bare name (`from ses_auth import ...`, `from emf import emit_parse_outcome`), exactly as when they were siblings. This works because the bundling `cp` lands them **flat in `/asset-output/`** beside `handler.py`, so at runtime each shared module sits on the function's own `sys.path` under its bare name — the handler import lines are unchanged, which is what keeps the byte-identical fail-closed `ses_auth` behavior through the move. The load-bearing `emf` dimension-set list `[["ParseMethod"], ["ParseMethod", "TemplateId"]]` is now pinned **once** in `emf.py` (one-sided dimension drift between the two pipelines becomes structurally impossible), while the deliberate per-pipeline **emission-ordering** differences stay in the handlers (PO emits `ai_fallback` *before* the Bedrock call with an intentional double-count; WO emits mutually-exclusive `ai_fallback`/`ai_fallback_rejected` after its gate). +**Flat-landing import rule.** The shared dir has **no `__init__.py`** — the modules are consumed by bare name (`from ses_auth import ...`, `from emf import emit_parse_outcome`), exactly as when they were siblings. This works because packaging lands them **flat in the zip root** beside `handler.py`, so at runtime each shared module sits on the function's own `sys.path` under its bare name — the handler import lines are unchanged, which is what keeps the byte-identical fail-closed `ses_auth` behavior through the move. The load-bearing `emf` dimension-set list `[["ParseMethod"], ["ParseMethod", "TemplateId"]]` is now pinned **once** in `emf.py` (one-sided dimension drift between the two pipelines becomes structurally impossible), while the deliberate per-pipeline **emission-ordering** differences stay in the handlers (PO emits `ai_fallback` *before* the Bedrock call with an intentional double-count; WO emits mutually-exclusive `ai_fallback`/`ai_fallback_rejected` after its gate). -**Bundling — email processors.** Both email-processor commands append a second glob, `cp shared/*.py /asset-output/`, after their own `cp /email_processor/*.py`. This ships all four shared modules flat into each email-processor zip. `web_ui_auth.py` therefore rides along into both email-processor bundles even though the email handlers never import it — a harmless, deliberate consequence of the all-of-`shared/` glob (`PO_EXPECTED_TOP_LEVEL_MODULES` and the bundle-parity expectations account for it). The base is cp-only (Phase 7 removed the pip install / `manylinux` pin from both email-processor commands, and this phase moves only pure first-party modules with no new dependencies, so it **stays** cp-only — no pip step is reintroduced). +**Packaging — email processors.** Both email-processor packages run `copy_py_dir` for the pipeline dir plus `copy_shared_all`. This ships all four shared modules flat into each email-processor zip. `web_ui_auth.py` therefore rides along into both email-processor bundles even though the email handlers never import it — a harmless, deliberate consequence of copying all of `shared/` (`PO_EXPECTED_TOP_LEVEL_MODULES` and the bundle-parity expectations account for it). -**Bundling — web UIs.** Both `po-web-ui` and `workorder-web-ui` gain the same widened-root Docker bundling mechanism: their `Code.from_asset` root widens to `../lambdas` and their command copies the function's own dir contents plus **only** `shared/web_ui_auth.py` (`cp shared/web_ui_auth.py`, *not* `cp shared/*.py`) — the web UIs need only the auth module, and shipping the email-processor-only modules would break the "deployed set + `web_ui_auth`, nothing else" parity. The per-stack INFRA-74 comments stay in each handler (their wording is deliberately pipeline-specific and is not unified). `site_extractor`'s asset is untouched. +**Packaging — web UIs.** Both `po-web-ui` and `workorder-web-ui` copy their own dir plus **only** `shared/web_ui_auth.py` (not all of `shared/`) — the web UIs need only the auth module. `site_extractor` packages its own dir only. -`tests/test_bundle_consistency.py` is updated in lockstep without losing teeth: `_first_party_sibling_imports` resolves shared-sourced imports under `lambdas/shared/`; the command extractor selects the email-processor command now that each stack has two bundled functions; `_bundling_ships_all` accumulates shipped module stems across **both** globs; `PO_EXPECTED_TOP_LEVEL_MODULES` gains the four shared modules; and a new pin + mutation test require the `cp shared/*.py` line to be actually executed (a commented-out or removed shared `cp` fails CI). +`tests/test_bundle_consistency.py` is updated in lockstep without losing teeth: `_first_party_sibling_imports` resolves shared-sourced imports under `lambdas/shared/`; packaging recipes are parsed from `terraform/build_packages.sh`; ships-all accumulates across `copy_py_dir` + `copy_shared_all` / `copy_src_file`; and pins require shared / web_ui_auth copies to be actually present (a commented-out or removed copy fails CI). ### Phase 5: handler decomposition + lazy boto3 clients -Both email-processor God-handlers are decomposed along the seams that already work into **flat sibling modules** in the same directory (bare-name imports, exactly like the `ses_auth`/`template_parser`/`derived_fields` pattern), so the Phase 0/2/3 `cp /email_processor/*.py` glob ships every new sibling automatically — no bundling change beyond the exact-set pin. Every move is a pure delete-here/add-there; `derived_fields.py`, both `template_parser.py`, the `validate_ai_fallback` gates, `lambdas/shared/`, and `cdk/` are byte-untouched. +Both email-processor God-handlers are decomposed along the seams that already work into **flat sibling modules** in the same directory (bare-name imports, exactly like the `ses_auth`/`template_parser`/`derived_fields` pattern), so the packaging `copy_py_dir` ships every new sibling automatically — no packaging change beyond the exact-set pin. Every move is a pure delete-here/add-there; `derived_fields.py`, both `template_parser.py`, the `validate_ai_fallback` gates, and `lambdas/shared/` are byte-untouched. **PO** (`handler.py` → 5 siblings + `prompts.py`): `handler.py` keeps the event loop, fail-closed SES auth, and `email_type` routing; `extraction.py` owns `extract_with_claude` + `_EMAIL_TAG_RE`; `enrichment.py` owns `enrich_parsed` + `pad_zip` (PO-only — WO has no enrichment stage) as a byte-identical move including the derived-field shadow block; `telemetry.py` owns the EMF `ParseMethod`/`DerivedFieldAgreement` emit wrappers; `persistence.py` owns `_write_fields`/`_merge_update`/`save_*` — with the two byte-identical `save_new_po`/`save_revision` **collapsed into one `_save_merge`** plus two thin wrappers differing only in the log verb (behavior-identical to both originals, sticky-cancel `ConditionExpression` guard intact; `save_cancellation` stays its own function). **WO** splits into ~5 concerns (no `enrichment`), keeping `validate_ai_fallback` **and** the `re.fullmatch(r"[0-9]+", work_order_id)` key guard in the handler loop AHEAD of both `save_work_order` and `save_event` (it protects the partition key and the `#`-delimited `comment_id` range-key segment), and keeps `_header_date_iso`/`comment_id` determinism together with `save_event` in `persistence.py`. @@ -277,7 +256,7 @@ Both email-processor God-handlers are decomposed along the seams that already wo ### `purchase-orders` table (owned here) -The `purchase-orders` DynamoDB table is **owned by this repo's `po-ingest` stack** (defined in `cdk/po_stack.py` with `RemovalPolicy.RETAIN`, `StreamViewType.NEW_IMAGE`, and SSE-KMS encryption with the shared customer-managed CMK `alias/seahaven-dynamodb`, INFRA-95 / M-3). The `po-email-processor` Lambda is the authoritative writer — it performs the merge inserts, field-level revision merges, and cancellation updates described above. +The `purchase-orders` DynamoDB table is **owned by this repo's Terraform config** (defined in `terraform/po_ddb.tf` with retain lifecycle, `StreamViewType.NEW_IMAGE`, and SSE-KMS encryption with the shared customer-managed CMK `alias/seahaven-dynamodb`, INFRA-95 / M-3). The `po-email-processor` Lambda is the authoritative writer — it performs the merge inserts, field-level revision merges, and cancellation updates described above. **Consumers (read-only):** @@ -293,7 +272,7 @@ The `purchase-orders` DynamoDB table is **owned by this repo's `po-ingest` stack ### `WorkOrders` and `WorkOrderComments` tables (owned here) -Both tables are **owned by this repo's `WorkorderIngestStack`** (`cdk/wo_stack.py`, `RemovalPolicy.RETAIN`): +Both tables are **owned by this repo's Terraform config** (`terraform/wo_ddb.tf`, retain lifecycle): - `WorkOrders` — PK `work_order_id` (S). - `WorkOrderComments` — PK `work_order_id` (S), SK `comment_id` (S). @@ -323,7 +302,7 @@ Both currently use default DynamoDB encryption — they are **not** yet on the s ### `verified-sites` table (owned here) -Owned by this repo's `po-ingest` stack (`cdk/po_stack.py`). PK `siteCode` (S); default DynamoDB encryption (NOT the shared CMK). +Owned by this repo's Terraform config (`terraform/po_ddb.tf`). PK `siteCode` (S); default DynamoDB encryption (NOT the shared CMK). **Consumer (read-only) — data contract:** `seahaven-slack-bot` (former reader, incl. the INFRA-138/INFRA-180 `by-state` GSI drift saga) was decommissioned 2026-07-23 — the GSI-drift issue died with it. Current consumer: the `procurement-api` Lambda (`GET /verified-sites*`). The API's `VerifiedSite` schema deliberately promises only pipeline-written fields (`siteCode`, `address`, `city`, `state`, `zip`, `fullAddress`, `locationCode`, `poCount`, `sourcePOs`) — `latitude`/`longitude`/`notes` were manually curated legacy attributes the extractor never writes, and are not part of the contract. @@ -337,7 +316,7 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr ## CI/CD GitHub Actions with reusable workflows from `Sea-Haven-Industries/.github` (all pinned to a commit SHA of `main`): -- **CI** (`ci.yaml`, PR to `main`): linting + `cdk synth` via `ci-python-sam.yaml` (historical CDK tree still synthesizes until a follow-up hygiene pass removes it). +- **CI** (`ci.yaml`, PR to `main`): linting + pytest via `ci-python-sam.yaml` (`run-cdk-synth: false`; HCP Terraform is the deploy path). - **Terraform CI** (`ci-terraform.yaml`, PR to `main` when `terraform/**` changes): `fmt -check`, `init -backend=false`, `validate`. - **CD:** HCP Terraform workspace `procurement-ingest-prod` (VCS-bound to `main`, working directory `terraform/`, Manual apply until sealed). There is no push-to-main CDK/SAM deploy workflow. Post-apply smoke: `scripts/post-deploy-smoke.sh`. - Plus dependency review and PR labeler workflows on every PR @@ -386,13 +365,13 @@ pytest **Test-root consolidation (Phase 8).** Both test roots are kept (`tests/` for genuinely cross-pipeline suites, plus each pipeline's own `lambdas/*/email_processor/tests/`), but loading is now single-sourced. A new repo-root `conftest.py` — not `tests/conftest.py`, which is never an ancestor of the pipeline test roots and so cannot load for a standalone `pytest lambdas/po/email_processor/tests` run — sets the dummy AWS env, imports `moto` before any handler import (registers moto's botocore stubber hook so boto3 sessions created afterwards are stubbable), and exposes the one `load_lambda_module(pipeline, name)` loader (implemented in `tests/support/loader.py`) that every handler exec in the suite goes through, including its `sys.modules` save/restore dance for the per-pipeline duplicated bare names (`template_parser`, `persistence`, etc. — `derived_fields`/`prompts`/`telemetry`/`extraction`/`enrichment` too). `tests/support/` is a small shared package (`FakeTable`, `FakeDynamoResource`, `FakeS3`, `load_email`, `load_golden`) used by both pipelines' `_po_parser_support.py`/`_wo_parser_support.py` shims — `load_golden` decodes JSON money with `parse_float=Decimal` unconditionally (load-bearing for PO's exact-money goldens; proven safe for WO, whose 55 golden fixtures contain zero float-typed JSON numbers). `test_local.py` (a root-level manual script that imported a handler at collection time, bypassing the loader gate, and globbed a nonexistent `samples/` dir) is deleted — the golden suites cover its role. Coverage: -- `tests/` — shared handler + cross-pipeline tests: `parse_raw_email` (`test_parse_raw_email.py`), the fail-closed sender-authentication parser (`test_ses_auth.py`, INFRA-107), the Phase 0 CDK-bundling/handler-import AST consistency check (`test_bundle_consistency.py` — see [Deploy-Pipeline Guards](#deploy-pipeline-guards-phase-0)), the `scripts/reprocess.py` synthetic S3-event-shape contract (`test_reprocess_contract.py`, Phase 7), and, new in Phase 8: the handler-level SES-auth reject seam per pipeline (`test_handler_auth_seam.py` — no auth monkeypatch + an empty `ALLOWED_DKIM_DOMAINS` must yield zero Bedrock calls, zero writes, no raise), and both web_ui functions' first coverage — fail-closed auth (`test_web_ui_auth.py`) and the handlers themselves, including a 401-without-a-table-scan assertion and a hostile-field escaping regression lock (`test_web_ui_handlers.py`). +- `tests/` — shared handler + cross-pipeline tests: `parse_raw_email` (`test_parse_raw_email.py`), the fail-closed sender-authentication parser (`test_ses_auth.py`, INFRA-107), the packaging/handler-import consistency check (`test_bundle_consistency.py` — see [Deploy-Pipeline Guards](#deploy-pipeline-guards-phase-0)), the `scripts/reprocess.py` synthetic S3-event-shape contract (`test_reprocess_contract.py`, Phase 7), and, new in Phase 8: the handler-level SES-auth reject seam per pipeline (`test_handler_auth_seam.py` — no auth monkeypatch + an empty `ALLOWED_DKIM_DOMAINS` must yield zero Bedrock calls, zero writes, no raise), and both web_ui functions' first coverage — fail-closed auth (`test_web_ui_auth.py`) and the handlers themselves, including a 401-without-a-table-scan assertion and a hostile-field escaping regression lock (`test_web_ui_handlers.py`). - `lambdas/wo/email_processor/tests/` — the deterministic WO parser suite: golden-file tests over 55 real scrubbed `.eml` fixtures (`test_parser.py`), fail-closed validation-gate rules and adversarial/injection cases (`test_validation_gate.py`), the issue #23 `comment_id` idempotency invariants (`test_comment_id.py`), the Bedrock-fallback dispatch/EMF-metric behavior with a mocked `invoke_model` (`test_bedrock_fallback.py`, which also carries the Phase 5 behavior pins — WO's mutually-exclusive emit and the `[0-9]+` key guard ahead of both saves), and the Phase 0 direct-invoke healthcheck contract (`test_healthcheck.py`). Golden JSON lives under `tests/fixtures/expected/`. New in Phase 8: `test_wo_bedrock_transport.py` (transport errors — throttle, missing `content`, empty content, non-JSON model text — plus the hand-computed no-double-count pin for the `handler.py` except-and-reraise metric wrap, and the multi-record failure-isolation pin) and `test_wo_merge.py` (a moto-backed mirror of `test_po_merge.py` against the `WorkOrders` table — null-status never clobbers `wo_status`, `created_at` immutable, `status`→`wo_status` mapping, `None` fields absent from `SET`, `record_type` only-when-present). After Phase 5 the suite patches accessors on the owning siblings (`extraction.bedrock`, `persistence.dynamodb`, `persistence.datetime`, `persistence.save_*`) rather than on `handler`. - `lambdas/po/email_processor/tests/` — the deterministic PO parser suite: golden-file tests over real scrubbed `.eml` fixtures (17 single-line new-PO + 8 cancellations, exact `Decimal`-aware golden comparison via `parse_float=Decimal`), fail-closed validation-gate coverage for **every** gate reason code (fixture-driven for body-level triggers under `fixtures/adversarial/`, direct `validate()` unit tests for candidate-level mutations), real multi-line and comment/non-Coupa fallback fixtures under `fixtures/ai-fallback/`, dual line-ending (CRLF/LF) parse-identity, two-path `enrich_parsed`/`save_new_po` parity (the site-extractor stream-contract guard), fixture hygiene (`ses_auth` pass + scrub-marker leak sweep), the Bedrock-fallback dispatch/EMF-metric behavior plus the Phase 5 behavior pins (`test_po_bedrock_fallback.py` — the pre-Bedrock `ai_fallback` emit and the additive rejected double-count), the `_save_merge` collapse parity to both `save_new_po`/`save_revision` (`test_po_save_merge_parity.py`), the `ai_fallback`-only shadow telemetry after the `enrich_parsed` move (`test_po_derived_wiring.py`, which gains the PO-DC-02 64-char `po_number` clamp regression pin in Phase 8), and the Phase 0 direct-invoke healthcheck contract (`test_po_healthcheck.py`). New in Phase 8: `test_po_bedrock_transport.py` (the same four transport-error cases as WO, asserting the pre-call `ai_fallback` metric survives and no partial write occurs, plus the multi-record failure-isolation pin) and, moved here from `tests/`: `test_po_merge.py` (#97, moto-backed merge-write semantics) and `test_pad_zip.py` (zip-code padding). After Phase 5 the suite patches accessors/constants on the owning siblings (`po_extraction.bedrock`/`.BEDROCK_MODEL_ID`/`._EMAIL_TAG_RE`, `po_persistence.dynamodb`/`.PO_TABLE`, `po_enrichment.derive_all`/`.pad_zip`, `po_telemetry.DERIVED_METRIC_NAME`) rather than on `po_handler`; re-exported names it calls (`save_*`, `extract_with_claude`, `enrich_parsed`, `_emit_parse_method_metric`, `EXTRACTION_PROMPT`) stay on `po_handler`. All three roots are discovered by `pytest.ini` (`testpaths`). -**Coverage floor (Phase 8).** `pytest.ini`'s `addopts` runs `pytest-cov` with an explicit `--cov` path per first-party package (`lambdas/po/email_processor`, `lambdas/wo/email_processor`, `lambdas/po/web_ui`, `lambdas/wo/web_ui`, `lambdas/po/site_extractor`, `lambdas/shared`) rather than relying on an `__init__.py` package marker — none of these dirs have one, and adding one would perturb the CDK bundling asset-hash fingerprint for zero runtime benefit; `pytest-cov`'s path form measures by source file regardless of package markers. `site_extractor` is deliberately included even though it measures 0% until Phase 6 lands — coverage honesty, not a silent skip. `.coveragerc` omits `*/tests/*` and `*/cdk.out/*` so the pipeline test dirs (which sit inside their own `--cov` path) don't dilute the number. `--cov-fail-under` is the new permanent CI floor (constraint 10 — never ratcheted down). +**Coverage floor (Phase 8).** `pytest.ini`'s `addopts` runs `pytest-cov` with an explicit `--cov` path per first-party package (`lambdas/po/email_processor`, `lambdas/wo/email_processor`, `lambdas/po/web_ui`, `lambdas/wo/web_ui`, `lambdas/po/site_extractor`, `lambdas/shared`) rather than relying on an `__init__.py` package marker — none of these dirs have one, and adding one would perturb flat packaging for zero runtime benefit; `pytest-cov`'s path form measures by source file regardless of package markers. `site_extractor` is deliberately included even though it measures 0% until Phase 6 lands — coverage honesty, not a silent skip. `.coveragerc` omits `*/tests/*` so the pipeline test dirs (which sit inside their own `--cov` path) don't dilute the number. `--cov-fail-under` is the new permanent CI floor (constraint 10 — never ratcheted down). **Ruff C901/PLR floor (Phase 8).** `ruff.toml` adds `extend-select = ["C901", "PLR"]` with `max-complexity = 12`, making the tree's pre-existing `# noqa: PLR09xx` suppressions load-bearing instead of inert (no config previously enabled the rules they suppressed). `scripts/` is in the lint scope. Findings that could not be split or were out of this phase's file-ownership were resolved with a per-file `[lint.per-file-ignores]` entry carrying a written justification — most notably `derived_fields.py` (shadow-bake freeze: even an in-file `noqa` comment is a barred edit) and the other Phase 3/5/6/7 frozen modules (`enrichment.py`, WO `template_parser.py`, both `ses_auth.py`/`emf.py`, `site_extractor/handler.py`, `scripts/reprocess.py`). PO's `template_parser.py` — this phase's one in-scope split target — clears the ceiling by decomposing `extract_new_po` and `_validate_new_po_values` into per-rule helpers instead of an ignore. @@ -420,24 +399,15 @@ python scripts/replay_shoc_webhooks.py --url https://... --since 2026-07-24T02:0 ## Directory Structure ``` -cdk/ - app.py # Three stacks: po-ingest + WorkorderIngestStack + procurement-api (region-only env) - common.py # Phase 4: shared plain-function CDK helpers (alarms, Bedrock grant, - # email bucket, processor DLQ, fallback-rate alarm) -- called with - # each stack's own scope + literal construct ids, logical-ID-safe - po_stack.py # Purchase order pipeline resources - wo_stack.py # Work order pipeline resources + the SHOC webhook feed (secret/CMK, - # rotator, emitter, disabled ESMs, queues, alarms) - procurement_api_stack.py # REST API (IAM SigV4 + resource policy) over both pipelines' tables + token-gated /docs -lambdas/ # Phase 2: shared Code.from_asset("../lambdas") bundling root for - # BOTH po-email-processor and workorder-email-processor (and, since - # Phase 3, both web_ui functions) -- each email-processor command - # `cp`s its own po/email_processor/*.py (or wo/...) subset PLUS - # `cp shared/*.py`; each web_ui command `cp`s its own dir PLUS only - # `shared/web_ui_auth.py`; site_extractor keeps its narrower, non- - # bundled asset root +terraform/ # Sole IaC path (HCP workspace procurement-ingest-prod) + build_packages.sh # Lambda zip packaging (copy_py_dir / copy_shared_all / copy_src_file) + *.tf # PO / WO / API / SHOC resources imported under PLAT-86 +lambdas/ # Runtime source; packaged by terraform/build_packages.sh + # email processors: copy_py_dir + copy_shared_all; + # web_ui: copy_py_dir + shared/web_ui_auth.py only; + # site_extractor / SHOC: copy_py_dir of their own dirs shared/ # Phase 3: single-sourced first-party modules, landed FLAT (no - # __init__.py -- bare-name imports) into each bundle by the cp above + # __init__.py -- bare-name imports) into each email-processor zip ses_auth.py # fail-closed SES sender-auth (INFRA-107) -- one copy, one fix web_ui_auth.py # fail-closed X-Auth-Token gate + token cache (INFRA-74; also gates the API /docs routes) email_parsing.py # parse_raw_email (WO superset; returns cc unconditionally) @@ -455,8 +425,7 @@ lambdas/ # Phase 2: shared Code.from_asset("../lambdas") bundling fonts.css # SHOC fonts (DM Sans/Montserrat/JetBrains Mono, @fontsource latin subsets) as data URIs, inlined into /docs po/ # PO pipeline Lambdas email_processor/ # Phase 5: God-handler decomposed into flat siblings (bare-name - # imports; the Phase 0/2/3 `cp /email_processor/*.py` - # glob ships every new sibling automatically) + # imports; copy_py_dir ships every new sibling automatically) handler.py # event loop + fail-closed SES auth + email_type routing + {"healthcheck": true} early-return; lazy s3 accessor; re-exports EXTRACTION_PROMPT (4 tests deref handler.EXTRACTION_PROMPT) extraction.py # extract_with_claude + _EMAIL_TAG_RE + EXTRACTION_PROMPT import; lazy bedrock accessor enrichment.py # enrich_parsed + pad_zip (PO-only; no boto3); derived-field shadow block diff --git a/cdk/app.py b/cdk/app.py deleted file mode 100644 index f1289ab..0000000 --- a/cdk/app.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -import aws_cdk as cdk -from po_stack import PoIngestStack -from procurement_api_stack import ProcurementApiStack -from wo_stack import WorkorderIngestStack - -app = cdk.App() - -PoIngestStack( - app, - "po-ingest", - stack_name="po-ingest", - env=cdk.Environment(region="us-east-1"), -) - -WorkorderIngestStack( - app, - "workorder-ingest", - stack_name="WorkorderIngestStack", - env=cdk.Environment(region="us-east-1"), -) - -ProcurementApiStack( - app, - "procurement-api", - stack_name="procurement-api", - env=cdk.Environment(region="us-east-1"), -) - -app.synth() diff --git a/cdk/cdk.json b/cdk/cdk.json deleted file mode 100644 index f28e013..0000000 --- a/cdk/cdk.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "app": "python3 app.py", - "context": { - "@aws-cdk/core:bootstrapQualifier": "hnb659fds" - } -} diff --git a/cdk/common.py b/cdk/common.py deleted file mode 100644 index 830ac03..0000000 --- a/cdk/common.py +++ /dev/null @@ -1,357 +0,0 @@ -"""Shared CDK helpers for the procurement-ingest stacks (Phase 4). - -Plain free functions extracted from po_stack.py / wo_stack.py. Each takes the -same Stack `scope` and the same literal construct `id` the stacks passed inline, -so every synthesized logical ID is byte-stable. NOTHING here is a Construct -subclass: a subclass would insert a tree node and reparent/replace the -RETAIN-protected tables and named buckets. -""" - -from aws_cdk import ( - Duration, - RemovalPolicy, - Stack, - aws_cloudwatch as cloudwatch, - aws_cloudwatch_actions as cw_actions, - aws_dynamodb as dynamodb, - aws_iam as iam, - aws_logs as logs, - aws_s3 as s3, - aws_sqs as sqs, -) - -# --- DynamoDB alarm operations (moved verbatim from both stacks) --- -_DDB_ALARM_OPERATIONS = [ - dynamodb.Operation.GET_ITEM, - dynamodb.Operation.BATCH_GET_ITEM, - dynamodb.Operation.QUERY, - dynamodb.Operation.SCAN, - dynamodb.Operation.PUT_ITEM, - dynamodb.Operation.UPDATE_ITEM, - dynamodb.Operation.DELETE_ITEM, - dynamodb.Operation.BATCH_WRITE_ITEM, -] - -# CloudWatch namespace for the log-derived sender-authentication metrics. -_SENDER_AUTH_METRIC_NAMESPACE = "Seahaven/ProcurementIngest" - - -def add_ddb_alarms(scope, id_prefix, table, alarm_name_prefix, alarm_topic): - """Add throttle + system-error alarms for a DynamoDB table. - - Both fire on any non-zero datapoint in a 5-min window. ALARM-only SnsAction - to site-alerts (no OK action); TreatMissingData NOT_BREACHING. - """ - table.metric_throttled_requests_for_operations( - operations=_DDB_ALARM_OPERATIONS, - period=Duration.minutes(5), - statistic="Sum", - ).create_alarm( - scope, - f"{id_prefix}ThrottlesAlarm", - alarm_name=f"{alarm_name_prefix}-throttles", - alarm_description=f"{alarm_name_prefix} DynamoDB throttled requests", - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - table.metric_system_errors_for_operations( - operations=_DDB_ALARM_OPERATIONS, - period=Duration.minutes(5), - statistic="Sum", - ).create_alarm( - scope, - f"{id_prefix}SystemErrorsAlarm", - alarm_name=f"{alarm_name_prefix}-system-errors", - alarm_description=f"{alarm_name_prefix} DynamoDB server-side (5xx) errors", - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - -def make_function_log_group(scope, id_prefix, function_name): - """Explicit log group for a Lambda, replacing the deprecated - ``log_retention`` prop (INFRA-114). - - Making the group a real stack resource (a) drops the LogRetention custom - resource whose role carried wildcard ``logs:PutRetentionPolicy`` (checkov - CKV_AWS_111), and (b) makes the group an orderable CFN dependency: - consumers like the sender-auth metric filter now deploy AFTER the group - exists. The previous by-name import raced group creation in a fresh - account and failed the first seahaven-prod deploy (the mgmt account - masked this because its groups predated the filter). - - RETAIN matches the repo convention for stateful resources and mirrors the - old behavior (LogRetention never deleted groups on stack delete). NOTE: - never ``cdk deploy`` this app to mgmt (328440206208) — PLAT-67 deleted the - mgmt stacks and left RETAIN cold-archive log groups; a redeploy would - collide with those leftovers. - """ - return logs.LogGroup( - scope, - f"{id_prefix}LogGroup", - log_group_name=f"/aws/lambda/{function_name}", - retention=logs.RetentionDays.TWO_MONTHS, - removal_policy=RemovalPolicy.RETAIN, - ) - - -def add_sender_auth_rejected_alarm( - scope, id_prefix, function_name, alarm_topic, log_group -): - """Metric-filter + alarm on ``sender_auth_rejected`` warnings (INFRA-107). - - A rejected inbound email is skipped without erroring the invocation, so it - is invisible to the Errors/Throttles/DLQ alarms. This turns the structured - warning log into a CloudWatch metric and pages when rejections spike -- - catching a silent false-reject storm (allowlist wrong, signing-domain - drift, SES header-format change) that would otherwise discard legitimate - mail while the pipeline reports healthy. - - ALARM-only SnsAction to site-alerts; no OK action. The metric filter reads - the function's own log group, passed in as the EXPLICIT LogGroup construct - (from ``make_function_log_group``) so CFN orders the filter after the - group exists -- a by-name import here failed the first fresh-account - deploy. A plain substring pattern is used because Lambda prefixes each - line with its own level/timestamp/request-id, so the JSON payload is not - a standalone JSON log event a `{$.event=...}` pattern could match. - """ - metric_name = f"{function_name}-sender-auth-rejected" - logs.MetricFilter( - scope, - f"{id_prefix}SenderAuthRejectedFilter", - log_group=log_group, - filter_pattern=logs.FilterPattern.literal('"sender_auth_rejected"'), - metric_namespace=_SENDER_AUTH_METRIC_NAMESPACE, - metric_name=metric_name, - metric_value="1", - default_value=0, - ) - - cloudwatch.Metric( - namespace=_SENDER_AUTH_METRIC_NAMESPACE, - metric_name=metric_name, - period=Duration.minutes(5), - statistic="Sum", - ).create_alarm( - scope, - f"{id_prefix}SenderAuthRejectedAlarm", - alarm_name=f"{function_name}-sender-auth-rejected", - alarm_description=( - f"{function_name} rejected inbound mail on sender authentication " - "(possible allowlist/DKIM-domain drift silently dropping real mail)" - ), - threshold=1, - evaluation_periods=6, - datapoints_to_alarm=2, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - -def add_standard_lambda_alarms( - scope, - id_prefix, - fn, - name_prefix, - topic, - *, - duration_statistic, - errors=True, - dlq=None, - descriptions, -): - """Standard per-Lambda alarm set: errors (optional), throttles, dlq - (optional), duration. Every alarm bespoke-described via `descriptions` - (keys: errors/throttles/dlq/duration passed through VERBATIM). Construct - ids are f"{id_prefix}Alarm", alarm names f"{name_prefix}-", - exactly the inline literals. Order of creation is irrelevant to logical IDs - (ids are explicit) so the fixed errors->throttles->dlq->duration order here - reproduces both PO (dlq before duration) and WO (duration before dlq) - templates identically. - """ - if errors: - fn.metric_errors(period=Duration.minutes(5), statistic="Sum").create_alarm( - scope, - f"{id_prefix}ErrorsAlarm", - alarm_name=f"{name_prefix}-errors", - alarm_description=descriptions["errors"], - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(topic)) - - fn.metric_throttles(period=Duration.minutes(5), statistic="Sum").create_alarm( - scope, - f"{id_prefix}ThrottlesAlarm", - alarm_name=f"{name_prefix}-throttles", - alarm_description=descriptions["throttles"], - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(topic)) - - if dlq is not None: - dlq.metric_approximate_number_of_messages_visible( - period=Duration.minutes(5), - statistic="Maximum", - ).create_alarm( - scope, - f"{id_prefix}DlqMessagesAlarm", - alarm_name=f"{name_prefix}-dlq-messages", - alarm_description=descriptions["dlq"], - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(topic)) - - fn.metric_duration( - period=Duration.minutes(5), - statistic=duration_statistic, - ).create_alarm( - scope, - f"{id_prefix}DurationAlarm", - alarm_name=f"{name_prefix}-duration", - alarm_description=descriptions["duration"], - threshold=45000, - evaluation_periods=3, - datapoints_to_alarm=2, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(topic)) - - -def make_bedrock_invoke_statement(scope): - """Return the Bedrock InvokeModel PolicyStatement for the email processor. - - The us.* inference profile can route cross-region, so the grant covers the - inference-profile ARN plus the per-region foundation-model ARNs - (us-east-1/us-east-2/us-west-2). ARN #1 account and ARN #1/#2 region are - DERIVED from Stack.of(scope).account/.region (not hardcoded 328440206208); - ARN #3/#4 regions (us-east-2/us-west-2) are cross-region reach targets, NOT - the stack's own region, so they stay literal. Caller attaches via - fn.add_to_role_policy(...) on the SAME function -> logical-ID-safe. - """ - stack = Stack.of(scope) - account = stack.account - region = stack.region - return iam.PolicyStatement( - actions=[ - "bedrock:InvokeModel", - "bedrock:InvokeModelWithResponseStream", - ], - resources=[ - f"arn:aws:bedrock:{region}:{account}:inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0", - f"arn:aws:bedrock:{region}::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0", - "arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0", - "arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0", - ], - ) - - -def make_email_bucket(scope, id, name_prefix): - """Raw-email S3 bucket: BLOCK_ALL public, RETAIN, 90-day expiration. - bucket_name = f"{name_prefix}-{account}" (account derived from the stack), - reproducing f"po-ingest-emails-{self.account}" / - f"workorder-ingest-emails-{self.account}" exactly. - """ - return s3.Bucket( - scope, - id, - bucket_name=f"{name_prefix}-{Stack.of(scope).account}", - block_public_access=s3.BlockPublicAccess.BLOCK_ALL, - removal_policy=RemovalPolicy.RETAIN, - lifecycle_rules=[ - s3.LifecycleRule(expiration=Duration.days(90)), - ], - ) - - -def make_processor_dlq(scope, id): - """Async-invoke DLQ: 14-day retention, enforce_ssl. CDK-generated name.""" - return sqs.Queue( - scope, - id, - retention_period=Duration.days(14), - enforce_ssl=True, - ) - - -def make_fallback_rate_alarm( - scope, - id, - *, - namespace, - alarm_topic, - alarm_name, - alarm_description, - rejected_included, - period, - threshold, - floor, - evaluation_periods, - datapoints_to_alarm, -): - """Template-fallback-rate MathExpression alarm. - - rejected_included=False (PO): numerator FILL(fb,0), denom fb+tmpl. - rejected_included=True (WO): numerator (fb+rej), denom fb+rej+tmpl. - Expression string, FILL, label reproduced BYTE-FOR-BYTE. GREATER_THAN, - NOT_BREACHING. NO element-wise MAX (post-#102). The two DISTINCT rejected - alarms are NOT built here. - """ - fb_metric = cloudwatch.Metric( - namespace=namespace, - metric_name="ParseOutcome", - dimensions_map={"ParseMethod": "ai_fallback"}, - statistic="Sum", - period=period, - ) - tmpl_metric = cloudwatch.Metric( - namespace=namespace, - metric_name="ParseOutcome", - dimensions_map={"ParseMethod": "template"}, - statistic="Sum", - period=period, - ) - if rejected_included: - fb_rej_metric = cloudwatch.Metric( - namespace=namespace, - metric_name="ParseOutcome", - dimensions_map={"ParseMethod": "ai_fallback_rejected"}, - statistic="Sum", - period=period, - ) - using_metrics = {"fb": fb_metric, "rej": fb_rej_metric, "tmpl": tmpl_metric} - sum_terms = "FILL(fb,0)+FILL(rej,0)+FILL(tmpl,0)" - numerator = "(FILL(fb,0)+FILL(rej,0))" - else: - using_metrics = {"fb": fb_metric, "tmpl": tmpl_metric} - sum_terms = "FILL(fb,0)+FILL(tmpl,0)" - numerator = "FILL(fb,0)" - expression = f"IF(({sum_terms})>={floor}, 100*{numerator}/({sum_terms}), 0)" - - fallback_rate = cloudwatch.MathExpression( - expression=expression, - using_metrics=using_metrics, - period=period, - label="TemplateFallbackRatePct", - ) - fallback_rate.create_alarm( - scope, - id, - alarm_name=alarm_name, - alarm_description=alarm_description, - threshold=threshold, - evaluation_periods=evaluation_periods, - datapoints_to_alarm=datapoints_to_alarm, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) diff --git a/cdk/po_stack.py b/cdk/po_stack.py deleted file mode 100644 index b03f572..0000000 --- a/cdk/po_stack.py +++ /dev/null @@ -1,616 +0,0 @@ -"""CDK stack for the Coupa PO email ingestion pipeline.""" - -import aws_cdk as cdk -from aws_cdk import ( - Duration, - RemovalPolicy, - Stack, - aws_cloudwatch as cloudwatch, - aws_cloudwatch_actions as cw_actions, - aws_dynamodb as dynamodb, - aws_kms as kms, - aws_lambda as lambda_, - aws_lambda_event_sources as lambda_event_sources, - aws_s3 as s3, - aws_s3_notifications as s3n, - aws_ses as ses, - aws_ses_actions as ses_actions, - aws_secretsmanager as secretsmanager, - aws_sns as sns, - aws_ssm as ssm, -) -from constructs import Construct - -import common - - -class PoIngestStack(Stack): - def __init__(self, scope: Construct, construct_id: str, **kwargs): - super().__init__(scope, construct_id, **kwargs) - - # --- Shared alarm SNS topic (site-alerts) --- - # Imported once near the top so every alarm in this stack reuses the same - # Topic construct instance (avoids duplicate logical IDs). ALARM-only - # SnsAction; no OK action, per the CloudWatch-alarm preference. The - # topic's CMK (alias/seahaven-alarm-topics) lives on the topic itself. - alarm_topic = sns.Topic.from_topic_arn( - self, - "SiteAlertsTopic", - f"arn:aws:sns:{self.region}:{self.account}:site-alerts", - ) - - # --- S3 bucket for raw emails --- - email_bucket = common.make_email_bucket(self, "EmailBucket", "po-ingest-emails") - - # --- Shared customer-managed CMK for sensitive DynamoDB tables --- - # Owned by the account-baseline app (alias/seahaven-dynamodb, INFRA-95 / - # M-3); ARN published to SSM. The purchase-orders table was migrated to - # SSE-KMS out-of-band, so declaring encryption_key here reconciles the - # drift and — via grant_read_write_data below — propagates the required - # kms:Decrypt/GenerateDataKey/DescribeKey to the consumer roles. - dynamodb_cmk = kms.Key.from_key_arn( - self, - "DynamoDbCmk", - ssm.StringParameter.value_for_string_parameter( - self, "/seahaven/dynamodb/cmk-arn" - ), - ) - - # --- Purchase-orders DynamoDB table --- - # Owned by this stack. Streams enabled for the site-extractor pipeline. - # Other stacks (seahaven-slack-bot) reference this table via fromTableName(). - po_table = dynamodb.Table( - self, - "PurchaseOrdersTable", - table_name="purchase-orders", - partition_key=dynamodb.Attribute( - name="po_number", - type=dynamodb.AttributeType.STRING, - ), - billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, - removal_policy=RemovalPolicy.RETAIN, - stream=dynamodb.StreamViewType.NEW_IMAGE, - encryption=dynamodb.TableEncryption.CUSTOMER_MANAGED, - encryption_key=dynamodb_cmk, - ) - - # --- Anthropic API key secret removed (Bedrock migration) --- - # PO parsing stays fully AI but moved from the Anthropic API to the - # Bedrock inference profile us.anthropic.claude-haiku-4-5-20251001-v1:0, - # so no provider API key is needed. The old secret - # "po-ingest/anthropic-api-key" had RemovalPolicy.RETAIN, so it is - # ORPHANED (not deleted) by this change: delete it manually post-deploy - # and revoke the stored key at Anthropic. - - # --- DLQ for failed async invocations (INFRA-41 / audit H-8) --- - # SES → S3 → Lambda is async; without an OnFailure destination a failed - # parse (bad email, transient error) is silently dropped after Lambda's - # retries. CDK generates the queue name to avoid colliding with the - # interim CLI-created po-email-processor-dlq (removed post-deploy). - email_processor_dlq = common.make_processor_dlq(self, "EmailProcessorDlq") - - # --- Lambda function --- - email_processor_log_group = common.make_function_log_group( - self, "EmailProcessor", "po-email-processor" - ) - email_processor = lambda_.Function( - self, - "EmailProcessor", - function_name="po-email-processor", - runtime=lambda_.Runtime.PYTHON_3_12, - architecture=lambda_.Architecture.ARM_64, - handler="handler.handler", - code=lambda_.Code.from_asset( - "../lambdas", - exclude=["**/__pycache__/**", "**/tests/**", "**/package/**"], - bundling=cdk.BundlingOptions( - image=lambda_.Runtime.PYTHON_3_12.bundling_image, - command=[ - "bash", - "-c", - # NOTE: non-recursive glob (not `cp -r`) so tests/ and - # the stale package/ dir are never shipped -- only - # top-level .py siblings of handler.py. This replaces - # a hand-maintained four-file allowlist that twice - # nearly shipped a broken Lambda (missing - # template_parser in PR #105, nearly missing - # derived_fields in PR #2) because a new sibling - # import wasn't added to the list. The glob makes - # that class of bug structurally impossible; - # tests/test_bundle_consistency.py ast-parses - # handler.py's first-party imports and asserts this - # command ships all of them, so a future revert back - # to an allowlist that omits a sibling fails CI. - # pip step removed in Phase 7: requirements.txt is now - # empty (boto3 comes from the Lambda runtime), so nothing - # is installed and the manylinux pin has nothing to pin. - # shared/*.py ships the four modules extracted to - # lambdas/shared/ (Phase 3): ses_auth, web_ui_auth, - # email_parsing, emf. Flat cp keeps the bare-name - # imports (e.g. `from ses_auth import ...`) resolving - # unchanged in /asset-output. - "cp po/email_processor/*.py /asset-output/ && " - "cp shared/*.py /asset-output/", - ], - ), - ), - timeout=Duration.seconds(60), - memory_size=256, - log_group=email_processor_log_group, - dead_letter_queue=email_processor_dlq, - environment={ - "PO_TABLE": "purchase-orders", - "BEDROCK_MODEL_ID": "us.anthropic.claude-haiku-4-5-20251001-v1:0", - # Fail-closed sender auth (INFRA-107): the handler only - # accepts mail whose SES-stamped Authentication-Results - # header carries dkim=pass for one of these domains. - # Observed on live traffic 2026-07-15: Coupa PO mail passes - # DKIM for amazon.coupahost.com (and amazonses.com, which is - # deliberately NOT allowlisted — every SES customer's mail - # passes that). Unset/empty ⇒ the handler rejects all mail. - "ALLOWED_DKIM_DOMAINS": "amazon.coupahost.com", - }, - ) - - # Grant permissions - email_bucket.grant_read(email_processor) - po_table.grant_read_write_data(email_processor) - - # --- Bedrock InvokeModel grant --- - # The us.* inference profile can route cross-region, so the grant MUST - # cover both the inference-profile ARN AND the per-region foundation-model - # ARNs (empty account field) for every region the profile can reach - # (us-east-1/us-east-2/us-west-2). A profile-only grant AccessDenies at - # runtime whenever the profile routes to a region whose foundation-model - # ARN is not allowed. - email_processor.add_to_role_policy(common.make_bedrock_invoke_statement(self)) - - # --- Standard per-Lambda alarms: po-email-processor --- - # errors (INFRA-41 / audit H-8), throttles, DLQ-messages (dropped PO - # emails), and a p99 duration alarm (orphan adoption of the CLI - # Lambda-Duration-po-email-processor under -duration naming, 45000 ms - # = 75% of the 60s timeout, eval 3 / dp 2). All ALARM-only to site-alerts. - common.add_standard_lambda_alarms( - self, - "EmailProcessor", - email_processor, - "po-email-processor", - alarm_topic, - duration_statistic="p99", - errors=True, - dlq=email_processor_dlq, - descriptions={ - "errors": "po-email-processor async invocation errors", - "throttles": "po-email-processor invocation throttles", - "dlq": "po-email-processor DLQ has messages (dropped PO emails)", - "duration": "po-email-processor p99 duration approaching the 60s timeout", - }, - ) - - # --- Sender-auth rejection alarm (INFRA-107) --- - # A rejected email (bad/unaligned DKIM verdict) returns normally, so it - # produces NO Lambda error, NO DLQ message and NO retry -- only a - # `sender_auth_rejected` warning log. Without this metric filter + alarm a - # domain drift (Coupa rotates its signing subdomain, SES changes its - # Authentication-Results format, the allowlist is wrong) would silently - # discard 100% of legitimate PO mail while every other alarm stays green. - # A CloudWatch Logs metric filter turns those warnings into a metric so a - # false-reject storm pages instead of vanishing. default_value=0 keeps the - # series populated (alarm stays OK, never INSUFFICIENT_DATA) between events. - common.add_sender_auth_rejected_alarm( - self, - "EmailProcessor", - "po-email-processor", - alarm_topic, - email_processor_log_group, - ) - - # --- Template fallback-rate alarm: po-email-processor --- - # The processor tries a deterministic template parse first and only calls - # the Bedrock AI extractor on a miss/invalid. A sustained rise in the - # ai_fallback share signals Coupa template drift (coverage collapse). - # EMF metric Seahaven/PoIngest/ParseOutcome, dimensioned by ParseMethod - # (template|ai_fallback). - # - # RETUNED for PO volume (~57 emails/day ≈ 14.25 per 6h period) -- the WO - # alarm's 15-min period / >=10-sample floor assume ~760/day and would be - # structurally DEAD here (a 15-min period holds ~0.6 PO emails, so the - # floor is never met and the IF always takes the 0 branch): - # * period 6h: a stable ~14-email denominator per datapoint. - # * volume floor >=8: at the floor, one fallback email = 12.5% < 20%, - # so a single email can NEVER breach a datapoint; a breach needs >=2 - # fallbacks in one 6h window (2/8 = 25%) or >=3 at typical volume - # (3/14 ≈ 21%). Sparse overnight/weekend windows (<8 emails) take - # the 0 branch -- non-breaching by design (accepted trade: a Friday- - # evening drift may not page until weekend volume accrues). - # * threshold >20%: expected baseline fallback ≈1% (comments 0.55% + - # multi-line 0.18% + non-USD 0) -- far below the threshold. - # * 2 of 4 datapoints (24h span): isolated noise self-clears, while - # total template drift (100% fallback) pages within ~12h. - # Post-#102 rule: NO element-wise MAX(timeseries, scalar) in alarm math; - # the IF volume floor guarantees the non-zero denominator. Any change to - # this expression must be gated by `npx cdk synth po-ingest`. - # - # DOUBLE-COUNT ACCOUNTING (Phase 1 / PO AI-fallback gate): PO emits - # ParseMethod=ai_fallback BEFORE the Bedrock call for EVERY AI-path - # email (handler pre-call emit; try_deterministic_parse returns - # "ai_fallback" on every template miss), so a gate-rejected email - # already appears exactly once in `fb`. Therefore fb = ALL fallback - # attempts (accepted + rejected), fb + tmpl = ALL emails, and - # rate = fb/(fb+tmpl) is exact -- the expression below is deliberately - # left BYTE-IDENTICAL to the pre-Phase-1 form, and `rej` - # (ai_fallback_rejected) is deliberately EXCLUDED from this - # expression's numerator, denominator, and volume floor, and is never - # added to using_metrics. This is NOT an oversight: folding `rej` in - # here as WO does (fb+rej numerator / fb+rej+tmpl denominator) would - # double-count every rejected email in both numerator and - # denominator (PO's pre-call emit already counts it once via `fb`), - # inflating the observed rate toward 100% and double-counting toward - # the >=8 volume floor -- a prompt-injection probing burst would then - # falsely page this template-drift alarm on top of the dedicated - # rejected alarm below. The rejected series gets its own alarm - # instead (EmailProcessorAiFallbackRejectedAlarm, below). - common.make_fallback_rate_alarm( - self, - "EmailProcessorTemplateFallbackRateAlarm", - namespace="Seahaven/PoIngest", - alarm_topic=alarm_topic, - alarm_name="po-email-processor-template-fallback-rate", - alarm_description=( - "po-email-processor deterministic-template coverage collapse: " - ">20% of parses fell back to the Bedrock AI extractor" - ), - rejected_included=False, - period=Duration.hours(6), - threshold=20, - floor=8, - evaluation_periods=4, - datapoints_to_alarm=2, - ) - - # --- AI-fallback rejected alarm: po-email-processor (Phase 1) --- - # The validate_ai_fallback gate (template_parser.py) fail-closes Bedrock - # output that doesn't match PO's contract (structurally wrong shape, - # injected po_number/email_type, wrong field types) and emits - # ParseMethod=ai_fallback_rejected instead of writing it. That is a - # SILENT skip (`continue`, never raise) by design -- attacker-controlled - # input must not churn the retry/DLQ path -- so without a dedicated - # alarm a sustained rejection run (prompt-injection probing, or a - # template-drift outage whose AI output also happens to fail the gate) - # is invisible everywhere except this metric and the ReasonCode log - # line. - # - # RETUNED for PO volume (~57 emails/day, baseline ai_fallback rate - # ~1% => ~0.6 AI-fallback emails/day, expected rejections ~= 0) -- NOT - # WO's 5-min/2-of-6 sparse idiom (wo_stack.py), which needs two - # rejections inside a single 30-min window and is structurally dead at - # this volume. Mirrors the PO fallback-rate alarm's 6h/eval-4/dp-2 - # retune idiom above, but with a COUNT floor on the rejected series - # itself rather than an email-volume floor: an email-volume floor - # (fb+tmpl>=N) would suppress paging in exactly the sparse - # overnight/weekend windows where a silently-dropped email matters - # most, and there is no denominator here, so there is nothing else to - # guard against divide-by-zero. FILL(rej,0) turns the sparse EMF - # series (no datapoint in quiet periods -- no metric-filter - # default_value exists for EMF) into a dense 0-series so every - # evaluation window has data. Post-#102 rule still holds: NO - # element-wise MAX(timeseries, scalar) anywhere in this expression. - # - # Tuning: rejections self-clear unless >=2 breaching datapoints land in - # >=2 distinct 6h windows within 24h (sustained probing, or template - # drift whose AI output also fails the gate), which pages within - # ~12-24h. Accepted residual (matches WO's accepted residual): because - # the breach is measured per 6h window, ANY burst of rejections - # confined to a single 6h window -- whether one stray email or dozens - # in a 20-minute spike -- is one breaching datapoint and never pages - # this alarm by itself. This is deliberate anti-flap tuning at ~0 - # expected rejections/day, not a coverage gap in the fail-closed gate: - # every burst email is still rejected before any DynamoDB write, and - # the burst stays fully visible as ai_fallback_rejected datapoints and - # ReasonCode log lines, with the pre-call ai_fallback emit also raising - # the fallback-rate numerator above. A same-window burst detector - # (1-of-1 at a higher threshold) is a tracked follow-up if faster - # single-window paging is wanted. - rejected_metric = cloudwatch.Metric( - namespace="Seahaven/PoIngest", - metric_name="ParseOutcome", - dimensions_map={"ParseMethod": "ai_fallback_rejected"}, - statistic="Sum", - period=Duration.hours(6), - ) - rejected_floor = cloudwatch.MathExpression( - expression="IF(FILL(rej,0)>=1, FILL(rej,0), 0)", - using_metrics={"rej": rejected_metric}, - period=Duration.hours(6), - label="AiFallbackRejectedCount", - ) - rejected_floor.create_alarm( - self, - "EmailProcessorAiFallbackRejectedAlarm", - alarm_name="po-email-processor-ai-fallback-rejected", - alarm_description=( - "po-email-processor is rejecting Bedrock AI-fallback output at " - "the validation gate (possible prompt-injection probing or " - "template drift silently dropping real mail)" - ), - threshold=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - evaluation_periods=4, - datapoints_to_alarm=2, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - # S3 event notification → Lambda - email_bucket.add_event_notification( - s3.EventType.OBJECT_CREATED, - s3n.LambdaDestination(email_processor), - s3.NotificationKeyFilter(prefix="inbound/"), - ) - - # --- SES Receipt Rule --- - # Reuse the existing INBOUND_MAIL rule set (shared with workorder-ingest) - rule_set = ses.ReceiptRuleSet.from_receipt_rule_set_name( - self, - "ExistingRuleSet", - "INBOUND_MAIL", - ) - - rule_set.add_rule( - "PoEmailRule", - recipients=["amazon_po@int.seahaven.com"], - actions=[ - ses_actions.S3( - bucket=email_bucket, - object_key_prefix="inbound/", - ), - ], - ) - - # --- Web UI auth token secret --- - # Shared secret for the web UI auth gate, stored in Secrets Manager and - # resolved at runtime so the token never appears in CloudFormation templates - # or Lambda environment variables. Create this secret before deploying - # either stack; both PO and WO stacks reference it by name. - web_ui_auth_secret = secretsmanager.Secret.from_secret_name_v2( - self, - "WebUiAuthToken", - "procurement-ingest/web-ui-auth-token", - ) - - # --- Web UI Lambda --- - web_ui_log_group = common.make_function_log_group(self, "WebUI", "po-web-ui") - web_ui = lambda_.Function( - self, - "WebUI", - function_name="po-web-ui", - runtime=lambda_.Runtime.PYTHON_3_12, - architecture=lambda_.Architecture.ARM_64, - handler="handler.handler", - code=lambda_.Code.from_asset( - "../lambdas", - exclude=["**/__pycache__/**", "requirements.txt"], - bundling=cdk.BundlingOptions( - image=lambda_.Runtime.PYTHON_3_12.bundling_image, - command=[ - "bash", - "-c", - # web_ui_auth.py is shared (lambdas/shared) and must land - # FLAT beside handler.py so the bare - # `from web_ui_auth import is_authenticated` resolves at - # runtime. Only web_ui_auth is copied from shared/ -- the - # other shared modules (ses_auth/email_parsing/emf) are - # email-processor-only and must not bloat the web UI zip. - # requirements.txt stays excluded (Dependabot anchor only, - # never runtime): keeps the deployed file list = {handler, - # web_ui_auth}. NOTE: the top-level `exclude=` on - # from_asset only filters the asset-hash fingerprint, NOT - # the directory Docker bundling actually mounts, so a - # local __pycache__/requirements.txt on disk at synth - # time WOULD otherwise leak into the bundled zip -- strip - # them explicitly post-cp instead of relying on exclude. - "cp -r po/web_ui/. /asset-output/ && " - "cp shared/web_ui_auth.py /asset-output/ && " - "rm -rf /asset-output/__pycache__ /asset-output/requirements.txt", - ], - ), - ), - timeout=Duration.seconds(60), - memory_size=256, - log_group=web_ui_log_group, - environment={ - "PO_TABLE": "purchase-orders", - # Defense-in-depth shared secret for the web UI handler. The - # handler fails closed if this ARN is unset or the secret is - # missing, so any future invocation path cannot re-expose the - # PO DB unauthenticated. The secret value is fetched at runtime - # from Secrets Manager (not embedded in env vars or template). - "WEB_UI_AUTH_TOKEN_SECRET_ARN": web_ui_auth_secret.secret_arn, - }, - ) - - po_table.grant_read_data(web_ui) - web_ui_auth_secret.grant_read(web_ui) - - # --- Standard per-Lambda alarms: po-web-ui --- - # Throttles + p99 duration only (no errors alarm, no DLQ -- web_ui is a - # synchronous read path with no async DLQ). p99 / 45000 ms (75% of the - # 60s timeout) / eval 3, datapoints 2. - common.add_standard_lambda_alarms( - self, - "WebUi", - web_ui, - "po-web-ui", - alarm_topic, - duration_statistic="p99", - errors=False, - dlq=None, - descriptions={ - "throttles": "po-web-ui invocation throttles", - "duration": "po-web-ui p99 duration approaching the 60s timeout", - }, - ) - - # Public Function URL removed 2026-06-08 (INFRA-74 / audit C-5): the - # unauthenticated FunctionUrlAuthType.NONE URL was deleted out-of-band - # via CLI. Removing the construct (and its auto-generated Principal:* - # invoke permission) reconciles IaC with the live state. - - # --- Verified sites table (extracted from PO ship-to addresses) --- - verified_sites_table = dynamodb.Table( - self, - "VerifiedSitesTable", - table_name="verified-sites", - partition_key=dynamodb.Attribute( - name="siteCode", - type=dynamodb.AttributeType.STRING, - ), - billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, - removal_policy=RemovalPolicy.RETAIN, - ) - # by-state GSI removed 2026-06-03 (audit M-20): 0 reads in 30d against - # 518 WCU of write amplification. Re-add if a state-level query path ships. - - # --- Site extractor Lambda (DynamoDB Streams → verified-sites) --- - site_extractor_log_group = common.make_function_log_group( - self, "SiteExtractor", "po-ingest-site-extractor" - ) - site_extractor = lambda_.Function( - self, - "SiteExtractor", - function_name="po-ingest-site-extractor", - runtime=lambda_.Runtime.PYTHON_3_12, - architecture=lambda_.Architecture.ARM_64, - handler="handler.handler", - code=lambda_.Code.from_asset( - # requirements.txt is excluded from the bundle: it exists only - # as a Dependabot anchor (git-based scan sees it), never pip- - # installed (this is a plain non-bundled asset) and never needed - # at runtime (boto3 comes from the Lambda runtime). Excluding it - # keeps the deployed asset hash neutral vs base while the manifest - # still lands in git for Dependabot. - "../lambdas/po/site_extractor", - exclude=["**/__pycache__/**", "requirements.txt"], - ), - timeout=Duration.seconds(60), - memory_size=256, - log_group=site_extractor_log_group, - environment={ - "VERIFIED_SITES_TABLE": verified_sites_table.table_name, - "PENDING_REVIEW_TABLE": "pending-site-review", - }, - ) - - verified_sites_table.grant_read_write_data(site_extractor) - - site_extractor.add_event_source( - lambda_event_sources.DynamoEventSource( - po_table, - starting_position=lambda_.StartingPosition.TRIM_HORIZON, - batch_size=10, - max_batching_window=Duration.seconds(30), - bisect_batch_on_error=True, - retry_attempts=3, - ) - ) - - # --- Standard per-Lambda alarms: po-ingest-site-extractor --- - # errors + throttles + p99 duration. NO DLQ alarm: site_extractor is a - # DynamoEventSource stream consumer with no async DLQ attached (dlq=None). - # Stream-consumer errors retry per the event-source config, but a - # persistent failure stalls the verified-sites pipeline. p99 / 45000 ms - # (75% of the 60s timeout) / eval 3, datapoints 2. - common.add_standard_lambda_alarms( - self, - "SiteExtractor", - site_extractor, - "po-ingest-site-extractor", - alarm_topic, - duration_statistic="p99", - errors=True, - dlq=None, - descriptions={ - "errors": "po-ingest-site-extractor invocation errors", - "throttles": "po-ingest-site-extractor invocation throttles", - "duration": "po-ingest-site-extractor p99 duration approaching the 60s timeout", - }, - ) - - cdk.CfnOutput( - self, - "VerifiedSitesTableName", - value=verified_sites_table.table_name, - description="Verified site addresses extracted from POs", - ) - - # --- Pending site review table (POs with no extractable site code) --- - pending_review_table = dynamodb.Table( - self, - "PendingSiteReviewTable", - table_name="pending-site-review", - partition_key=dynamodb.Attribute( - name="po_number", - type=dynamodb.AttributeType.STRING, - ), - billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, - removal_policy=RemovalPolicy.RETAIN, - ) - - pending_review_table.grant_read_write_data(site_extractor) - verified_sites_table.grant_read_data(site_extractor) - - # --- DynamoDB throttle + system-error alarms --- - # ThrottledRequests / SystemErrors emit at TableName + Operation only - # (verified against live CloudWatch: no TableName-only rollup exists, and - # metric_throttled_requests is deprecated/invalid in aws-cdk-lib 2.261.0). - # Each table currently has zero throttle/error datapoints, so the series - # only materialise on first occurrence — NOT_BREACHING keeps them OK until - # then. - common.add_ddb_alarms( - self, "PurchaseOrdersTable", po_table, "purchase-orders", alarm_topic - ) - common.add_ddb_alarms( - self, - "VerifiedSitesTable", - verified_sites_table, - "verified-sites", - alarm_topic, - ) - common.add_ddb_alarms( - self, - "PendingSiteReviewTable", - pending_review_table, - "pending-site-review", - alarm_topic, - ) - - # --- Function ARN + consumed-table-name outputs (Phase 4, additive) --- - cdk.CfnOutput( - self, - "EmailProcessorFunctionArn", - value=email_processor.function_arn, - description="ARN of the po-email-processor Lambda", - ) - cdk.CfnOutput( - self, - "WebUiFunctionArn", - value=web_ui.function_arn, - description="ARN of the po-web-ui Lambda", - ) - cdk.CfnOutput( - self, - "SiteExtractorFunctionArn", - value=site_extractor.function_arn, - description="ARN of the po-ingest-site-extractor Lambda", - ) - cdk.CfnOutput( - self, - "PurchaseOrdersTableName", - value=po_table.table_name, - description="purchase-orders DynamoDB table consumed by this stack", - ) - cdk.CfnOutput( - self, - "PendingSiteReviewTableName", - value=pending_review_table.table_name, - description="pending-site-review DynamoDB table", - ) diff --git a/cdk/procurement_api_stack.py b/cdk/procurement_api_stack.py deleted file mode 100644 index ea3cdf7..0000000 --- a/cdk/procurement_api_stack.py +++ /dev/null @@ -1,427 +0,0 @@ -"""procurement-api stack: read-only REST API over both pipelines' tables. - -Third stack in the app. Serves work orders + comments (WO stack tables) and -purchase orders + verified sites (PO stack tables) to SigV4 callers -- the -primary consumer is the SHOC backend, for which this API replaces the retired -SyncController cross-account DynamoDB scan as the reconciliation/backfill -path. Also hosts the token-gated OpenAPI docs page (/docs, /openapi.json). - -Tables are imported by fixed physical name (Table.from_table_name), NOT -passed as cross-stack objects: object passing would synthesize CFN Exports -from the owning stacks and lock them against future changes to the tables. -The one thing name-import does NOT carry is the purchase-orders CMK -association -- see the explicit KMS grant below. -""" - -import aws_cdk as cdk -from aws_cdk import ( - Duration, - Stack, - aws_apigateway as apigateway, - aws_certificatemanager as acm, - aws_cloudwatch as cloudwatch, - aws_cloudwatch_actions as cw_actions, - aws_dynamodb as dynamodb, - aws_iam as iam, - aws_kms as kms, - aws_lambda as lambda_, - aws_secretsmanager as secretsmanager, - aws_sns as sns, - aws_ssm as ssm, -) -from constructs import Construct - -import common - -# The only cross-account caller. Grants are to this exact role ARN -- future -# shoc-backend-staging/-prod roles are each a deliberate, individually -# cross-reviewed addition (no wildcard/prefix trust). -SHOC_BACKEND_DEV_ROLE_ARN = "arn:aws:iam::396287094661:role/shoc-backend-dev" - -STAGE_NAME = "prod" - -# Custom domain for the SHOC-facing read API. The seahaven.com zone is in the -# mgmt account, so the cert is issued out of band and its ARN handed in via -# this SSM parameter (see the custom-domain block below). -CUSTOM_DOMAIN_NAME = "procurement-api.seahaven.com" -CUSTOM_DOMAIN_CERT_ARN_SSM_PARAM = "/procurement-api/custom-domain/certificate-arn" - - -class ProcurementApiStack(Stack): - def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: - super().__init__(scope, construct_id, **kwargs) - - alarm_topic = sns.Topic.from_topic_arn( - self, - "SiteAlertsTopic", - f"arn:aws:sns:{self.region}:{self.account}:site-alerts", - ) - - work_orders_table = dynamodb.Table.from_table_name( - self, "WorkOrdersTable", "WorkOrders" - ) - comments_table = dynamodb.Table.from_table_name( - self, "CommentsTable", "WorkOrderComments" - ) - po_table = dynamodb.Table.from_table_name( - self, "PurchaseOrdersTable", "purchase-orders" - ) - verified_sites_table = dynamodb.Table.from_table_name( - self, "VerifiedSitesTable", "verified-sites" - ) - - web_ui_auth_secret = secretsmanager.Secret.from_secret_name_v2( - self, - "WebUiAuthToken", - "procurement-ingest/web-ui-auth-token", - ) - - # purchase-orders is SSE-KMS encrypted with the org DynamoDB CMK. A - # name-imported Table has no encryption-key association, so - # grant_read_data alone leaves the reader without kms:Decrypt and every - # purchase-orders read AccessDenies at runtime (the INFRA-104 failure - # class). Import the key from the same SSM parameter po_stack uses and - # grant it explicitly. - dynamodb_cmk = kms.Key.from_key_arn( - self, - "DynamoDbCmk", - ssm.StringParameter.value_for_string_parameter( - self, "/seahaven/dynamodb/cmk-arn" - ), - ) - - # RETAIN log group (INFRA-114). This is the only stateful resource in - # this otherwise-stateless stack, so it carries the fresh-deploy - # rollback trap: if the FIRST create fails after the group exists, - # rollback deletes everything else but retains the group, and the retry - # CREATE then collides on "/aws/lambda/procurement-api already exists". - # Recovery: delete that log group before re-running a failed first - # deploy (same class as the RETAIN-orphan recovery in the deploy-role - # runbook / reference_cfn_deploy_role_gotchas). - api_log_group = common.make_function_log_group( - self, "ProcurementApi", "procurement-api" - ) - api_fn = lambda_.Function( - self, - "ProcurementApi", - function_name="procurement-api", - runtime=lambda_.Runtime.PYTHON_3_12, - architecture=lambda_.Architecture.ARM_64, - handler="handler.handler", - code=lambda_.Code.from_asset( - "../lambdas", - exclude=["**/__pycache__/**"], - bundling=cdk.BundlingOptions( - image=lambda_.Runtime.PYTHON_3_12.bundling_image, - command=[ - "bash", - "-c", - # Non-recursive glob ships every api/ sibling (the - # allowlist-omission trap from PR #105/PR #2); the spec, - # docs page, and the vendored Redoc bundle ride along - # because the handler serves them from its own package - # dir. web_ui_auth.py must land FLAT beside handler.py - # for the bare import. - "cp api/*.py /asset-output/ && " - "cp api/openapi.json /asset-output/ && " - "cp api/docs.html /asset-output/ && " - "cp api/redoc.standalone.js /asset-output/ && " - "cp api/fonts.css /asset-output/ && " - "cp shared/web_ui_auth.py /asset-output/ && " - "rm -rf /asset-output/__pycache__", - ], - ), - ), - timeout=Duration.seconds(30), - memory_size=256, - log_group=api_log_group, - environment={ - "WORK_ORDERS_TABLE": work_orders_table.table_name, - "COMMENTS_TABLE": comments_table.table_name, - "PO_TABLE": po_table.table_name, - "VERIFIED_SITES_TABLE": verified_sites_table.table_name, - "WEB_UI_AUTH_TOKEN_SECRET_ARN": web_ui_auth_secret.secret_arn, - }, - ) - - work_orders_table.grant_read_data(api_fn) - comments_table.grant_read_data(api_fn) - po_table.grant_read_data(api_fn) - verified_sites_table.grant_read_data(api_fn) - web_ui_auth_secret.grant_read(api_fn) - api_fn.add_to_role_policy( - iam.PolicyStatement( - actions=["kms:Decrypt", "kms:DescribeKey"], - resources=[dynamodb_cmk.key_arn], - # Scope the grant to the DynamoDB data path only: the role can - # decrypt purchase-orders items via DynamoDB, never call - # kms:Decrypt directly on arbitrary ciphertext under the shared - # org CMK. - conditions={ - "StringEquals": { - "kms:ViaService": f"dynamodb.{self.region}.amazonaws.com" - } - }, - ) - ) - - # Resource policy: once a REST API has one, anything not explicitly - # allowed is denied -- so the docs routes need their own Allow or the - # NONE-auth methods would be black-holed. The docs routes are still - # token-gated inside the Lambda (fail-closed), so this is not an - # unauthenticated data path (INFRA-74 posture). - # - # The SHOC data grant enumerates the exact GET resources rather than - # GET/*: adding a RESOURCE must be as reviewable as adding a PRINCIPAL, - # so a future GET route can't silently inherit cross-account reach - # without a policy diff. (Note: this resource policy binds CROSS-account - # callers only; a same-account principal holding execute-api:Invoke is - # authorized by its own identity policy under AWS union semantics -- it - # is NOT constrained here, including on the planned PATCH/POST methods, - # which is why those also rely on the 501 handler + absent write grant, - # not on this policy, until phase 2.) - shoc_data_resources = [ - f"execute-api:/{STAGE_NAME}/GET/work-orders", - f"execute-api:/{STAGE_NAME}/GET/work-orders/*", - f"execute-api:/{STAGE_NAME}/GET/purchase-orders", - f"execute-api:/{STAGE_NAME}/GET/purchase-orders/*", - f"execute-api:/{STAGE_NAME}/GET/verified-sites", - f"execute-api:/{STAGE_NAME}/GET/verified-sites/*", - ] - api_policy = iam.PolicyDocument( - statements=[ - iam.PolicyStatement( - sid="ShocBackendDevDataRead", - principals=[iam.ArnPrincipal(SHOC_BACKEND_DEV_ROLE_ARN)], - actions=["execute-api:Invoke"], - resources=shoc_data_resources, - ), - # SECURITY INVARIANT: this AnyPrincipal allow is safe only - # while /docs//openapi.json serve static docs (handler - # enforces the shared token, fail-closed). Widening these - # routes to dynamic data, or enabling access logging (which - # would record the docs ?token= shim), requires a security - # re-review + docs-token rotation. - iam.PolicyStatement( - sid="DocsTokenGatedRoutes", - principals=[iam.AnyPrincipal()], - actions=["execute-api:Invoke"], - resources=[ - f"execute-api:/{STAGE_NAME}/GET/docs", - f"execute-api:/{STAGE_NAME}/GET/openapi.json", - ], - ), - ] - ) - - # OPERATIONAL NOTE: API Gateway serves the resource policy from the - # deployed stage snapshot, and CDK's Deployment hash is computed from - # resources/methods, not the RestApi Policy. A later policy-ONLY change - # (e.g. revoking the SHOC role) will UPDATE the RestApi but keep serving - # the old policy until a new Deployment is forced (any method/resource - # change, or a salted deployment). When tightening this policy, force a - # redeploy and verify the effective policy post-deploy. - api = apigateway.RestApi( - self, - "ProcurementRestApi", - rest_api_name="procurement-api", - description=( - "Read API over procurement-ingest work orders + purchase " - "orders; token-gated OpenAPI docs at /docs" - ), - endpoint_types=[apigateway.EndpointType.REGIONAL], - policy=api_policy, - deploy_options=apigateway.StageOptions( - stage_name=STAGE_NAME, - # Bound the blast radius of the unauthenticated /docs routes - # (and the whole API) below the 10k rps account default -- this - # is a low-volume reconciliation/backfill API, not a hot path. - throttling_rate_limit=50, - throttling_burst_limit=100, - ), - # No access/execution logging in v1: avoids the account-level API - # Gateway CloudWatch role prerequisite AND keeps the docs ?token= - # query shim out of any log. Rotate the docs token before ever - # enabling access logging here. - cloud_watch_role=False, - ) - - integration = apigateway.LambdaIntegration(api_fn) - iam_auth = {"authorization_type": apigateway.AuthorizationType.IAM} - - work_orders = api.root.add_resource("work-orders") - work_orders.add_method("GET", integration, **iam_auth) - wo_by_id = work_orders.add_resource("{workOrderId}") - wo_by_id.add_method("GET", integration, **iam_auth) - # Phase-2 planned write endpoints: deployed but the handler answers 501 - # and the role holds no DynamoDB write grant. The resource policy denies - # these to the cross-account SHOC role (GET-only enumeration above); a - # same-account caller is NOT blocked by the resource policy, so the 501 - # handler + absent write grant are the real gate until phase 2 lands the - # deliberate policy + handler + write-grant change with its own review. - wo_by_id.add_method("PATCH", integration, **iam_auth) - wo_comments = wo_by_id.add_resource("comments") - wo_comments.add_method("GET", integration, **iam_auth) - wo_comments.add_method("POST", integration, **iam_auth) - - purchase_orders = api.root.add_resource("purchase-orders") - purchase_orders.add_method("GET", integration, **iam_auth) - purchase_orders.add_resource("{poNumber}").add_method( - "GET", integration, **iam_auth - ) - - verified_sites = api.root.add_resource("verified-sites") - verified_sites.add_method("GET", integration, **iam_auth) - verified_sites.add_resource("{siteCode}").add_method( - "GET", integration, **iam_auth - ) - - api.root.add_resource("docs").add_method( - "GET", - integration, - authorization_type=apigateway.AuthorizationType.NONE, - ) - api.root.add_resource("openapi.json").add_method( - "GET", - integration, - authorization_type=apigateway.AuthorizationType.NONE, - ) - - # --- Custom domain: procurement-api.seahaven.com --- - # Stable, brandable endpoint for the SHOC backend to sign SigV4 against - # (replaces the opaque execute-api URL). REGIONAL to match the API, so - # the ACM cert lives in this account+region (us-east-1). - # - # CROSS-ACCOUNT DNS: the seahaven.com public zone - # (Z06652411XKH89KTZD3XA) is in the mgmt account (328440206208), not - # here. So the cert's DNS-validation CNAME and the final A-alias record - # are added to that zone OUT OF BAND (see the README runbook / - # scripts/setup_procurement_api_domain.sh), and the issued cert ARN is - # handed to this stack via SSM. Reading it with value_for_string_ - # parameter keeps the reference CFN-resolved at deploy -- no synth-time - # AWS creds, unlike a cross-account HostedZone.from_lookup. The - # A-alias record itself is created out of band too (the zone is not in - # this account, so CDK cannot own it); the outputs below give the exact - # alias target. - cert_arn = ssm.StringParameter.value_for_string_parameter( - self, CUSTOM_DOMAIN_CERT_ARN_SSM_PARAM - ) - certificate = acm.Certificate.from_certificate_arn( - self, "ProcurementApiCertificate", cert_arn - ) - custom_domain = apigateway.DomainName( - self, - "ProcurementApiDomain", - domain_name=CUSTOM_DOMAIN_NAME, - certificate=certificate, - endpoint_type=apigateway.EndpointType.REGIONAL, - security_policy=apigateway.SecurityPolicy.TLS_1_2, - ) - # Empty base path: the custom domain root maps to the prod stage, so - # callers hit https://procurement-api.seahaven.com/work-orders (no - # /prod segment -- the mapping strips it). - custom_domain.add_base_path_mapping(api, stage=api.deployment_stage) - - # --- Alarms (ALARM-only -> site-alerts, NOT_BREACHING) --- - # common.add_standard_lambda_alarms is NOT used here: its duration - # threshold is a fixed 45000 ms (75% of the processors' 60 s timeout), - # which this function's 30 s timeout can never reach. Same idiom, - # right-sized thresholds. - api_fn.metric_errors(period=Duration.minutes(5), statistic="Sum").create_alarm( - self, - "ProcurementApiErrorsAlarm", - alarm_name="procurement-api-errors", - alarm_description=( - "procurement-api Lambda raised (bundle/init failures; the " - "handler catches request errors, so any signal here is " - "structural)" - ), - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - api_fn.metric_throttles( - period=Duration.minutes(5), statistic="Sum" - ).create_alarm( - self, - "ProcurementApiThrottlesAlarm", - alarm_name="procurement-api-throttles", - alarm_description="procurement-api Lambda throttled", - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - api_fn.metric_duration( - period=Duration.minutes(5), statistic="p99" - ).create_alarm( - self, - "ProcurementApiDurationAlarm", - alarm_name="procurement-api-duration", - alarm_description=( - "procurement-api p99 duration >= 22.5s (75% of the 30s " - "timeout; scans degrading toward timeout)" - ), - threshold=22500, - evaluation_periods=3, - datapoints_to_alarm=2, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - # Gateway-side 5xx: catches what the Lambda's own Errors metric can't - # (the handler returns clean 500s; integration faults surface here). - # No 4XX alarm -- 401/403/404 are expected traffic. - cloudwatch.Metric( - namespace="AWS/ApiGateway", - metric_name="5XXError", - dimensions_map={"ApiName": "procurement-api", "Stage": STAGE_NAME}, - period=Duration.minutes(5), - statistic="Sum", - ).create_alarm( - self, - "ProcurementApi5xxAlarm", - alarm_name="procurement-api-5xx", - alarm_description="procurement-api gateway 5XX responses", - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - cdk.CfnOutput( - self, - "ApiEndpointUrl", - value=api.url, - description="procurement-api invoke URL (stage prod)", - ) - cdk.CfnOutput( - self, - "ProcurementApiFunctionArn", - value=api_fn.function_arn, - description="ARN of the procurement-api Lambda", - ) - cdk.CfnOutput( - self, - "ProcurementApiCustomDomainUrl", - value=f"https://{CUSTOM_DOMAIN_NAME}/", - description="procurement-api custom domain (SHOC-facing endpoint)", - ) - # A-alias target for the mgmt-account Route53 record. Create an ALIAS A - # record: procurement-api.seahaven.com -> this regional domain name, - # with this hosted-zone id, EvaluateTargetHealth=false. - cdk.CfnOutput( - self, - "ProcurementApiAliasTarget", - value=custom_domain.domain_name_alias_domain_name, - description="Route53 A-alias target (add in the mgmt seahaven.com zone)", - ) - cdk.CfnOutput( - self, - "ProcurementApiAliasHostedZoneId", - value=custom_domain.domain_name_alias_hosted_zone_id, - description="Route53 A-alias target hosted-zone id (mgmt zone record)", - ) diff --git a/cdk/requirements.txt b/cdk/requirements.txt deleted file mode 100644 index 6f01bc2..0000000 --- a/cdk/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -aws-cdk-lib==2.263.0 -constructs==10.8.0 diff --git a/cdk/wo_stack.py b/cdk/wo_stack.py deleted file mode 100644 index bb133c5..0000000 --- a/cdk/wo_stack.py +++ /dev/null @@ -1,910 +0,0 @@ -"""CDK stack for the work order email ingestion pipeline.""" - -import aws_cdk as cdk -import common -from aws_cdk import ( - Duration, - RemovalPolicy, - Stack, -) -from aws_cdk import ( - aws_cloudwatch as cloudwatch, -) -from aws_cdk import ( - aws_cloudwatch_actions as cw_actions, -) -from aws_cdk import ( - aws_dynamodb as dynamodb, -) -from aws_cdk import ( - aws_iam as iam, -) -from aws_cdk import ( - aws_kms as kms, -) -from aws_cdk import ( - aws_lambda as lambda_, -) -from aws_cdk import ( - aws_lambda_event_sources as lambda_event_sources, -) -from aws_cdk import ( - aws_s3 as s3, -) -from aws_cdk import ( - aws_s3_notifications as s3n, -) -from aws_cdk import ( - aws_secretsmanager as secretsmanager, -) -from aws_cdk import ( - aws_ses as ses, -) -from aws_cdk import ( - aws_ses_actions as ses_actions, -) -from aws_cdk import ( - aws_sns as sns, -) -from aws_cdk import ( - aws_sqs as sqs, -) -from constructs import Construct - - -class WorkorderIngestStack(Stack): - def __init__(self, scope: Construct, construct_id: str, **kwargs): - super().__init__(scope, construct_id, **kwargs) - - # --- Shared alarm SNS topic (site-alerts) --- - # Imported once near the top so every alarm in this stack reuses the same - # Topic construct instance (avoids duplicate logical IDs). ALARM-only - # SnsAction; no OK action, per the CloudWatch-alarm preference. The - # topic's CMK (alias/seahaven-alarm-topics) lives on the topic itself. - alarm_topic = sns.Topic.from_topic_arn( - self, - "SiteAlertsTopic", - f"arn:aws:sns:{self.region}:{self.account}:site-alerts", - ) - - # --- S3 bucket for raw emails --- - email_bucket = common.make_email_bucket( - self, "EmailBucket", "workorder-ingest-emails" - ) - - # --- DynamoDB tables --- - work_orders_table = dynamodb.Table( - self, - "WorkOrdersTable", - table_name="WorkOrders", - partition_key=dynamodb.Attribute( - name="work_order_id", - type=dynamodb.AttributeType.STRING, - ), - billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, - # NEW_AND_OLD_IMAGES: the SHOC emitter needs OLD.wo_status to - # classify the cancelled transition (docs/shoc-webhook-plan.md - # Phase 2). In-place CFN update -- no table replacement. - stream=dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, - removal_policy=RemovalPolicy.RETAIN, - ) - # site-code-index and status-index GSIs removed 2026-06-03 (audit M-20): - # 0 reads in 30d against ~50k WCU each of write amplification. Re-add if - # a site-code or status query path ships. - - comments_table = dynamodb.Table( - self, - "CommentsTable", - table_name="WorkOrderComments", - partition_key=dynamodb.Attribute( - name="work_order_id", - type=dynamodb.AttributeType.STRING, - ), - sort_key=dynamodb.Attribute( - name="comment_id", - type=dynamodb.AttributeType.STRING, - ), - billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, - # Streamed for the SHOC emitter (docs/shoc-webhook-plan.md Phase 2). - stream=dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, - removal_policy=RemovalPolicy.RETAIN, - ) - - # --- Anthropic API key secret removed (Bedrock migration) --- - # Parsing moved from the Anthropic API to the Bedrock inference profile - # us.anthropic.claude-haiku-4-5-20251001-v1:0, so no provider API key is - # needed. The old secret "workorder-ingest/anthropic-api-key" had - # RemovalPolicy.RETAIN, so it is ORPHANED (not deleted) by this change: - # delete it manually post-deploy and revoke the stored key at Anthropic. - - # --- DLQ for failed async invocations (INFRA-41 / audit H-8) --- - # SES → S3 → Lambda is async; without an OnFailure destination a failed - # parse (bad email, transient error) is silently dropped after Lambda's - # retries. CDK generates the queue name to avoid colliding with the - # interim CLI-created workorder-email-processor-dlq (removed post-deploy). - email_processor_dlq = common.make_processor_dlq(self, "EmailProcessorDlq") - - # --- Lambda function --- - email_processor_log_group = common.make_function_log_group( - self, "EmailProcessor", "workorder-email-processor" - ) - email_processor = lambda_.Function( - self, - "EmailProcessor", - function_name="workorder-email-processor", - runtime=lambda_.Runtime.PYTHON_3_12, - architecture=lambda_.Architecture.ARM_64, - handler="handler.handler", - code=lambda_.Code.from_asset( - "../lambdas", - exclude=["**/__pycache__/**", "**/tests/**", "**/package/**"], - bundling=cdk.BundlingOptions( - image=lambda_.Runtime.PYTHON_3_12.bundling_image, - command=[ - "bash", - "-c", - # pip step removed in Phase 7: requirements.txt is now empty - # (boto3 comes from the Lambda runtime), so nothing is installed - # and the manylinux pin has nothing to pin. cp-only is safe. - # shared/*.py ships the four modules extracted to - # lambdas/shared/ (Phase 3): ses_auth, web_ui_auth, - # email_parsing, emf. Flat cp keeps the bare-name - # imports (e.g. `from ses_auth import ...`) resolving - # unchanged in /asset-output. - "cp wo/email_processor/*.py /asset-output/ && " - "cp shared/*.py /asset-output/", - ], - ), - ), - timeout=Duration.seconds(60), - memory_size=256, - log_group=email_processor_log_group, - dead_letter_queue=email_processor_dlq, - environment={ - "WORK_ORDERS_TABLE": work_orders_table.table_name, - "COMMENTS_TABLE": comments_table.table_name, - "BEDROCK_MODEL_ID": "us.anthropic.claude-haiku-4-5-20251001-v1:0", - # Fail-closed sender auth (INFRA-107): the handler only - # accepts mail whose SES-stamped Authentication-Results - # header carries dkim=pass for one of these domains. APM - # mail arrives via the apm@ Google Groups forward, which - # re-signs as seahaven.com (observed on live traffic - # 2026-07-15: "dkim=pass header.i=@seahaven.com"; the - # original hxgnsmartcloud.com signature does not survive - # the forward). Unset/empty ⇒ the handler rejects all mail. - "ALLOWED_DKIM_DOMAINS": "seahaven.com", - }, - ) - - # Grant permissions - email_bucket.grant_read(email_processor) - work_orders_table.grant_read_write_data(email_processor) - comments_table.grant_read_write_data(email_processor) - - # --- Bedrock InvokeModel grant --- - # The us.* inference profile can route cross-region, so the grant MUST - # cover both the inference-profile ARN AND the per-region foundation-model - # ARNs (empty account field) for every region the profile can reach - # (us-east-1/us-east-2/us-west-2). A profile-only grant AccessDenies at - # runtime whenever the profile routes to a region whose foundation-model - # ARN is not allowed. - email_processor.add_to_role_policy(common.make_bedrock_invoke_statement(self)) - - # NOTE: The pre-emptive grant_encrypt_decrypt on the shared DynamoDB CMK - # (alias/seahaven-dynamodb) was removed (security sweep 2026-06-17). The - # WorkOrders/WorkOrderComments tables are NOT SSE-KMS encrypted with that - # CMK, so the grant was unused for these tables yet handed - # wo-email-processor kms:Decrypt on the CMK that also protects the - # purchase-orders table (cross-stack decrypt reach). Re-add this grant only - # as part of the actual CMK migration of these tables (INFRA-6), at which - # point grant_read_write_data on the (then encrypted) tables would propagate - # the needed key permissions automatically. - - # --- Standard per-Lambda alarms: workorder-email-processor --- - # errors (INFRA-41 / audit H-8), throttles, DLQ-visible-messages (dropped - # emails), and a p95 duration alarm (orphan adoption of the CLI - # Lambda-Duration-workorder-email-processor under -duration naming, - # 45000 ms = 75% of the 60s timeout, eval 3 / dp 2). p95 (NOT p99) is the - # WO-specific duration statistic. All ALARM-only to site-alerts. - common.add_standard_lambda_alarms( - self, - "EmailProcessor", - email_processor, - "workorder-email-processor", - alarm_topic, - duration_statistic="p95", - errors=True, - dlq=email_processor_dlq, - descriptions={ - "errors": "workorder-email-processor async invocation errors", - "throttles": "workorder-email-processor invocation throttles", - "dlq": "workorder-email-processor DLQ has visible messages (dropped emails)", - "duration": "workorder-email-processor p95 duration approaching the 60s timeout", - }, - ) - - # --- Sender-auth rejection alarm (INFRA-107) --- - # A rejected email (bad/unaligned DKIM verdict) returns normally, so it - # produces NO Lambda error, NO DLQ message and NO retry -- only a - # `sender_auth_rejected` warning log. The WO allowlist trusts dkim=pass - # for seahaven.com on the assumption the apm@ forward re-signs there; if - # that assumption is wrong (e.g. a Gmail auto-forward re-signs under a - # different domain), 100% of legitimate work-order mail is silently - # dropped. This metric filter + alarm turns those warnings into a paging - # signal so a false-reject storm surfaces instead of a silent outage. - common.add_sender_auth_rejected_alarm( - self, - "EmailProcessor", - "workorder-email-processor", - alarm_topic, - email_processor_log_group, - ) - - # --- Template fallback-rate alarm: workorder-email-processor --- - # The processor tries a deterministic template parse first and only calls - # the Bedrock AI extractor on a miss/invalid. A sustained rise in the - # ai_fallback share signals Hexagon template drift (coverage collapse). - # EMF metric Seahaven/WorkorderIngest/ParseOutcome, dimensioned by - # ParseMethod (template|ai_fallback). 15-min periods (deliberate deviation - # from the 5-min house style) accumulate a stable denominator at the low - # ~760/day volume; FILL(0) + a >=10-sample volume floor prevent - # low-volume false pages and INSUFFICIENT_DATA. ALARM-only SnsAction to - # site-alerts, no OK action, NOT_BREACHING -- matching the stack idiom. - # rejected_included=True: the WO expression folds ai_fallback_rejected - # (rej) into BOTH numerator and denominator -- a drift outage whose AI - # output also fails the gate must still count as fallback, otherwise it - # would LOWER the observed rate while silently dropping mail. (Contrast - # PO, which excludes rej to avoid a pre-call double-count.) - common.make_fallback_rate_alarm( - self, - "EmailProcessorTemplateFallbackRateAlarm", - namespace="Seahaven/WorkorderIngest", - alarm_topic=alarm_topic, - alarm_name="workorder-email-processor-template-fallback-rate", - alarm_description=( - "workorder-email-processor deterministic-template coverage " - "collapse: >15% of parses fell back to the Bedrock AI extractor" - ), - rejected_included=True, - period=Duration.minutes(15), - threshold=15, - floor=10, - evaluation_periods=3, - datapoints_to_alarm=2, - ) - - # --- AI-fallback rejected alarm: workorder-email-processor --- - # A parse rejected by the validate_ai_fallback gate is dropped without - # error/retry/DLQ (fail closed), so like sender-auth rejections it - # needs its own pager or a sustained rejection condition (prompt- - # injection probing, or template drift whose AI output fails the gate) - # stays silent. Same sparse-arrival idiom as the sender-auth-rejected - # alarm: >=1 rejection per 5-min period, 2 of the last 6 periods (30 - # min), so a lone probe self-clears but a burst pages within ~10 min. - # Coverage residual (matching the sender-auth-rejected sibling and - # knowingly accepted): rejections spaced >~25-30 min apart never place - # two breaching datapoints in one 30-min window, and the fallback-rate - # alarm dilutes them below 15% against normal template volume, so a - # *very* sparse silent-drop trickle is not paged by either alarm. - # EMF emits no datapoint in quiet periods (no metric-filter - # default_value here); NOT_BREACHING treats those gaps as OK. - cloudwatch.Metric( - namespace="Seahaven/WorkorderIngest", - metric_name="ParseOutcome", - dimensions_map={"ParseMethod": "ai_fallback_rejected"}, - statistic="Sum", - period=Duration.minutes(5), - ).create_alarm( - self, - "EmailProcessorAiFallbackRejectedAlarm", - alarm_name="workorder-email-processor-ai-fallback-rejected", - alarm_description=( - "workorder-email-processor is rejecting Bedrock AI-fallback " - "output at the validation gate (possible prompt-injection " - "probing or template drift silently dropping real mail)" - ), - threshold=1, - evaluation_periods=6, - datapoints_to_alarm=2, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - # S3 event notification -> Lambda - email_bucket.add_event_notification( - s3.EventType.OBJECT_CREATED, - s3n.LambdaDestination(email_processor), - s3.NotificationKeyFilter(prefix="inbound/"), - ) - - # --- SES Receipt Rule --- - rule_set = ses.ReceiptRuleSet.from_receipt_rule_set_name( - self, - "ExistingRuleSet", - "INBOUND_MAIL", - ) - - rule_set.add_rule( - "WorkorderEmailRule", - recipients=["apm@int.seahaven.com"], - actions=[ - ses_actions.S3( - bucket=email_bucket, - object_key_prefix="inbound/", - ), - ], - ) - - # --- Web UI auth token secret --- - # Shared secret for the web UI auth gate, stored in Secrets Manager and - # resolved at runtime so the token never appears in CloudFormation templates - # or Lambda environment variables. Create this secret before deploying - # either stack; both PO and WO stacks reference it by name. - web_ui_auth_secret = secretsmanager.Secret.from_secret_name_v2( - self, - "WebUiAuthToken", - "procurement-ingest/web-ui-auth-token", - ) - - # --- Web UI Lambda --- - web_ui_log_group = common.make_function_log_group( - self, "WebUI", "workorder-web-ui" - ) - web_ui = lambda_.Function( - self, - "WebUI", - function_name="workorder-web-ui", - runtime=lambda_.Runtime.PYTHON_3_12, - architecture=lambda_.Architecture.ARM_64, - handler="handler.handler", - code=lambda_.Code.from_asset( - "../lambdas", - exclude=["**/__pycache__/**"], - bundling=cdk.BundlingOptions( - image=lambda_.Runtime.PYTHON_3_12.bundling_image, - command=[ - "bash", - "-c", - # web_ui_auth.py is shared (lambdas/shared) and must land - # FLAT beside handler.py so - # `from web_ui_auth import is_authenticated` resolves at - # runtime. Only web_ui_auth is copied from shared/. WO - # baseline keeps __init__.py and requirements.txt, so the - # whole web_ui dir is copied; deployed file list becomes - # {__init__, handler, requirements.txt, web_ui_auth}. - # NOTE: the top-level `exclude=` on from_asset only - # filters the asset-hash fingerprint, NOT the directory - # Docker bundling actually mounts, so a local - # __pycache__ on disk at synth time WOULD otherwise leak - # into the bundled zip -- strip it explicitly post-cp - # instead of relying on exclude. - "cp -r wo/web_ui/. /asset-output/ && " - "cp shared/web_ui_auth.py /asset-output/ && " - "rm -rf /asset-output/__pycache__", - ], - ), - ), - timeout=Duration.seconds(15), - memory_size=128, - log_group=web_ui_log_group, - environment={ - "WORK_ORDERS_TABLE": work_orders_table.table_name, - "COMMENTS_TABLE": comments_table.table_name, - # Defense-in-depth shared secret for the web UI handler. The - # handler fails closed if this ARN is unset or the secret is - # missing, so any future invocation path cannot re-expose the - # WO DB unauthenticated. The secret value is fetched at runtime - # from Secrets Manager (not embedded in env vars or template). - "WEB_UI_AUTH_TOKEN_SECRET_ARN": web_ui_auth_secret.secret_arn, - }, - ) - - work_orders_table.grant_read_data(web_ui) - comments_table.grant_read_data(web_ui) - web_ui_auth_secret.grant_read(web_ui) - - # --- DynamoDB throttle + system-error alarms --- - # ThrottledRequests / SystemErrors emit at TableName + Operation only - # (verified against live CloudWatch: no TableName-only rollup exists, and - # metric_throttled_requests is deprecated/invalid in aws-cdk-lib 2.261.0). - # Each table currently has zero throttle/error datapoints, so the series - # only materialise on first occurrence — NOT_BREACHING keeps them OK until - # then. - common.add_ddb_alarms( - self, "WorkOrdersTable", work_orders_table, "WorkOrders", alarm_topic - ) - common.add_ddb_alarms( - self, "WorkOrderComments", comments_table, "WorkOrderComments", alarm_topic - ) - - # --- Function ARN + consumed-table-name outputs (Phase 4, additive) --- - cdk.CfnOutput( - self, - "EmailProcessorFunctionArn", - value=email_processor.function_arn, - description="ARN of the workorder-email-processor Lambda", - ) - cdk.CfnOutput( - self, - "WebUiFunctionArn", - value=web_ui.function_arn, - description="ARN of the workorder-web-ui Lambda", - ) - cdk.CfnOutput( - self, - "WorkOrdersTableName", - value=work_orders_table.table_name, - description="WorkOrders DynamoDB table", - ) - cdk.CfnOutput( - self, - "WorkOrderCommentsTableName", - value=comments_table.table_name, - description="WorkOrderComments DynamoDB table", - ) - - # Public Function URL removed 2026-06-08 (INFRA-74 / audit C-5): the - # unauthenticated FunctionUrlAuthType.NONE URL was deleted out-of-band - # via CLI. Removing the construct (and its auto-generated Principal:* - # invoke permission) reconciles IaC with the live state. - - # ===================================================================== - # --- SHOC webhook emitter (docs/shoc-webhook-plan.md) --- - # Realtime work-order feed to the SHOC backend: DynamoDB Streams on the - # two WO tables -> workorder-shoc-emitter -> HMAC-signed HTTPS POST. - # Contract: docs/shoc-webhook-contract.md (Rev 2026-07-23). Built in - # _add_shoc_webhook_emitter (module helper, common.py plain-helper - # style) to keep __init__ under the PLR0915 statement ceiling; the - # stack stays the construct scope, so extraction does not move any - # logical ID. - # ===================================================================== - _add_shoc_webhook_emitter(self, work_orders_table, comments_table, alarm_topic) - - -def _scope_rotation_invoke_permission(stack, rotator_fn, secret): - """Add SourceAccount/SourceArn to the generated rotation invoke permission. - - ``add_rotation_schedule`` emits an ``AWS::Lambda::Permission`` for the - ``secretsmanager.amazonaws.com`` service principal with no source - conditions. Rather than add a second (additive) permission, find that - generated ``CfnPermission`` and pin it to this account + secret so only - this secret's Secrets Manager can invoke the rotator. - """ - patched = False - for child in stack.node.find_all(): - if ( - isinstance(child, lambda_.CfnPermission) - and child.principal == "secretsmanager.amazonaws.com" - and stack.resolve(child.function_name) - == stack.resolve(rotator_fn.function_arn) - ): - child.source_account = stack.account - child.source_arn = secret.secret_arn - patched = True - if not patched: # fail loud if CDK changes the generated shape on upgrade - raise RuntimeError( - "rotation invoke CfnPermission not found; cannot scope source conditions" - ) - - -def _add_shoc_webhook_emitter(stack, work_orders_table, comments_table, alarm_topic): - """SHOC webhook emitter (docs/shoc-webhook-plan.md Phases 1-4). - - Dedicated HMAC CMK + secret with cross-account SHOC read grants, the - 30-day rotation Lambda, the two failure queues, the stream-driven emitter - Lambda with its two (dark, enabled=False) event source mappings, and the - full alarm set. `stack` is the construct scope for every child, exactly as - if this code were inline in __init__. - """ - # SHOC consumer principal for the cross-account read grants. EXACT role - # ARN only -- future shoc-backend-staging/-prod roles are each a - # deliberate, individually-reviewed policy addition (no wildcard or - # prefix trust). - shoc_consumer_principal = iam.ArnPrincipal( - "arn:aws:iam::396287094661:role/shoc-backend-dev" - ) - - # --- Dedicated CMK for the HMAC secret --- - # Dedicated key, NOT alias/seahaven-dynamodb: reusing the DynamoDB CMK - # would hand the SHOC cross-account grant decrypt reach over the PO - # table's encryption key -- the dedicated key scopes the grant to - # exactly this secret (plan Phase 1). - shoc_webhook_key = kms.Key( - stack, - "ShocWebhookHmacKey", - alias="workorder-ingest-shoc-webhook-kms", - description=( - "Dedicated CMK for the workorder-ingest/shoc-webhook-hmac " - "secret (cross-account readable by the SHOC backend)" - ), - enable_key_rotation=True, - # GOTCHA: DESTROY is deliberate -- do not "harden" this to RETAIN. - # The key protects only machine-generated HMAC material that is - # fully regenerable by one rotation, and DESTROY avoids the - # fixed-name RETAIN-orphan deadlock on the alias (mirrors the - # secret's rationale below). - removal_policy=RemovalPolicy.DESTROY, - pending_window=Duration.days(7), - ) - # Key-policy half of the cross-account read grant (the secret resource - # policy below is the other half; either one alone fails silently at - # the receiver). resources=["*"] is key-scoped, not account-wide -- - # KMS key policies only ever apply to this key. - # kms:ViaService pins the grant to Secrets Manager decrypt paths only - # (GPT-4.1 cross-review FIX): a compromised shoc-backend-dev cannot use - # this key for arbitrary KMS operations outside the secret fetch. - shoc_webhook_key.add_to_resource_policy( - iam.PolicyStatement( - actions=["kms:Decrypt"], - principals=[shoc_consumer_principal], - resources=["*"], - conditions={ - "StringEquals": { - "kms:ViaService": (f"secretsmanager.{stack.region}.amazonaws.com") - } - }, - ) - ) - - # --- HMAC signing secret --- - # Value shape (contract section 6.1): - # {"keys": [{"kid": "", "secret": "<64 hex>"}, ...]}, - # newest first, max 2; the producer signs with keys[0]. The - # generate_secret_string below is BOOTSTRAP shape only ({"keys": []} - # plus throwaway entropy the rotator ignores); the first rotation - # (rotate_immediately default) populates the real keys. - shoc_hmac_secret = secretsmanager.Secret( - stack, - "ShocWebhookHmacSecret", - secret_name="workorder-ingest/shoc-webhook-hmac", - encryption_key=shoc_webhook_key, - description=( - "HMAC signing keys for the SHOC work-order webhook " - "(docs/shoc-webhook-contract.md section 6)" - ), - # GOTCHA: DESTROY is deliberate -- do not "harden" this to RETAIN. - # The value is machine-generated HMAC material with no operator-set - # content, fully regenerable by one rotation, so RETAIN buys - # nothing and would expose the fixed-name RETAIN orphan deadlock - # (a failed first create orphans an empty shell holding the global - # name; see reference_secret_retain_orphan_deadlock). - removal_policy=RemovalPolicy.DESTROY, - generate_secret_string=secretsmanager.SecretStringGenerator( - secret_string_template='{"keys": []}', - generate_string_key="bootstrap_entropy", - password_length=32, - exclude_punctuation=True, - ), - ) - cdk.Tags.of(shoc_hmac_secret).add("Purpose", "shoc-webhook-hmac") - cdk.Tags.of(shoc_hmac_secret).add("ManagedBy", "procurement-ingest-cdk") - # Secret-resource-policy half of the cross-account read grant (the key - # policy above is the other half). DescribeSecret lets the receiver - # resolve secret metadata without any broader list permission. - shoc_hmac_secret.add_to_resource_policy( - iam.PolicyStatement( - actions=[ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret", - ], - principals=[shoc_consumer_principal], - resources=["*"], - ) - ) - - # --- HMAC rotation Lambda --- - # 30-day schedule: generates a new key, prepends as keys[0], truncates - # to 2 entries. Single-user rotation (receivers re-fetch on a <=5-min - # TTL), so the standard 4-step rotation collapses to - # createSecret/finishSecret. - shoc_hmac_rotator_log_group = common.make_function_log_group( - stack, "ShocHmacRotator", "workorder-shoc-hmac-rotator" - ) - shoc_hmac_rotator = lambda_.Function( - stack, - "ShocHmacRotator", - function_name="workorder-shoc-hmac-rotator", - runtime=lambda_.Runtime.PYTHON_3_12, - architecture=lambda_.Architecture.ARM_64, - handler="handler.handler", - code=lambda_.Code.from_asset( - "../lambdas", - exclude=["**/__pycache__/**", "**/tests/**", "**/package/**"], - bundling=cdk.BundlingOptions( - image=lambda_.Runtime.PYTHON_3_12.bundling_image, - command=[ - "bash", - "-c", - # Stdlib + boto3-from-runtime only; nothing installed. - "cp wo/shoc_hmac_rotator/*.py /asset-output/", - ], - ), - ), - timeout=Duration.seconds(60), - memory_size=128, - log_group=shoc_hmac_rotator_log_group, - ) - # Rotation permissions, scoped to the one secret. CDK's secret_arn - # token resolves to the full ARN including the -?????? suffix wildcard, - # so no separate "*"-suffixed resource variant is needed. - shoc_hmac_rotator.add_to_role_policy( - iam.PolicyStatement( - actions=[ - "secretsmanager:DescribeSecret", - "secretsmanager:GetSecretValue", - "secretsmanager:PutSecretValue", - "secretsmanager:UpdateSecretVersionStage", - ], - resources=[shoc_hmac_secret.secret_arn], - ) - ) - # Explicit statement instead of grant_encrypt_decrypt so the grant can - # carry kms:ViaService (cross-review FIX): the rotator only ever touches - # this key through Secrets Manager put/get, never the KMS API directly. - shoc_hmac_rotator.add_to_role_policy( - iam.PolicyStatement( - actions=[ - "kms:Decrypt", - "kms:Encrypt", - "kms:GenerateDataKey*", - "kms:ReEncrypt*", - ], - resources=[shoc_webhook_key.key_arn], - conditions={ - "StringEquals": { - "kms:ViaService": (f"secretsmanager.{stack.region}.amazonaws.com") - } - }, - ) - ) - shoc_hmac_secret.add_rotation_schedule( - "Rotation", - rotation_lambda=shoc_hmac_rotator, - automatically_after=Duration.days(30), - ) - # Scope the Secrets-Manager-service invoke permission to THIS secret - # (cross-review FIX / confused-deputy): add_rotation_schedule emits an - # AWS::Lambda::Permission for secretsmanager.amazonaws.com with no - # SourceAccount/SourceArn, so any account's Secrets Manager could invoke - # the rotator by pointing a foreign secret's RotationLambdaARN at it. - # Lambda permissions are additive (OR), so a second scoped permission - # would NOT revoke the unscoped one -- patch the generated permission in - # place. source_arn pins the invoker to this secret; source_account is the - # belt-and-braces account bound. (Blast radius was already contained by - # the rotator role being resource-scoped to this secret, but this closes - # the unauthenticated invoke primitive per AWS rotation guidance.) - _scope_rotation_invoke_permission(stack, shoc_hmac_rotator, shoc_hmac_secret) - - # --- Standard per-Lambda alarms: workorder-shoc-hmac-rotator --- - # errors + throttles + p99 duration. No DLQ alarm: rotation is invoked - # synchronously by Secrets Manager (dlq=None); a failed rotation - # surfaces as an invocation error. - common.add_standard_lambda_alarms( - stack, - "ShocHmacRotator", - shoc_hmac_rotator, - "workorder-shoc-hmac-rotator", - alarm_topic, - duration_statistic="p99", - errors=True, - dlq=None, - descriptions={ - "errors": "workorder-shoc-hmac-rotator invocation errors", - "throttles": "workorder-shoc-hmac-rotator invocation throttles", - "duration": ( - "workorder-shoc-hmac-rotator p99 duration approaching the 60s timeout" - ), - }, - ) - - # --- Emitter failure queues --- - # Failures queue: ESM on_failure destination. It receives ESM failure - # METADATA (shard/sequence pointers), not full payloads -- replay - # rebuilds events from DynamoDB (contract section 8). - shoc_emitter_failures_queue = sqs.Queue( - stack, - "ShocEmitterFailuresQueue", - queue_name="workorder-shoc-emitter-failures", - retention_period=Duration.days(14), - enforce_ssl=True, - ) - # Rejected queue: full {envelope, response_status} payloads parked by - # the handler on non-retryable 4xx responses (contract section 7). - shoc_emitter_rejected_queue = sqs.Queue( - stack, - "ShocEmitterRejectedQueue", - queue_name="workorder-shoc-emitter-rejected", - retention_period=Duration.days(14), - enforce_ssl=True, - ) - - # --- Emitter Lambda --- - shoc_emitter_log_group = common.make_function_log_group( - stack, "ShocEmitter", "workorder-shoc-emitter" - ) - shoc_emitter = lambda_.Function( - stack, - "ShocEmitter", - function_name="workorder-shoc-emitter", - runtime=lambda_.Runtime.PYTHON_3_12, - architecture=lambda_.Architecture.ARM_64, - handler="handler.handler", - code=lambda_.Code.from_asset( - "../lambdas", - exclude=["**/__pycache__/**", "**/tests/**", "**/package/**"], - bundling=cdk.BundlingOptions( - image=lambda_.Runtime.PYTHON_3_12.bundling_image, - command=[ - "bash", - "-c", - # Stdlib HTTP (urllib.request) + boto3-from-runtime - # only; nothing installed. - "cp wo/shoc_emitter/*.py /asset-output/", - ], - ), - ), - timeout=Duration.seconds(60), - memory_size=256, - log_group=shoc_emitter_log_group, - environment={ - # Non-sensitive endpoint URL (HMAC is the auth, the URL is - # not). path TBD by SHOC -- confirmed in the activation PR. - "SHOC_WEBHOOK_URL": ( - "https://api.dev.seahaven.com/api/webhooks/work-orders" - ), - "HMAC_SECRET_ARN": shoc_hmac_secret.secret_arn, - "REJECTED_QUEUE_URL": shoc_emitter_rejected_queue.queue_url, - }, - ) - - work_orders_table.grant_stream_read(shoc_emitter) - comments_table.grant_stream_read(shoc_emitter) - shoc_hmac_secret.grant_read(shoc_emitter) - # Explicit statement instead of grant_decrypt so the grant carries - # kms:ViaService (cross-review FIX): the emitter only decrypts this key - # through Secrets Manager GetSecretValue. - shoc_emitter.add_to_role_policy( - iam.PolicyStatement( - actions=["kms:Decrypt"], - resources=[shoc_webhook_key.key_arn], - conditions={ - "StringEquals": { - "kms:ViaService": (f"secretsmanager.{stack.region}.amazonaws.com") - } - }, - ) - ) - shoc_emitter_rejected_queue.grant_send_messages(shoc_emitter) - - # ------------------------------------------------------------------ - # ACTIVATED (enabled=True) 2026-07-30 after SHOC's receiver passed the - # shared HMAC test vectors. Shipped DARK originally (enabled=False) so the - # stack could deploy and be tested with zero deliveries while SHOC had no - # receiver; this activation PR is the deliberate one-line flip (plan Phase - # 3). LATEST start position => the feed begins now, no historical flood; - # SHOC backfills history via the procurement read API, not the stream. - # Ordering knobs: parallelization_factor=1, bisect_batch_on_error= - # False and retry_attempts=-1 (retry until the 24h record age) are - # REQUIRED for strict per-work-order in-order delivery -- a retryable - # failure blocks the shard rather than skipping ahead, and - # report_batch_item_failures keeps earlier in-batch successes from - # being re-delivered. - # ------------------------------------------------------------------ - shoc_emitter.add_event_source( - lambda_event_sources.DynamoEventSource( - work_orders_table, - starting_position=lambda_.StartingPosition.LATEST, - batch_size=10, - bisect_batch_on_error=False, - retry_attempts=-1, - max_record_age=Duration.hours(24), - parallelization_factor=1, - report_batch_item_failures=True, - enabled=True, - on_failure=lambda_event_sources.SqsDlq(shoc_emitter_failures_queue), - ) - ) - shoc_emitter.add_event_source( - lambda_event_sources.DynamoEventSource( - comments_table, - starting_position=lambda_.StartingPosition.LATEST, - batch_size=10, - bisect_batch_on_error=False, - retry_attempts=-1, - max_record_age=Duration.hours(24), - parallelization_factor=1, - report_batch_item_failures=True, - enabled=True, - on_failure=lambda_event_sources.SqsDlq(shoc_emitter_failures_queue), - ) - ) - - # --- Standard per-Lambda alarms: workorder-shoc-emitter --- - # errors + throttles + p99 duration. No DLQ alarm here: the emitter is - # a stream consumer with no async DLQ (dlq=None); its failure surfaces - # are the two SQS queues alarmed bespoke below. - common.add_standard_lambda_alarms( - stack, - "ShocEmitter", - shoc_emitter, - "workorder-shoc-emitter", - alarm_topic, - duration_statistic="p99", - errors=True, - dlq=None, - descriptions={ - "errors": "workorder-shoc-emitter invocation errors", - "throttles": "workorder-shoc-emitter invocation throttles", - "duration": ( - "workorder-shoc-emitter p99 duration approaching the 60s timeout" - ), - }, - ) - - # --- Bespoke emitter alarms (plan Phase 4) --- - # These don't fit add_standard_lambda_alarms' shape and stay bespoke. - # Iterator age >= 10 min sustained means SHOC is likely down and the - # shard is blocking (exactly the ordered-backpressure design working); - # the two SQS-visible alarms page the operator replay runbook. - shoc_emitter.metric( - "IteratorAge", - statistic="Maximum", - period=Duration.minutes(5), - ).create_alarm( - stack, - "ShocEmitterIteratorAgeAlarm", - alarm_name="workorder-shoc-emitter-iterator-age", - alarm_description=( - "workorder-shoc-emitter stream lag >= 10 min " - "(SHOC receiver likely down; shard blocking on retries)" - ), - threshold=600000, - evaluation_periods=3, - datapoints_to_alarm=2, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - shoc_emitter_failures_queue.metric_approximate_number_of_messages_visible( - period=Duration.minutes(5), - statistic="Maximum", - ).create_alarm( - stack, - "ShocEmitterFailuresMessagesAlarm", - alarm_name="workorder-shoc-emitter-failures-messages", - alarm_description=( - "workorder-shoc-emitter retry-exhausted stream records parked " - "(ESM failure metadata; replay rebuilds from DynamoDB)" - ), - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - shoc_emitter_rejected_queue.metric_approximate_number_of_messages_visible( - period=Duration.minutes(5), - statistic="Maximum", - ).create_alarm( - stack, - "ShocEmitterRejectedMessagesAlarm", - alarm_name="workorder-shoc-emitter-rejected-messages", - alarm_description=( - "workorder-shoc-emitter parked non-retryable 4xx deliveries " - "(contract bug; inspect payloads and replay)" - ), - threshold=0, - evaluation_periods=1, - comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, - ).add_alarm_action(cw_actions.SnsAction(alarm_topic)) - - cdk.CfnOutput( - stack, - "ShocWebhookHmacSecretArn", - value=shoc_hmac_secret.secret_arn, - description=( - "SHOC webhook HMAC secret ARN -- hand off to Luby (SHOC team) " - "for the cross-account receiver fetch" - ), - ) diff --git a/ruff.toml b/ruff.toml index 4ca9eb3..9a48e0b 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,6 +1,5 @@ # Phase 8: make the C901/PLR complexity ceilings ENFORCED, not decorative — # the pre-existing `noqa: PLR09xx` suppressions were inert with no config. -extend-exclude = ["cdk.out"] [lint] extend-select = ["C901", "PLR"] @@ -26,8 +25,5 @@ max-complexity = 12 "lambdas/wo/email_processor/template_parser.py" = ["C901", "PLR0911", "PLR0912"] "lambdas/shared/ses_auth.py" = ["C901", "PLR0911"] "lambdas/shared/emf.py" = ["PLR0913"] # 6-arg emitter IS the Phase 3 envelope contract -# Phase 4 plain helpers take (scope, id, ...) positionally by design to keep -# construct logical IDs byte-stable; the arg count IS the contract (like emf.py). -"cdk/common.py" = ["PLR0913"] "lambdas/po/site_extractor/handler.py" = ["PLR0911"] # Phase 6 owns this file "scripts/reprocess.py" = ["PLR0912"] # argparse validation ladder; Phase 7 file diff --git a/scripts/setup_procurement_api_domain.sh b/scripts/setup_procurement_api_domain.sh index 9646bd6..73614ec 100755 --- a/scripts/setup_procurement_api_domain.sh +++ b/scripts/setup_procurement_api_domain.sh @@ -14,14 +14,14 @@ # - adds its DNS-validation CNAME to the mgmt seahaven.com zone # - waits for ISSUED, then writes the cert ARN to prod SSM # (/procurement-api/custom-domain/certificate-arn) -# 2. deploy the procurement-api stack (cdk deploy procurement-api) -- it -# reads the SSM param and creates the API Gateway DomainName + mapping +# 2. HCP Terraform apply on workspace procurement-ingest-prod -- it reads +# the SSM param and owns the API Gateway DomainName + mapping # 3. ./setup_procurement_api_domain.sh alias -# - reads the stack's regional alias target from the outputs +# - reads the regional alias target from API Gateway get-domain-name # - adds the A-alias (procurement-api.seahaven.com -> API GW) to the # mgmt zone # -# Requires SSO sessions for BOTH profiles (prod for ACM/SSM, mgmt for Route53). +# Requires SSO sessions for BOTH profiles (prod for ACM/SSM/API GW, mgmt for Route53). ############################################################################### set -euo pipefail @@ -33,7 +33,6 @@ PROD_ACCOUNT="011934824531" MGMT_ACCOUNT="328440206208" ZONE_ID="Z06652411XKH89KTZD3XA" # seahaven.com public zone, in the mgmt account SSM_PARAM="/procurement-api/custom-domain/certificate-arn" -STACK_NAME="procurement-api" _verify_account() { local profile="$1" expected="$2" @@ -89,23 +88,23 @@ JSON aws ssm put-parameter --profile "${PROD_PROFILE}" --region "${REGION}" \ --name "${SSM_PARAM}" --type String --overwrite --value "${cert_arn}" >/dev/null - echo "OK: cert ISSUED and SSM param set. Now: cdk deploy ${STACK_NAME}, then '$0 alias'." + echo "OK: cert ISSUED and SSM param set. Next: HCP apply on procurement-ingest-prod, then '$0 alias'." } cmd_alias() { _verify_account "${PROD_PROFILE}" "${PROD_ACCOUNT}" _verify_account "${MGMT_PROFILE}" "${MGMT_ACCOUNT}" - echo "==> Reading regional alias target from the ${STACK_NAME} stack outputs" + echo "==> Reading regional alias target from API Gateway domain ${DOMAIN}" local target zone - target="$(aws cloudformation describe-stacks --profile "${PROD_PROFILE}" --region "${REGION}" \ - --stack-name "${STACK_NAME}" \ - --query "Stacks[0].Outputs[?OutputKey=='ProcurementApiAliasTarget'].OutputValue | [0]" --output text)" - zone="$(aws cloudformation describe-stacks --profile "${PROD_PROFILE}" --region "${REGION}" \ - --stack-name "${STACK_NAME}" \ - --query "Stacks[0].Outputs[?OutputKey=='ProcurementApiAliasHostedZoneId'].OutputValue | [0]" --output text)" + target="$(aws apigateway get-domain-name --profile "${PROD_PROFILE}" --region "${REGION}" \ + --domain-name "${DOMAIN}" \ + --query "regionalDomainName" --output text)" + zone="$(aws apigateway get-domain-name --profile "${PROD_PROFILE}" --region "${REGION}" \ + --domain-name "${DOMAIN}" \ + --query "regionalHostedZoneId" --output text)" if [[ -z "${target}" || "${target}" == "None" ]]; then - echo "ERROR: no alias target output; deploy the stack first." >&2 + echo "ERROR: no regionalDomainName for ${DOMAIN}; HCP-apply the API custom domain first." >&2 exit 1 fi diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index ddd2d5b..b8c8002 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -4,5 +4,6 @@ web_ui_auth_token_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:se shoc_hmac_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB" # Required: no Terraform default. Live emitter target today (contract Rev 2026-07-23). shoc_webhook_url = "https://api.dev.seahaven.com/api/webhooks/work-orders" -# Required exact-ARN pin for cross-account HMAC read (docs/shoc-webhook-contract.md). +# Exact-ARN pin for cross-account HMAC/API trust (matches variable default; +# docs/shoc-webhook-contract.md). Changing this is a deliberate IAM review. shoc_consumer_role_arn = "arn:aws:iam::396287094661:role/shoc-backend-dev" diff --git a/terraform/variables.tf b/terraform/variables.tf index 1df0930..7eee3f6 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -21,5 +21,6 @@ variable "shoc_webhook_url" { variable "shoc_consumer_role_arn" { type = string - description = "Exact IAM role ARN allowed to GetSecretValue / kms:Decrypt the SHOC HMAC secret (cross-account consumer). Live pin today is arn:aws:iam::396287094661:role/shoc-backend-dev per docs/shoc-webhook-contract.md." + description = "Exact IAM role ARN allowed to GetSecretValue / kms:Decrypt the SHOC HMAC secret and invoke the read API (cross-account consumer). Default is the live pin per docs/shoc-webhook-contract.md; changing it is a deliberate cross-family IAM review." + default = "arn:aws:iam::396287094661:role/shoc-backend-dev" } diff --git a/tests/test_bundle_consistency.py b/tests/test_bundle_consistency.py index fc17f13..8332a7c 100644 --- a/tests/test_bundle_consistency.py +++ b/tests/test_bundle_consistency.py @@ -1,34 +1,32 @@ -"""AST-based bundle-consistency test for the email-processor Lambdas. +"""Bundle-consistency tests for Terraform Lambda packaging. -Verifies that each pipeline's CDK bundling `command` actually ships every -first-party sibling module handler.py imports into /asset-output. This -guards against a regression where the bundling `cp` step (whether an -explicit filename allowlist or a glob) silently drops a module the handler -depends on -- history: PR #105 shipped without template_parser.py, and PR #2 -nearly shipped without derived_fields.py, both allowlist-maintenance misses -that would ImportError at runtime. +Verifies that terraform/build_packages.sh actually ships every first-party +sibling module each handler imports. Guards against a regression where the +copy step silently drops a module the handler depends on -- history: PR #105 +shipped without template_parser.py, and PR #2 nearly shipped without +derived_fields.py, both allowlist-maintenance misses that would ImportError +at runtime. -Pure ast + file reads -- no AWS/boto3/CDK synth, no handler import, no moto. +Pure file reads + ast on handlers -- no AWS/boto3, no Terraform plan, no moto. Fast and has no dependency on the moto-before-handler import-order invariant that the rest of the suite relies on. """ import ast import re +from dataclasses import dataclass, field from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] +BUILD_PACKAGES = REPO_ROOT / "terraform" / "build_packages.sh" PO_HANDLER = REPO_ROOT / "lambdas" / "po" / "email_processor" / "handler.py" WO_HANDLER = REPO_ROOT / "lambdas" / "wo" / "email_processor" / "handler.py" API_HANDLER = REPO_ROOT / "lambdas" / "api" / "handler.py" SHOC_EMITTER_HANDLER = REPO_ROOT / "lambdas" / "wo" / "shoc_emitter" / "handler.py" SHOC_ROTATOR_HANDLER = REPO_ROOT / "lambdas" / "wo" / "shoc_hmac_rotator" / "handler.py" -PO_STACK = REPO_ROOT / "cdk" / "po_stack.py" -WO_STACK = REPO_ROOT / "cdk" / "wo_stack.py" -API_STACK = REPO_ROOT / "cdk" / "procurement_api_stack.py" # Phase 3: ses_auth/web_ui_auth/email_parsing/emf are single-sourced here and -# shipped into the email-processor bundles via `cp shared/*.py`. +# shipped into the email-processor bundles via copy_shared_all. SHARED_DIR = REPO_ROOT / "lambdas" / "shared" # The names Phase 3 single-sourced under lambdas/shared/. After the move NONE @@ -48,19 +46,11 @@ SHARED_DIR = REPO_ROOT / "lambdas" / "shared" SHARED_MODULES = frozenset({"ses_auth", "email_parsing", "emf", "web_ui_auth"}) # Exact top-level .py stems each dir must hold. Pinned as exact sets (both -# bounds): the bundling globs (`cp /email_processor/*.py` + -# `cp shared/*.py`) ship every top-level .py in these dirs, and -# `Code.from_asset` stages untracked files too (it does not honor .gitignore), -# so a stray scratch/secrets .py -- or a shadow copy of a shared module -- would -# silently ship into the production zip on a local deploy. Any new top-level -# module must be added here deliberately, the moment to decide whether it SHOULD -# ship (a real module) or must be excluded. -# Phase 5 decomposed each God-handler into flat siblings (constraint 6): the -# non-recursive `cp /email_processor/*.py` glob auto-ships them, but -# the exact-set pin must list every new sibling or the ships-no-unexpected test -# fails -- keeping the teeth (a sibling not added here, or a commented-out cp, -# still fails). PO gained prompts/extraction/enrichment/telemetry/persistence; -# WO the same minus enrichment (it has no enrichment stage). +# bounds): copy_py_dir ships every top-level .py in these dirs, so a stray +# scratch/secrets .py -- or a shadow copy of a shared module -- would silently +# ship into the production zip. Any new top-level module must be added here +# deliberately, the moment to decide whether it SHOULD ship (a real module) or +# must be excluded. PO_PIPELINE_MODULES = frozenset( { "handler", @@ -85,19 +75,16 @@ WO_PIPELINE_MODULES = frozenset( } ) -# Full shipped set of each email-processor bundle (pipeline glob + shared glob). +# Full shipped set of each email-processor bundle (pipeline dir + shared). # Derived from the per-dir pins above so it stays consistent with them; policed # per-dir (NOT via this union) so a shared-name shadow in a pipeline dir cannot -# be masked. web_ui_auth is a deliberate, harmless ride-along of the pinned -# `cp shared/*.py` (constraint #8) -- never imported by the email handlers, but -# it ships, so it is part of the shipped set. +# be masked. web_ui_auth is a deliberate, harmless ride-along of copy_shared_all +# (constraint #8) -- never imported by the email handlers, but it ships, so it +# is part of the shipped set. PO_EXPECTED_TOP_LEVEL_MODULES = PO_PIPELINE_MODULES | SHARED_MODULES WO_EXPECTED_TOP_LEVEL_MODULES = WO_PIPELINE_MODULES | SHARED_MODULES -# procurement-api (lambdas/api/): a fourth bundled function, in its own stack. -# Same exact-set discipline as the pipeline dirs -- `cp api/*.py` ships every -# top-level .py here (untracked strays included), so any new module is a -# deliberate addition to this pin. +# procurement-api (lambdas/api/): same exact-set discipline. API_PIPELINE_MODULES = frozenset( { "handler", @@ -108,53 +95,28 @@ API_PIPELINE_MODULES = frozenset( "po_repo", } ) -# Non-.py files the api bundle must also EXECUTE cps for: the handler serves -# the spec, docs page, and the vendored Redoc bundle from its own package -# dir, so dropping any cp 500s /docs at runtime with green CI. -API_DATA_FILES_CP_RES = ( - r"(?:^|\s)api/openapi\.json(?:\s|$)", - r"(?:^|\s)api/docs\.html(?:\s|$)", - r"(?:^|\s)api/redoc\.standalone\.js(?:\s|$)", - r"(?:^|\s)api/fonts\.css(?:\s|$)", +# Non-.py files the api package must also copy: the handler serves the spec, +# docs page, and the vendored Redoc bundle from its own package dir, so dropping +# any copy 500s /docs at runtime with green CI. +API_DATA_FILES = ( + "api/openapi.json", + "api/docs.html", + "api/redoc.standalone.js", + "api/fonts.css", ) -# SHOC webhook emitter + HMAC rotator (lambdas/wo/shoc_emitter|shoc_hmac_rotator, -# bundled by cdk/wo_stack.py's _add_shoc_webhook_emitter). Same exact-set -# discipline as the other bundles: `cp wo/shoc_emitter/*.py` / -# `cp wo/shoc_hmac_rotator/*.py` ship every top-level .py in those dirs -# (untracked strays included -- Code.from_asset does not honor .gitignore), so -# any new module is a deliberate addition to these pins. +# SHOC webhook emitter + HMAC rotator. Same exact-set discipline. SHOC_EMITTER_MODULES = frozenset({"__init__", "handler", "envelope", "delivery"}) SHOC_ROTATOR_MODULES = frozenset({"__init__", "handler"}) -# Pipeline-scoped glob shapes the per-stack ships-all pins accept. Phase 2 -# widened the bundling cwd to the shared ../lambdas asset root, so the executed -# glob carries its own pipeline's path prefix (`po/email_processor/*.py`); a -# bare `*.py`/`./*.py` prefix is still accepted for the legacy in-dir cwd. A -# WRONG-pipeline prefix (`wo/email_processor/*.py` in po_stack) or an arbitrary -# directory (`venv/lib/*.py`) is deliberately NOT accepted: it would false-pass -# the ships-all shape check while staging a bundle missing a required sibling -# (e.g. derived_fields.py, which lives only under po/) -- a runtime ImportError -# that green CI must never wave through. Do NOT relax these to an any-prefix -# pattern: that reintroduces exactly the wrong-pipeline false-pass this pins out. -PO_SCOPED_GLOB_RE = r"(?:^|\s)(?:\./|po/email_processor/)?\*\.py(?:\s|$)" -WO_SCOPED_GLOB_RE = r"(?:^|\s)(?:\./|wo/email_processor/)?\*\.py(?:\s|$)" -# Phase 3: both email-processor bundles gain a second glob copying the -# single-sourced shared modules flat into the zip. This must be the EXECUTED -# form (`_executed_cp_commands` strips comments), so a commented-out shared cp -# cannot false-pass the pin. -SHARED_CP_RE = r"(?:^|\s)shared/\*\.py(?:\s|$)" +@dataclass +class PackageRecipe: + """What build_packages.sh copies into one terraform/build/ dir.""" -# Phase 3: each stack's web_ui function stages ONLY shared/web_ui_auth.py flat -# beside its handler (NOT all of shared/ -- the email-only modules must not -# bloat the web UI zip). The auth-gated web_ui handlers do a module-top-level -# `from web_ui_auth import is_authenticated`, so dropping/commenting this cp -# ImportErrors the Lambda at cold start with green CI -- the PR #105 ImportError -# class the AST test exists to prevent, but for a surface (web_ui) the -# email-processor selector deliberately excludes. Checked as the EXECUTED form -# so a commented-out cp cannot false-pass. -WEB_UI_AUTH_CP_RE = r"(?:^|\s)shared/web_ui_auth\.py(?:\s|$)" + py_dirs: list[str] = field(default_factory=list) + shared_all: bool = False + src_files: list[str] = field(default_factory=list) def _first_party_sibling_imports(handler_path: Path) -> set[str]: @@ -164,7 +126,7 @@ def _first_party_sibling_imports(handler_path: Path) -> set[str]: statements -- not imports nested in functions -- and keeps a name only if `/X.py` exists, which filters out stdlib/third-party imports (json, os, boto3, ...) and keeps exactly the modules the - bundling step is obligated to ship. + packaging step is obligated to ship. """ tree = ast.parse(handler_path.read_text()) names: set[str] = set() @@ -179,8 +141,7 @@ def _first_party_sibling_imports(handler_path: Path) -> set[str]: # A first-party sibling resolves either next to the handler (per-pipeline: # template_parser/derived_fields) or under lambdas/shared/ (single-sourced: # ses_auth/email_parsing/emf, Phase 3). Both must count, or the ships-all - # pin would silently drop the shared siblings (ses_auth was silently - # dropped, email_parsing/emf never seen, before this resolved shared/). + # pin would silently drop the shared siblings. return { name for name in names @@ -188,141 +149,95 @@ def _first_party_sibling_imports(handler_path: Path) -> set[str]: } -def _extract_bundling_command(stack_path: Path) -> str: - """Extract the bash -c bundling command string from a CDK stack file. +_FOR_LOOP_RE = re.compile( + r"for\s+(\w+)\s+in\s+([^;]+);\s*do\s*" + r'copy_src_file\s+"([^"]*)\$\{\1\}([^"]*)"\s+"(\$\{BUILD\}/[^"]*)\$\{\1\}([^"]*)"\s*' + r"done", + re.MULTILINE, +) - Locates the `command=[...]` keyword argument to BundlingOptions and - returns its final list element's string value. Adjacent string-literal - concatenation (used in both stacks to keep the command readable across - lines, with `#` comments interspersed) is folded by the parser itself, - so this returns the exact single command string CDK will hand to bash. - Since Phase 3 each stack has TWO bundled functions -- the email processor - and the web_ui function (which now also stages a shared module). "The" - bundling command this test cares about is the EMAIL-processor one, so we - select the command whose text mentions ``email_processor`` (its first cp - is ``cp /email_processor/*.py``) and demand exactly one match -- - the web_ui command (``cp -r /web_ui/.``) and site_extractor do - not match. +def _parse_build_packages(script_text: str | None = None) -> dict[str, PackageRecipe]: + """Parse copy_* calls from build_packages.sh into per-package recipes. + + Strips `#` comments so a commented-out copy cannot false-pass. Expands + simple `for f in a b c; do copy_src_file "api/${f}" ...; done` loops used + for the procurement-api static assets. """ - tree = ast.parse(stack_path.read_text()) - commands: list[str] = [] - for node in ast.walk(tree): - if isinstance(node, ast.keyword) and node.arg == "command": - list_node = node.value - if isinstance(list_node, ast.List) and list_node.elts: - last = list_node.elts[-1] - if isinstance(last, ast.Constant) and isinstance(last.value, str): - commands.append(last.value) - email_cmds = [c for c in commands if "email_processor" in c] - if len(email_cmds) != 1: - raise AssertionError( - f"expected exactly one email-processor bundling command=[...] list in " - f"{stack_path}, found {len(email_cmds)} (of {len(commands)} total " - "command lists) — update this test to select the intended bundling " - "command explicitly" - ) - return email_cmds[0] + text = BUILD_PACKAGES.read_text() if script_text is None else script_text + # Drop full-line and trailing comments before parsing. + cleaned_lines = [] + for raw_line in text.splitlines(): + cleaned_lines.append(raw_line.split("#", 1)[0]) + text = "\n".join(cleaned_lines) + recipes: dict[str, PackageRecipe] = {} + def _pkg(dest: str) -> PackageRecipe: + # dest is "${BUILD}/po_email_processor" or "${BUILD}/po_web_ui/web_ui_auth.py" + m = re.match(r"\$\{BUILD\}/([^/\s\"']+)", dest) + if not m: + raise AssertionError(f"unrecognized build dest: {dest!r}") + name = m.group(1) + return recipes.setdefault(name, PackageRecipe()) -def _extract_web_ui_command(stack_path: Path) -> str: - """Extract the web_ui function's bash -c bundling command string. + # Expand for-loops first so ${f} never lands as a literal src path. + for match in _FOR_LOOP_RE.finditer(text): + items = match.group(2).split() + src_prefix, src_suffix = match.group(3), match.group(4) + dest_prefix, dest_suffix = match.group(5), match.group(6) + for item in items: + dest = f"{dest_prefix}{item}{dest_suffix}" + _pkg(dest).src_files.append(f"{src_prefix}{item}{src_suffix}") + text_without_loops = _FOR_LOOP_RE.sub("", text) - Mirrors _extract_bundling_command but selects the command whose text - mentions ``web_ui`` (its first cp is ``cp -r /web_ui/.``). The - email-processor command (``cp /email_processor/*.py``, plus - ``cp shared/*.py``) and site_extractor do not contain ``web_ui`` in the - folded command STRING (the explanatory ``# ... web_ui_auth ...`` comments - around the email command are Python comments, not string content, so they - are not part of the folded literal). Demands exactly one match so an - ambiguity surfaces loudly instead of silently checking the wrong command. - """ - tree = ast.parse(stack_path.read_text()) - commands: list[str] = [] - for node in ast.walk(tree): - if isinstance(node, ast.keyword) and node.arg == "command": - list_node = node.value - if isinstance(list_node, ast.List) and list_node.elts: - last = list_node.elts[-1] - if isinstance(last, ast.Constant) and isinstance(last.value, str): - commands.append(last.value) - web_ui_cmds = [c for c in commands if "web_ui" in c] - if len(web_ui_cmds) != 1: - raise AssertionError( - f"expected exactly one web_ui bundling command=[...] list in " - f"{stack_path}, found {len(web_ui_cmds)} (of {len(commands)} total " - "command lists) — update this test to select the intended bundling " - "command explicitly" - ) - return web_ui_cmds[0] - - -def _executed_cp_commands(command: str) -> list[str]: - """The `cp ...` invocations bash would actually execute, comment-stripped. - - Splits the bundling command on shell separators (`&&`, `;`, `|`, newline), - drops any trailing `#` comment from each segment, and returns the segments - whose command word is `cp`. This is deliberately stricter than a substring - match: a glob or `cp -r` string that survives only inside a comment - (e.g. `cp handler.py /asset-output/ # was: cp ./*.py /asset-output/`) is - NOT returned, because bash would not execute it -- so a revert that strips - the real copy but leaves the old text commented cannot false-pass. - """ - segments = re.split(r"&&|\|\||;|\||\n", command) - cp_cmds: list[str] = [] - for seg in segments: - seg = seg.split("#", 1)[0].strip() - if re.match(r"cp\b", seg): - cp_cmds.append(seg) - return cp_cmds - - -def _bundling_ships_all(command: str, sibling_names: set[str]) -> bool: - """True if `command` is guaranteed to ship every name in `sibling_names`. - - Inspects only the `cp` commands bash actually executes (comments stripped - via `_executed_cp_commands`) and ACCUMULATES the set of shipped module - stems across ALL of them -- because since Phase 3 the required siblings - ship via TWO globs, not one: `cp /email_processor/*.py` covers - the per-pipeline siblings and `cp shared/*.py` covers the single-sourced - ones (ses_auth/email_parsing/emf). A single-cp "one glob ships everything" - check would wrongly report False now that coverage is split. - - Each executed cp contributes to the shipped set as follows: - - a non-recursive `*.py` glob ships every top-level .py in the globbed - directory -- but ONLY that directory. Its (optional) path prefix is - resolved against the ../lambdas asset root (the Phase 2 bundling cwd) - and every `.py` stem actually present there is added. A wrong-pipeline - or arbitrary-directory glob (`cp wo/email_processor/*.py` in po_stack, - `cp venv/lib/*.py`) therefore contributes only what that dir really - holds (or nothing, if it does not exist) and can never cover a sibling - that lives elsewhere; - - a recursive copy of the WHOLE source dir, e.g. `cp -r . /asset-output/` - (`.`/`./` source only), ships everything -> short-circuit True; - - any other executed `cp` is an explicit filename allowlist (the legacy - PO form): each copied `.py` stem is added, so a reverted - allowlist missing a sibling leaves that sibling out of the set. - Returns True only if every required sibling ended up in the accumulated set. - """ - cp_cmds = _executed_cp_commands(command) - if not cp_cmds: - return False - shipped: set[str] = set() - for cp in cp_cmds: - glob_match = re.search(r"(?:^|\s)((?:[\w./-]+/)?)\*\.py(?:\s|$)", cp) - if glob_match: - glob_dir = (REPO_ROOT / "lambdas" / glob_match.group(1)).resolve() - shipped.update(p.stem for p in glob_dir.glob("*.py")) + for raw_line in text_without_loops.splitlines(): + line = raw_line.strip() + if not line: continue - if re.search(r"cp\s+-r\s+\.\/?\s+/asset-output", cp): - return True - # Explicit-allowlist shape: collect the copied source filenames, - # ignoring any cp flags and the trailing /asset-output destination. - match = re.search( - r"cp\s+(?:-\S+\s+)*(.*?)\s*/asset-output/?\"?\s*$", cp.strip() + + m = re.match( + r'copy_py_dir\s+"([^"]+)"\s+"(\$\{BUILD\}/[^"]+)"', + line, ) - if match: - shipped.update(Path(f).stem for f in match.group(1).split()) + if m: + _pkg(m.group(2)).py_dirs.append(m.group(1)) + continue + + m = re.match(r'copy_shared_all\s+"(\$\{BUILD\}/[^"]+)"', line) + if m: + _pkg(m.group(1)).shared_all = True + continue + + m = re.match( + r'copy_src_file\s+"([^"]+)"\s+"(\$\{BUILD\}/[^"]+)"', + line, + ) + if m: + _pkg(m.group(2)).src_files.append(m.group(1)) + continue + + return recipes + + +def _shipped_modules(recipe: PackageRecipe) -> set[str]: + """Module stems a PackageRecipe would place at the zip root.""" + shipped: set[str] = set() + for rel_dir in recipe.py_dirs: + glob_dir = REPO_ROOT / "lambdas" / rel_dir + shipped.update(p.stem for p in glob_dir.glob("*.py")) + if recipe.shared_all: + shipped.update(p.stem for p in SHARED_DIR.glob("*.py")) + for rel in recipe.src_files: + if rel.endswith(".py"): + shipped.add(Path(rel).stem) + return shipped + + +def _packaging_ships_all(recipe: PackageRecipe, sibling_names: set[str]) -> bool: + """True if recipe is guaranteed to ship every name in sibling_names.""" + if not recipe.py_dirs and not recipe.shared_all and not recipe.src_files: + return False + shipped = _shipped_modules(recipe) return all(name in shipped for name in sibling_names) @@ -333,33 +248,22 @@ def test_po_bundling_ships_all_first_party_siblings(): # an empty set, the test below would vacuously pass -- guard against that. assert siblings, "expected first-party sibling imports in PO handler.py" - command = _extract_bundling_command(PO_STACK) + recipes = _parse_build_packages() + recipe = recipes["po_email_processor"] - # Pinned per the Phase 0 healthcheck/bundling contract: PO's bundling - # command must EXECUTE the non-recursive glob, not a hand-maintained - # allowlist. Checked against the executed cp (comments stripped) so the - # old glob text surviving only in a comment cannot satisfy this. - executed_cps = _executed_cp_commands(command) - assert any(re.search(PO_SCOPED_GLOB_RE, cp) for cp in executed_cps), ( - "cdk/po_stack.py bundling command must EXECUTE the PO-scoped non-recursive " - "glob 'cp po/email_processor/*.py /asset-output/' so every first-party " - "sibling handler.py imports ships automatically. A wrong-pipeline or " - "arbitrary-directory glob is rejected here on purpose. " - f"Executed cp commands: {executed_cps}" + assert "po/email_processor" in recipe.py_dirs, ( + "terraform/build_packages.sh must copy_py_dir po/email_processor into " + "po_email_processor so every first-party sibling handler.py imports " + f"ships automatically. Recipe: {recipe}" ) - # Phase 3: the shared siblings (ses_auth/email_parsing/emf) ship via a - # SECOND executed glob, `cp shared/*.py /asset-output/`. Require it to be - # actually executed (comment-stripped), so commenting it out fails here; - # combined with ships-all below (those siblings resolve only under shared/) - # a removed/commented shared cp fails BOTH assertions. - assert any(re.search(SHARED_CP_RE, cp) for cp in executed_cps), ( - "cdk/po_stack.py email-processor bundling command must EXECUTE " - "'cp shared/*.py /asset-output/' so the single-sourced shared modules " - f"ship flat beside handler.py. Executed cp commands: {executed_cps}" + assert recipe.shared_all, ( + "terraform/build_packages.sh must copy_shared_all into " + "po_email_processor so the single-sourced shared modules ship flat " + f"beside handler.py. Recipe: {recipe}" ) - assert _bundling_ships_all(command, siblings), ( - f"cdk/po_stack.py bundling command does not ship all of {sorted(siblings)}: " - f"{command!r}" + assert _packaging_ships_all(recipe, siblings), ( + f"po_email_processor packaging does not ship all of {sorted(siblings)}: " + f"{recipe}" ) @@ -367,114 +271,82 @@ def test_wo_bundling_ships_all_first_party_siblings(): siblings = _first_party_sibling_imports(WO_HANDLER) assert siblings, "expected first-party sibling imports in WO handler.py" - command = _extract_bundling_command(WO_STACK) + recipes = _parse_build_packages() + recipe = recipes["wo_email_processor"] - # Phase 2: WO now ships via the same scoped non-recursive glob as PO, - # copying only wo/email_processor/*.py from the widened ../lambdas asset - # root. This deliberately drops tests/, __pycache__, and requirements.txt - # from the production zip (docs/refactor-evaluation.md Phase 2). Checked - # against the comment-stripped executed cp so an old `cp -r .` surviving - # only in a comment cannot satisfy this, and a revert to the whole-dir - # copy (which would re-ship tests/) fails loudly. - executed_cps = _executed_cp_commands(command) - assert any(re.search(WO_SCOPED_GLOB_RE, cp) for cp in executed_cps), ( - "cdk/wo_stack.py bundling command must EXECUTE the WO-scoped non-recursive " - "glob 'cp wo/email_processor/*.py /asset-output/' so every first-party " - "sibling ships while tests/ and requirements.txt are excluded. A " - "wrong-pipeline or arbitrary-directory glob is rejected here on purpose. " - f"Executed cp commands: {executed_cps}" + assert "wo/email_processor" in recipe.py_dirs, ( + "terraform/build_packages.sh must copy_py_dir wo/email_processor into " + "wo_email_processor so every first-party sibling ships while tests/ " + f"and requirements.txt are excluded. Recipe: {recipe}" ) - # Phase 3: shared siblings ship via the second executed glob `cp shared/*.py`. - assert any(re.search(SHARED_CP_RE, cp) for cp in executed_cps), ( - "cdk/wo_stack.py email-processor bundling command must EXECUTE " - "'cp shared/*.py /asset-output/' so the single-sourced shared modules " - f"ship flat beside handler.py. Executed cp commands: {executed_cps}" + assert recipe.shared_all, ( + "terraform/build_packages.sh must copy_shared_all into " + "wo_email_processor so the single-sourced shared modules ship flat " + f"beside handler.py. Recipe: {recipe}" ) - assert _bundling_ships_all(command, siblings), ( - f"cdk/wo_stack.py bundling command does not ship all of {sorted(siblings)}: " - f"{command!r}" + assert _packaging_ships_all(recipe, siblings), ( + f"wo_email_processor packaging does not ship all of {sorted(siblings)}: " + f"{recipe}" ) def test_po_email_processor_dir_ships_no_unexpected_top_level_modules(): """Upper bound on the PO pipeline dir alone (NOT unioned with shared/). - The sibling-import tests above prove the glob ships every module the + The sibling-import tests above prove packaging ships every module the handler needs (shipped >= required). This proves the other direction for - the pipeline dir (shipped <= expected): because `cp po/email_processor/*.py` - copies every top-level .py in that dir -- and `Code.from_asset` stages - untracked files too (it does not honor .gitignore) -- a stray scratch or - secrets .py, OR a shadow copy of a moved shared module, would silently ship - into the zip on a local deploy. Policing this dir SEPARATELY from shared/ - (rather than as a union with it) is what makes a strayed-back ses_auth.py / - email_parsing.py / emf.py FAIL here instead of being masked by the same name - already being expected in shared/. + the pipeline dir (shipped <= expected): because copy_py_dir copies every + top-level .py in that dir, a stray scratch or secrets .py, OR a shadow + copy of a moved shared module, would silently ship into the zip. Policing + this dir SEPARATELY from shared/ (rather than as a union with it) is what + makes a strayed-back ses_auth.py / email_parsing.py / emf.py FAIL here + instead of being masked by the same name already being expected in shared/. """ top_level = {p.stem for p in PO_HANDLER.parent.glob("*.py")} assert top_level == set(PO_PIPELINE_MODULES), ( - "unexpected top-level .py set in lambdas/po/email_processor -- the " - "'cp po/email_processor/*.py' glob would ship exactly these into the " - f"Lambda zip. Found {sorted(top_level)}, expected " - f"{sorted(PO_PIPELINE_MODULES)}. A moved shared module " - f"({sorted(SHARED_MODULES)}) reappearing here would SHADOW the single " - "shared source in every handler-loaded test -- remove it. If a new " - "per-pipeline module is intended, add it to PO_PIPELINE_MODULES." + "unexpected top-level .py set in lambdas/po/email_processor -- " + "copy_py_dir would ship exactly these into the Lambda zip. Found " + f"{sorted(top_level)}, expected {sorted(PO_PIPELINE_MODULES)}. A moved " + f"shared module ({sorted(SHARED_MODULES)}) reappearing here would " + "SHADOW the single shared source in every handler-loaded test -- " + "remove it. If a new per-pipeline module is intended, add it to " + "PO_PIPELINE_MODULES." ) def test_wo_email_processor_dir_ships_no_unexpected_top_level_modules(): - """Upper bound on the WO pipeline dir alone (NOT unioned with shared/). - - The WO equivalent of the PO exact-set pin -- previously MISSING entirely, - so a stray .py (or a shadow copy of a moved shared module) in - lambdas/wo/email_processor was policed by nothing. Same rationale as the PO - pin: `cp wo/email_processor/*.py` ships every top-level .py in this dir, and - a strayed-back ses_auth/email_parsing/emf would shadow the shared source in - the WO handler-loaded tests. - """ + """Upper bound on the WO pipeline dir alone (NOT unioned with shared/).""" top_level = {p.stem for p in WO_HANDLER.parent.glob("*.py")} assert top_level == set(WO_PIPELINE_MODULES), ( - "unexpected top-level .py set in lambdas/wo/email_processor -- the " - "'cp wo/email_processor/*.py' glob would ship exactly these into the " - f"Lambda zip. Found {sorted(top_level)}, expected " - f"{sorted(WO_PIPELINE_MODULES)}. A moved shared module " - f"({sorted(SHARED_MODULES)}) reappearing here would SHADOW the single " - "shared source in every handler-loaded test -- remove it. If a new " - "per-pipeline module is intended, add it to WO_PIPELINE_MODULES." + "unexpected top-level .py set in lambdas/wo/email_processor -- " + "copy_py_dir would ship exactly these into the Lambda zip. Found " + f"{sorted(top_level)}, expected {sorted(WO_PIPELINE_MODULES)}. A moved " + f"shared module ({sorted(SHARED_MODULES)}) reappearing here would " + "SHADOW the single shared source in every handler-loaded test -- " + "remove it. If a new per-pipeline module is intended, add it to " + "WO_PIPELINE_MODULES." ) def test_shared_dir_ships_no_unexpected_top_level_modules(): """Upper bound on lambdas/shared/ alone (NOT unioned with a pipeline dir). - `cp shared/*.py` ships every top-level .py under lambdas/shared/ into BOTH + copy_shared_all ships every top-level .py under lambdas/shared/ into BOTH email-processor bundles, so a stray scratch/secrets .py here would leak into both production zips. Pinned to exactly the four single-sourced modules. """ top_level = {p.stem for p in SHARED_DIR.glob("*.py")} assert top_level == set(SHARED_MODULES), ( - "unexpected top-level .py set in lambdas/shared -- the 'cp shared/*.py' " - "glob would ship exactly these into BOTH email-processor Lambda zips. " - f"Found {sorted(top_level)}, expected {sorted(SHARED_MODULES)}. If a new " - "shared module is intended, add it to SHARED_MODULES; if it is a scratch " - "or secrets file, remove it before deploy." + "unexpected top-level .py set in lambdas/shared -- copy_shared_all " + "would ship exactly these into BOTH email-processor Lambda zips. " + f"Found {sorted(top_level)}, expected {sorted(SHARED_MODULES)}. If a " + "new shared module is intended, add it to SHARED_MODULES; if it is a " + "scratch or secrets file, remove it before deploy." ) def test_no_shared_module_shadow_in_pipeline_dirs(): - """The moved shared names must live ONLY under lambdas/shared/. - - Replaces the future-drift guard the retired byte-identity ses_auth fixture - used to provide. A stray reappearance of a moved shared module in either - email-processor pipeline dir would be resolved FIRST by every handler loader - -- tests/conftest.py load_handler checks `path.parent / f"{sibling}.py"` - before the _SHARED_DIR fallback, and - lambdas/wo/email_processor/tests/_wo_parser_support.py inserts the pipeline - dir ahead of shared/ on sys.path -- silently SHADOWING the single shared - source in every handler-loaded test, while test_ses_auth / test_parse_raw_email - keep exercising shared/. Divergence would go undetected. Police the pipeline - dirs and shared/ SEPARATELY so no union can mask the shadow. - """ + """The moved shared names must live ONLY under lambdas/shared/.""" for name in sorted(SHARED_MODULES): assert (SHARED_DIR / f"{name}.py").exists(), ( f"{name}.py must exist under lambdas/shared/ (single source of truth)" @@ -494,258 +366,138 @@ def test_no_shared_module_shadow_in_pipeline_dirs(): def test_detection_logic_catches_allowlist_missing_a_sibling(): - """Unit-level check on `_bundling_ships_all` itself. + """Unit-level check on `_packaging_ships_all` itself. - Simulates the historical regression shape directly: someone reverts the - PO glob back to an explicit filename allowlist that omits a required sibling. - Phase 5 note: the PR #2 near-miss module (derived_fields) is now a TRANSITIVE - import via enrichment.py, so it is no longer among handler.py's DIRECT - first-party imports; the representative near-miss here is enrichment (PO-only, - a direct handler import). `_bundling_ships_all` must detect the gap so that, - combined with the "cp ./*.py" pin above, - test_po_bundling_ships_all_first_party_siblings fails loudly on any such - revert rather than silently passing. + Simulates the historical regression shape: packaging copies only an + explicit filename set that omits a required sibling. Phase 5 note: the + PR #2 near-miss module (derived_fields) is now a TRANSITIVE import via + enrichment.py; the representative near-miss here is enrichment (PO-only, + a direct handler import). """ siblings = _first_party_sibling_imports(PO_HANDLER) assert "enrichment" in siblings # sanity: a PO-only direct flat-sibling import - reverted_allowlist_command = ( - "pip install --platform manylinux2014_aarch64 --only-binary=:all: " - "-r requirements.txt -t /asset-output && " - "cp handler.py ses_auth.py template_parser.py /asset-output/" + incomplete = PackageRecipe( + src_files=[ + "po/email_processor/handler.py", + "shared/ses_auth.py", + "po/email_processor/template_parser.py", + ] ) + assert not _packaging_ships_all(incomplete, siblings) - assert not _bundling_ships_all(reverted_allowlist_command, siblings) - - # Control: the same allowlist shape listing ALL required direct siblings - # (the Phase 3 shared ses_auth/email_parsing + the Phase 5 flat siblings - # prompts/telemetry/extraction/enrichment/persistence) is correctly - # recognized as complete under the accumulate model, proving the failure - # above is about the missing files and not a regex artifact. - complete_allowlist_command = ( - "pip install --platform manylinux2014_aarch64 --only-binary=:all: " - "-r requirements.txt -t /asset-output && " - "cp handler.py ses_auth.py template_parser.py email_parsing.py " - "prompts.py telemetry.py extraction.py enrichment.py persistence.py " - "/asset-output/" + # Control: explicit files covering all required direct siblings is complete. + complete = PackageRecipe( + src_files=[ + "po/email_processor/handler.py", + "shared/ses_auth.py", + "po/email_processor/template_parser.py", + "shared/email_parsing.py", + "po/email_processor/prompts.py", + "po/email_processor/telemetry.py", + "po/email_processor/extraction.py", + "po/email_processor/enrichment.py", + "po/email_processor/persistence.py", + ] ) - assert _bundling_ships_all(complete_allowlist_command, siblings) + assert _packaging_ships_all(complete, siblings) -def test_detection_logic_rejects_narrowed_scoped_glob(): - """A narrowed single-file cp (no `*`) must not satisfy the scoped-glob pin. - - Phase 2 widened the glob's source prefix to `po/email_processor/*.py` - (resp. `wo/email_processor/*.py`) against the ../lambdas asset root. - Guard against a narrowing regression -- e.g. a bad find/replace that - keeps the path prefix but drops the `*` and copies only handler.py -- - from silently passing as ships-all. `cp po/email_processor/handler.py` - has a path prefix but no glob star, so the scoped-glob regex must not - match it, and it also fails the explicit-allowlist fallback because it - omits ses_auth/template_parser/derived_fields. - """ +def test_detection_logic_rejects_narrowed_py_dir(): + """A recipe that only copies handler.py must not satisfy ships-all.""" siblings = _first_party_sibling_imports(PO_HANDLER) + narrowed = PackageRecipe(src_files=["po/email_processor/handler.py"]) + assert not _packaging_ships_all(narrowed, siblings) - narrowed_command = ( - "pip install --platform manylinux2014_aarch64 --only-binary=:all: " - "-r po/email_processor/requirements.txt -t /asset-output && " - "cp po/email_processor/handler.py /asset-output/" + +def test_detection_logic_rejects_commented_out_shared_copy(): + """A copy_shared_all surviving only in a `#` comment must not count as run.""" + script = ( + 'copy_py_dir "po/email_processor" "${BUILD}/po_email_processor"\n' + '# copy_shared_all "${BUILD}/po_email_processor"\n' ) - assert not _bundling_ships_all(narrowed_command, siblings) - - -def test_detection_logic_rejects_commented_out_scoped_glob(): - """A scoped glob surviving only in a `#` comment must not pass. - - Simulates a revert that narrows the executed `cp` to a single file but - leaves the old scoped-glob text trailing as a comment (e.g. a - half-finished revert). `_executed_cp_commands` strips `#` comments - before any regex sees the segment, so the glob text alone -- never - actually executed by bash -- cannot false-pass the pin. - """ - siblings = _first_party_sibling_imports(WO_HANDLER) - - commented_out_command = ( - "pip install --platform manylinux2014_aarch64 --only-binary=:all: " - "-r wo/email_processor/requirements.txt -t /asset-output && " - "cp wo/email_processor/handler.py /asset-output/ " - "# was: cp wo/email_processor/*.py /asset-output/" - ) - assert not _bundling_ships_all(commented_out_command, siblings) - - -def test_detection_logic_rejects_commented_out_shared_cp(): - """A `cp shared/*.py` surviving only in a `#` comment must not count as run. - - Simulates a half-finished revert that drops the executed shared cp but - leaves its text trailing as a comment. `_executed_cp_commands` strips `#` - comments before any regex sees the segment, so the shared-cp text alone -- - never executed by bash -- is not among the executed cp's. Combined with the - fixed ships-all (ses_auth/email_parsing/emf resolve ONLY under shared/), a - removed or commented shared cp fails both the SHARED_CP_RE pin and ships-all. - """ - commented_out_command = ( - "cp po/email_processor/*.py /asset-output/ # cp shared/*.py /asset-output/" - ) - executed = _executed_cp_commands(commented_out_command) - assert not any(re.search(SHARED_CP_RE, cp) for cp in executed) - # And ships-all is False: the shared siblings never got shipped. + recipes = _parse_build_packages(script) + recipe = recipes["po_email_processor"] + assert not recipe.shared_all po_siblings = _first_party_sibling_imports(PO_HANDLER) - assert not _bundling_ships_all(commented_out_command, po_siblings) + assert not _packaging_ships_all(recipe, po_siblings) -def test_detection_logic_rejects_wrong_pipeline_glob(): - """A glob pointing at the WRONG pipeline (or any other dir) must not pass. +def test_detection_logic_rejects_wrong_pipeline_dir(): + """A copy_py_dir pointing at the WRONG pipeline must not pass ships-all. - Phase 2 widened the bundling cwd to the shared ../lambdas asset root, so a - copy-paste slip that leaves po_stack copying `wo/email_processor/*.py` - would stage a bundle missing derived_fields.py (which lives only under - po/email_processor) -- a runtime ImportError. `_bundling_ships_all` - resolves the globbed directory against the lambdas root and confirms it - actually holds every required sibling, so a wrong-pipeline or - arbitrary-directory glob is rejected rather than short-circuiting to True - on the mere presence of a `*.py` token. This is the missing-sibling class - (PR #105 / PR #2) the AST test exists to catch at CI time. + A slip that leaves po_email_processor copying wo/email_processor would + stage a bundle missing enrichment.py (PO-only) -- a runtime ImportError. """ po_siblings = _first_party_sibling_imports(PO_HANDLER) - # enrichment is a direct PO handler import that lives ONLY under - # po/email_processor (WO has no enrichment stage) -- Phase 5's clean - # stand-in for derived_fields (now only a transitive import) as the - # sibling a wrong-pipeline `wo/email_processor/*.py` glob would miss. assert "enrichment" in po_siblings # lives only under po/email_processor - wrong_pipeline_command = ( - "pip install --platform manylinux2014_aarch64 --only-binary=:all: " - "-r po/email_processor/requirements.txt -t /asset-output && " - "cp wo/email_processor/*.py /asset-output/" - ) - assert not _bundling_ships_all(wrong_pipeline_command, po_siblings) + wrong = PackageRecipe(py_dirs=["wo/email_processor"]) + assert not _packaging_ships_all(wrong, po_siblings) - arbitrary_dir_command = ( - "pip install --platform manylinux2014_aarch64 --only-binary=:all: " - "-r po/email_processor/requirements.txt -t /asset-output && " - "cp venv/lib/*.py /asset-output/" - ) - assert not _bundling_ships_all(arbitrary_dir_command, po_siblings) - - # The per-stack shape-check pins reject the wrong prefix too: the PO pin - # matches its own scoped glob but not the WO one, and vice versa. - assert re.search(PO_SCOPED_GLOB_RE, "cp po/email_processor/*.py /asset-output/") - assert not re.search(PO_SCOPED_GLOB_RE, "cp wo/email_processor/*.py /asset-output/") - assert re.search(WO_SCOPED_GLOB_RE, "cp wo/email_processor/*.py /asset-output/") - assert not re.search(WO_SCOPED_GLOB_RE, "cp po/email_processor/*.py /asset-output/") + arbitrary = PackageRecipe(py_dirs=["venv/lib"]) + assert not _packaging_ships_all(arbitrary, po_siblings) def test_po_web_ui_bundle_stages_shared_auth_module(): - """The PO web_ui bundle must EXECUTE `cp shared/web_ui_auth.py`. + """The PO web_ui package must copy shared/web_ui_auth.py. Phase 3 removed the inline auth block from lambdas/po/web_ui/handler.py, which now does a module-top-level `from web_ui_auth import is_authenticated`; - web_ui_auth.py lives ONLY under lambdas/shared/. No test imports the web_ui - handler, and the email-processor AST pins deliberately exclude the web_ui - command -- so without this pin, dropping/commenting the web_ui cp would - ImportError the auth-gated Lambda at cold start with green CI. Checked - against the comment-stripped executed cp so the old text surviving only in a - comment cannot satisfy it. + web_ui_auth.py lives ONLY under lambdas/shared/. Dropping this copy would + ImportError the auth-gated Lambda at cold start with green CI. """ - command = _extract_web_ui_command(PO_STACK) - executed_cps = _executed_cp_commands(command) - assert any(re.search(WEB_UI_AUTH_CP_RE, cp) for cp in executed_cps), ( - "cdk/po_stack.py web_ui bundling command must EXECUTE " - "'cp shared/web_ui_auth.py /asset-output/' so the shared auth module " - "ships flat beside handler.py and `from web_ui_auth import " - f"is_authenticated` resolves at cold start. Executed cp commands: " - f"{executed_cps}" + recipes = _parse_build_packages() + recipe = recipes["po_web_ui"] + assert "po/web_ui" in recipe.py_dirs, ( + f"po_web_ui must copy_py_dir po/web_ui. Recipe: {recipe}" + ) + assert "shared/web_ui_auth.py" in recipe.src_files, ( + "terraform/build_packages.sh must copy_src_file shared/web_ui_auth.py " + "into po_web_ui so `from web_ui_auth import is_authenticated` resolves " + f"at cold start. Recipe: {recipe}" ) def test_wo_web_ui_bundle_stages_shared_auth_module(): - """The WO web_ui bundle must EXECUTE `cp shared/web_ui_auth.py`. - - WO equivalent of test_po_web_ui_bundle_stages_shared_auth_module -- same - ImportError-at-cold-start hazard for lambdas/wo/web_ui/handler.py. - """ - command = _extract_web_ui_command(WO_STACK) - executed_cps = _executed_cp_commands(command) - assert any(re.search(WEB_UI_AUTH_CP_RE, cp) for cp in executed_cps), ( - "cdk/wo_stack.py web_ui bundling command must EXECUTE " - "'cp shared/web_ui_auth.py /asset-output/' so the shared auth module " - "ships flat beside handler.py and `from web_ui_auth import " - f"is_authenticated` resolves at cold start. Executed cp commands: " - f"{executed_cps}" + """The WO web_ui package must copy shared/web_ui_auth.py.""" + recipes = _parse_build_packages() + recipe = recipes["wo_web_ui"] + assert "wo/web_ui" in recipe.py_dirs, ( + f"wo_web_ui must copy_py_dir wo/web_ui. Recipe: {recipe}" + ) + assert "shared/web_ui_auth.py" in recipe.src_files, ( + "terraform/build_packages.sh must copy_src_file shared/web_ui_auth.py " + "into wo_web_ui so `from web_ui_auth import is_authenticated` resolves " + f"at cold start. Recipe: {recipe}" ) def test_detection_logic_rejects_commented_out_web_ui_auth_cp(): - """A `cp shared/web_ui_auth.py` surviving only in a `#` comment must not pass. - - Mirror of test_detection_logic_rejects_commented_out_shared_cp for the - web_ui staging: a half-finished revert that drops the executed cp but leaves - its text trailing as a comment must NOT register as executed, since bash - would never run it. - """ - commented_out_command = ( - "cp -r po/web_ui/. /asset-output/ # cp shared/web_ui_auth.py /asset-output/" + """A web_ui_auth copy surviving only in a `#` comment must not pass.""" + script = ( + 'copy_py_dir "po/web_ui" "${BUILD}/po_web_ui"\n' + '# copy_src_file "shared/web_ui_auth.py" "${BUILD}/po_web_ui/web_ui_auth.py"\n' ) - executed = _executed_cp_commands(commented_out_command) - assert not any(re.search(WEB_UI_AUTH_CP_RE, cp) for cp in executed) - - -def _extract_api_command(stack_path: Path) -> str: - """Extract the procurement-api function's bash -c bundling command string. - - Mirrors _extract_bundling_command but selects the command whose text - mentions ``api/`` (its first cp is ``cp api/*.py``). procurement_api_stack - has exactly one bundled function today; the exactly-one demand makes any - future second bundle in that stack surface loudly instead of silently - checking the wrong command. - """ - tree = ast.parse(stack_path.read_text()) - commands: list[str] = [] - for node in ast.walk(tree): - if isinstance(node, ast.keyword) and node.arg == "command": - list_node = node.value - if isinstance(list_node, ast.List) and list_node.elts: - last = list_node.elts[-1] - if isinstance(last, ast.Constant) and isinstance(last.value, str): - commands.append(last.value) - api_cmds = [c for c in commands if "api/" in c] - if len(api_cmds) != 1: - raise AssertionError( - f"expected exactly one api bundling command=[...] list in " - f"{stack_path}, found {len(api_cmds)} (of {len(commands)} total " - "command lists) — update this test to select the intended bundling " - "command explicitly" - ) - return api_cmds[0] + recipes = _parse_build_packages(script) + recipe = recipes["po_web_ui"] + assert "shared/web_ui_auth.py" not in recipe.src_files def test_api_bundling_ships_all_first_party_siblings(): - """The procurement-api bundle must ship every sibling handler.py imports. - - Same PR #105 ImportError class as the email processors: the api handler - imports router/pagination/serialization/wo_repo/po_repo (next to it) and - web_ui_auth (lambdas/shared/); a narrowed glob or dropped shared cp would - cold-start ImportError with green CI. - """ + """The procurement-api package must ship every sibling handler.py imports.""" required = _first_party_sibling_imports(API_HANDLER) assert required, "expected api/handler.py to import first-party siblings" - command = _extract_api_command(API_STACK) - assert _bundling_ships_all(command, required), ( - f"cdk/procurement_api_stack.py bundling does not ship all first-party " - f"siblings {sorted(required)}. Executed cp commands: " - f"{_executed_cp_commands(command)}" + recipes = _parse_build_packages() + recipe = recipes["procurement_api"] + assert _packaging_ships_all(recipe, required), ( + f"procurement_api packaging does not ship all first-party siblings " + f"{sorted(required)}. Recipe: {recipe}" ) def test_api_dir_ships_no_unexpected_top_level_modules(): - """Exact-set pin on lambdas/api/*.py -- both bounds. - - `cp api/*.py` ships every top-level .py in the dir (untracked strays - included, since Code.from_asset does not honor .gitignore), so a stray - scratch/secrets module would silently ship into the production zip. Any - new module must be deliberately added to API_PIPELINE_MODULES. - """ + """Exact-set pin on lambdas/api/*.py -- both bounds.""" api_dir = REPO_ROOT / "lambdas" / "api" top_level = {p.stem for p in api_dir.glob("*.py")} assert top_level == set(API_PIPELINE_MODULES), ( @@ -756,139 +508,64 @@ def test_api_dir_ships_no_unexpected_top_level_modules(): def test_api_bundle_stages_shared_auth_module(): - """The api bundle must EXECUTE `cp shared/web_ui_auth.py` (docs-token gate).""" - command = _extract_api_command(API_STACK) - executed_cps = _executed_cp_commands(command) - assert any(re.search(WEB_UI_AUTH_CP_RE, cp) for cp in executed_cps), ( - "cdk/procurement_api_stack.py bundling command must EXECUTE " - "'cp shared/web_ui_auth.py /asset-output/' so the docs-token gate " - f"resolves at cold start. Executed cp commands: {executed_cps}" + """The api package must copy shared/web_ui_auth.py (docs-token gate).""" + recipes = _parse_build_packages() + recipe = recipes["procurement_api"] + assert "shared/web_ui_auth.py" in recipe.src_files, ( + "terraform/build_packages.sh must copy_src_file shared/web_ui_auth.py " + "into procurement_api so the docs-token gate resolves at cold start. " + f"Recipe: {recipe}" ) def test_api_bundle_stages_spec_and_docs_page(): - """The api bundle must EXECUTE cps for openapi.json and docs.html. - - The handler serves both from its package dir; the .py glob does not cover - them, so each needs its own executed cp or /docs 500s at runtime. - """ - command = _extract_api_command(API_STACK) - executed_cps = _executed_cp_commands(command) - for pattern in API_DATA_FILES_CP_RES: - assert any(re.search(pattern, cp) for cp in executed_cps), ( - f"cdk/procurement_api_stack.py bundling command must EXECUTE a cp " - f"matching {pattern!r}. Executed cp commands: {executed_cps}" + """The api package must copy openapi.json, docs.html, and Redoc assets.""" + recipes = _parse_build_packages() + recipe = recipes["procurement_api"] + for rel in API_DATA_FILES: + assert rel in recipe.src_files, ( + f"terraform/build_packages.sh must copy_src_file {rel!r} into " + f"procurement_api. Recipe: {recipe}" ) -def _extract_shoc_emitter_command(stack_path: Path) -> str: - """Extract the SHOC emitter's bash -c bundling command string. - - Mirrors _extract_api_command but selects the command whose text mentions - ``shoc_emitter`` (its cp is ``cp wo/shoc_emitter/*.py``). The plan's CI - note (docs/shoc-webhook-plan.md Phase 3) is explicit that an unrecognized - bundle ships unchecked -- the email/web_ui selectors deliberately do not - match the emitter command, so it needs its own selector. Demands exactly - one match so an ambiguity surfaces loudly instead of silently checking the - wrong command. - """ - tree = ast.parse(stack_path.read_text()) - commands: list[str] = [] - for node in ast.walk(tree): - if isinstance(node, ast.keyword) and node.arg == "command": - list_node = node.value - if isinstance(list_node, ast.List) and list_node.elts: - last = list_node.elts[-1] - if isinstance(last, ast.Constant) and isinstance(last.value, str): - commands.append(last.value) - shoc_cmds = [c for c in commands if "shoc_emitter" in c] - if len(shoc_cmds) != 1: - raise AssertionError( - f"expected exactly one shoc_emitter bundling command=[...] list in " - f"{stack_path}, found {len(shoc_cmds)} (of {len(commands)} total " - "command lists) — update this test to select the intended bundling " - "command explicitly" - ) - return shoc_cmds[0] - - -def _extract_shoc_rotator_command(stack_path: Path) -> str: - """Extract the SHOC HMAC rotator's bash -c bundling command string. - - Mirrors _extract_shoc_emitter_command with the ``shoc_hmac_rotator`` - selector (its cp is ``cp wo/shoc_hmac_rotator/*.py``); the emitter command - does not contain that substring, so exactly-one holds. - """ - tree = ast.parse(stack_path.read_text()) - commands: list[str] = [] - for node in ast.walk(tree): - if isinstance(node, ast.keyword) and node.arg == "command": - list_node = node.value - if isinstance(list_node, ast.List) and list_node.elts: - last = list_node.elts[-1] - if isinstance(last, ast.Constant) and isinstance(last.value, str): - commands.append(last.value) - rotator_cmds = [c for c in commands if "shoc_hmac_rotator" in c] - if len(rotator_cmds) != 1: - raise AssertionError( - f"expected exactly one shoc_hmac_rotator bundling command=[...] list " - f"in {stack_path}, found {len(rotator_cmds)} (of {len(commands)} " - "total command lists) — update this test to select the intended " - "bundling command explicitly" - ) - return rotator_cmds[0] - - def test_shoc_emitter_bundling_ships_all_first_party_siblings(): - """The SHOC emitter bundle must ship every sibling handler.py imports. - - Same PR #105 ImportError class as the other bundles: the emitter handler - imports envelope and delivery as bare-name flat siblings - (lambdas/wo/shoc_emitter/); a narrowed cp would cold-start ImportError on - the first stream invocation with green CI. - """ + """The SHOC emitter package must ship every sibling handler.py imports.""" required = _first_party_sibling_imports(SHOC_EMITTER_HANDLER) - # Sanity: the emitter handler is known to import envelope + delivery. If - # this ever collapses to empty, ships-all would vacuously pass. assert required == {"envelope", "delivery"}, ( f"expected the emitter handler's first-party siblings to be envelope + " f"delivery, found {sorted(required)} — update this pin deliberately" ) - command = _extract_shoc_emitter_command(WO_STACK) - assert _bundling_ships_all(command, required), ( - f"cdk/wo_stack.py shoc_emitter bundling does not ship all first-party " - f"siblings {sorted(required)}. Executed cp commands: " - f"{_executed_cp_commands(command)}" + recipes = _parse_build_packages() + recipe = recipes["wo_shoc_emitter"] + assert "wo/shoc_emitter" in recipe.py_dirs, ( + f"wo_shoc_emitter must copy_py_dir wo/shoc_emitter. Recipe: {recipe}" + ) + assert _packaging_ships_all(recipe, required), ( + f"wo_shoc_emitter packaging does not ship all first-party siblings " + f"{sorted(required)}. Recipe: {recipe}" ) def test_shoc_rotator_bundling_ships_handler(): - """The rotator bundle must ship handler.py (it has no first-party siblings). - - The rotator is stdlib + boto3-from-runtime only, so its required sibling - set is empty -- pin that fact (a future sibling import must extend this - test), then prove the executed glob actually ships handler.py itself. - """ + """The rotator package must ship handler.py (it has no first-party siblings).""" required = _first_party_sibling_imports(SHOC_ROTATOR_HANDLER) assert required == set(), ( f"the rotator handler grew first-party sibling imports " f"{sorted(required)} — extend this ships-all test to require them" ) - command = _extract_shoc_rotator_command(WO_STACK) - assert _bundling_ships_all(command, {"handler"}), ( - f"cdk/wo_stack.py shoc_hmac_rotator bundling does not ship handler.py. " - f"Executed cp commands: {_executed_cp_commands(command)}" + recipes = _parse_build_packages() + recipe = recipes["wo_shoc_hmac_rotator"] + assert "wo/shoc_hmac_rotator" in recipe.py_dirs, ( + f"wo_shoc_hmac_rotator must copy_py_dir wo/shoc_hmac_rotator. Recipe: {recipe}" + ) + assert _packaging_ships_all(recipe, {"handler"}), ( + f"wo_shoc_hmac_rotator packaging does not ship handler.py. Recipe: {recipe}" ) def test_shoc_emitter_dir_ships_no_unexpected_top_level_modules(): - """Exact-set pin on lambdas/wo/shoc_emitter/*.py -- both bounds. - - `cp wo/shoc_emitter/*.py` ships every top-level .py in the dir (untracked - strays included, since Code.from_asset does not honor .gitignore), so a - stray scratch/secrets module would silently ship into the production zip. - Any new module must be deliberately added to SHOC_EMITTER_MODULES. - """ + """Exact-set pin on lambdas/wo/shoc_emitter/*.py -- both bounds.""" top_level = {p.stem for p in SHOC_EMITTER_HANDLER.parent.glob("*.py")} assert top_level == set(SHOC_EMITTER_MODULES), ( f"lambdas/wo/shoc_emitter/ top-level modules {sorted(top_level)} != " @@ -898,11 +575,7 @@ def test_shoc_emitter_dir_ships_no_unexpected_top_level_modules(): def test_shoc_rotator_dir_ships_no_unexpected_top_level_modules(): - """Exact-set pin on lambdas/wo/shoc_hmac_rotator/*.py -- both bounds. - - Same rationale as the emitter pin: `cp wo/shoc_hmac_rotator/*.py` ships - every top-level .py in the dir, strays included. - """ + """Exact-set pin on lambdas/wo/shoc_hmac_rotator/*.py -- both bounds.""" top_level = {p.stem for p in SHOC_ROTATOR_HANDLER.parent.glob("*.py")} assert top_level == set(SHOC_ROTATOR_MODULES), ( f"lambdas/wo/shoc_hmac_rotator/ top-level modules {sorted(top_level)} " @@ -911,26 +584,16 @@ def test_shoc_rotator_dir_ships_no_unexpected_top_level_modules(): ) -def test_shoc_commands_do_not_collide_with_existing_selectors(): - """The SHOC bundling commands must not trip the other exactly-one selectors. - - _extract_bundling_command selects on the substring ``email_processor`` and - _extract_web_ui_command on ``web_ui``, each demanding exactly one match in - wo_stack.py. If either SHOC command ever grew one of those substrings - (e.g. a copy-pasted comment folded into the command string), those - selectors would find two commands and every email/web_ui pin would error. - Assert the invariant here so the failure names the actual cause. - """ - for command in ( - _extract_shoc_emitter_command(WO_STACK), - _extract_shoc_rotator_command(WO_STACK), - ): - assert "email_processor" not in command, ( - f"SHOC bundling command contains 'email_processor', which would " - f"break _extract_bundling_command's exactly-one selection: " - f"{command!r}" +def test_email_processor_packages_do_not_collide_with_web_ui_dirs(): + """Email-processor recipes must not copy web_ui dirs (and vice versa).""" + recipes = _parse_build_packages() + for name in ("po_email_processor", "wo_email_processor"): + recipe = recipes[name] + assert not any("web_ui" in d for d in recipe.py_dirs), ( + f"{name} packaging unexpectedly copies a web_ui dir: {recipe}" ) - assert "web_ui" not in command, ( - f"SHOC bundling command contains 'web_ui', which would break " - f"_extract_web_ui_command's exactly-one selection: {command!r}" + for name in ("po_web_ui", "wo_web_ui"): + recipe = recipes[name] + assert not any("email_processor" in d for d in recipe.py_dirs), ( + f"{name} packaging unexpectedly copies an email_processor dir: {recipe}" ) diff --git a/tests/test_cross_account_principal_pin.py b/tests/test_cross_account_principal_pin.py index 9047a00..1b80b1c 100644 --- a/tests/test_cross_account_principal_pin.py +++ b/tests/test_cross_account_principal_pin.py @@ -1,12 +1,15 @@ -"""Pin the cross-account principal surface of the CDK app. +"""Pin the cross-account principal surface of the Terraform app. The SHOC integration deliberately trusts EXACTLY ONE foreign principal: -``arn:aws:iam::396287094661:role/shoc-backend-dev`` (read API resource -policy in procurement_api_stack.py, HMAC secret + KMS grants in -wo_stack.py). Future shoc-backend-staging/-prod roles are each a -deliberate, individually-reviewed policy addition — so any new foreign -account id or role ARN appearing in cdk/ must consciously update this -pin (and go through the mandatory GPT-4.1 cross-family IAM review). +``arn:aws:iam::396287094661:role/shoc-backend-dev`` (API resource policy in +api.tf, HMAC secret + KMS grants in wo_shoc.tf). The exact ARN is pinned as +the ``shoc_consumer_role_arn`` variable default and in +``terraform.tfvars.example``; grant sites consume ``local.shoc_consumer_role_arn`` +only. Future shoc-backend-staging/-prod roles are each a deliberate, +individually-reviewed policy addition — so any new foreign account id or role +ARN appearing under terraform/, or any change to the default/example pin, must +consciously update this test (and go through the mandatory GPT-4.1 cross-family +IAM review). Raised as a QUESTION in the 2026-07-24 cross-family review of the webhook emitter policy surface: "how is the exact-one-principal @@ -17,26 +20,77 @@ import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] -CDK_DIR = REPO_ROOT / "cdk" +TF_DIR = REPO_ROOT / "terraform" +VARIABLES_TF = TF_DIR / "variables.tf" +TFVARS_EXAMPLE = TF_DIR / "terraform.tfvars.example" -# The one foreign principal the app may reference, and the only files -# allowed to reference it. +# The one foreign principal the app may trust. ALLOWED_FOREIGN_PRINCIPAL = "arn:aws:iam::396287094661:role/shoc-backend-dev" -ALLOWED_FILES = {"procurement_api_stack.py", "wo_stack.py"} + +# Files allowed to embed that ARN as a literal (default + example). Grant +# sites must use local.shoc_consumer_role_arn instead. +ALLOWED_LITERAL_FILES = {"variables.tf", "terraform.tfvars.example"} + +# Grant sites that must reference the local; dropping one half fails the pin. +GRANT_FILES = ("api.tf", "wo_shoc.tf") # Accounts that are not "foreign": seahaven-prod (the deploy target). HOME_ACCOUNTS = {"011934824531"} _IAM_ARN_RE = re.compile(r"arn:aws:iam::(\d{12}):\S*?(?=[\"'\s])") +_VAR_DEFAULT_RE = re.compile( + r'variable\s+"shoc_consumer_role_arn"\s*\{(.*?)^\}', + re.DOTALL | re.MULTILINE, +) +_DEFAULT_VALUE_RE = re.compile(r'default\s*=\s*"([^"]+)"') +_TFVARS_VALUE_RE = re.compile( + r'^shoc_consumer_role_arn\s*=\s*"([^"]+)"\s*$', + re.MULTILINE, +) +_LOCAL_REF = "local.shoc_consumer_role_arn" -def _cdk_sources(): - return sorted(CDK_DIR.glob("*.py")) +def _terraform_sources(): + paths = sorted(TF_DIR.glob("*.tf")) + if TFVARS_EXAMPLE.exists(): + paths.append(TFVARS_EXAMPLE) + return paths -def test_only_the_pinned_foreign_principal_appears_in_cdk_sources(): +def test_variable_default_is_the_pinned_foreign_principal(): + """The Terraform default must equal the exact trusted ARN. + + Grant sites consume the variable via local.shoc_consumer_role_arn. Pinning + the default restores the exact-principal invariant the former CDK scan + enforced: widening trust requires editing this default (and this test). + """ + block = _VAR_DEFAULT_RE.search(VARIABLES_TF.read_text()) + assert block, "variables.tf must declare variable shoc_consumer_role_arn" + default = _DEFAULT_VALUE_RE.search(block.group(1)) + assert default, ( + "variable shoc_consumer_role_arn must set default = " + f'"{ALLOWED_FOREIGN_PRINCIPAL}" so the exact principal is pinned in-repo' + ) + assert default.group(1) == ALLOWED_FOREIGN_PRINCIPAL, ( + f"shoc_consumer_role_arn default is {default.group(1)!r}, expected " + f"{ALLOWED_FOREIGN_PRINCIPAL!r}" + ) + + +def test_tfvars_example_is_the_pinned_foreign_principal(): + match = _TFVARS_VALUE_RE.search(TFVARS_EXAMPLE.read_text()) + assert match, ( + "terraform.tfvars.example must set shoc_consumer_role_arn to the pinned ARN" + ) + assert match.group(1) == ALLOWED_FOREIGN_PRINCIPAL, ( + f"terraform.tfvars.example shoc_consumer_role_arn is {match.group(1)!r}, " + f"expected {ALLOWED_FOREIGN_PRINCIPAL!r}" + ) + + +def test_only_the_pinned_foreign_principal_appears_in_terraform_sources(): findings = [] - for path in _cdk_sources(): + for path in _terraform_sources(): for match in _IAM_ARN_RE.finditer(path.read_text()): account = match.group(1) if account in HOME_ACCOUNTS: @@ -46,12 +100,37 @@ def test_only_the_pinned_foreign_principal_appears_in_cdk_sources(): unexpected = [ (name, arn) for name, arn in findings - if arn != ALLOWED_FOREIGN_PRINCIPAL or name not in ALLOWED_FILES + if arn != ALLOWED_FOREIGN_PRINCIPAL or name not in ALLOWED_LITERAL_FILES ] assert not unexpected, ( - "Unexpected foreign IAM principal(s) in cdk/ — every cross-account " - f"trust addition must update this pin deliberately: {unexpected}" + "Unexpected foreign IAM principal(s) under terraform/ — every " + "cross-account trust addition must update this pin deliberately: " + f"{unexpected}" ) - # Both grant sites must still reference the pinned role (deleting one - # half of the secret/KMS grant pair fails silently at the receiver). - assert {name for name, _ in findings} == ALLOWED_FILES + # Both default/example sites must still name the pinned role as a literal. + assert {name for name, _ in findings} == ALLOWED_LITERAL_FILES + + +def test_grant_files_use_local_shoc_consumer_role_arn_only(): + """Grant sites must consume the local — never a hardcoded foreign ARN. + + api.tf (API resource policy) and wo_shoc.tf (KMS + secret policy) are the + two halves of the trust surface. Each must reference + local.shoc_consumer_role_arn so dropping one half fails CI, and neither + may embed a raw foreign IAM ARN (that would bypass the variable default pin). + """ + for filename in GRANT_FILES: + text = (TF_DIR / filename).read_text() + assert _LOCAL_REF in text, ( + f"{filename} must reference {_LOCAL_REF} so the SHOC cross-account " + "grant surface cannot silently drop one half of the trust pair" + ) + foreign = [ + m.group(0) + for m in _IAM_ARN_RE.finditer(text) + if m.group(1) not in HOME_ACCOUNTS + ] + assert not foreign, ( + f"{filename} must not hardcode foreign IAM ARNs " + f"(use {_LOCAL_REF}): {foreign}" + )