From 0ba36001776156a579d9a9285dc8742cae480956 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 6 Aug 2026 17:07:40 -0400 Subject: [PATCH] feat(infra): add HCP Terraform for procurement-ingest import-in-place --- .github/workflows/ci-terraform.yaml | 32 + README.md | 5 +- .../WorkorderIngestStack-resources.json | 374 ++++++++++ docs/plat-86/import-map.md | 52 ++ docs/plat-86/po-ingest-resources.json | 278 ++++++++ docs/plat-86/procurement-api-resources.json | 338 +++++++++ docs/plat-86/raw-inventory.tsv | 164 +++++ terraform/.gitignore | 11 + terraform/.terraform.lock.hcl | 74 ++ terraform/api.tf | 328 +++++++++ terraform/api_alarms.tf | 73 ++ terraform/artifacts.tf | 55 ++ terraform/build_packages.sh | 152 ++++ terraform/build_packages_external.sh | 25 + terraform/iam.tf | 658 ++++++++++++++++++ terraform/imports.tf | 595 ++++++++++++++++ terraform/locals.tf | 56 ++ terraform/outputs.tf | 34 + terraform/po_alarms.tf | 270 +++++++ terraform/po_ddb.tf | 134 ++++ terraform/po_lambda.tf | 134 ++++ terraform/po_s3.tf | 96 +++ terraform/po_ses.tf | 14 + terraform/providers.tf | 11 + terraform/terraform.tfvars.example | 8 + terraform/variables.tf | 25 + terraform/versions.tf | 26 + terraform/wo_alarms.tf | 344 +++++++++ terraform/wo_ddb.tf | 122 ++++ terraform/wo_lambda.tf | 84 +++ terraform/wo_s3.tf | 96 +++ terraform/wo_ses.tf | 14 + terraform/wo_shoc.tf | 345 +++++++++ 33 files changed, 5026 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci-terraform.yaml create mode 100644 docs/plat-86/WorkorderIngestStack-resources.json create mode 100644 docs/plat-86/import-map.md create mode 100644 docs/plat-86/po-ingest-resources.json create mode 100644 docs/plat-86/procurement-api-resources.json create mode 100644 docs/plat-86/raw-inventory.tsv create mode 100644 terraform/.gitignore create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/api.tf create mode 100644 terraform/api_alarms.tf create mode 100644 terraform/artifacts.tf create mode 100755 terraform/build_packages.sh create mode 100755 terraform/build_packages_external.sh create mode 100644 terraform/iam.tf create mode 100644 terraform/imports.tf create mode 100644 terraform/locals.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/po_alarms.tf create mode 100644 terraform/po_ddb.tf create mode 100644 terraform/po_lambda.tf create mode 100644 terraform/po_s3.tf create mode 100644 terraform/po_ses.tf create mode 100644 terraform/providers.tf create mode 100644 terraform/terraform.tfvars.example create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf create mode 100644 terraform/wo_alarms.tf create mode 100644 terraform/wo_ddb.tf create mode 100644 terraform/wo_lambda.tf create mode 100644 terraform/wo_s3.tf create mode 100644 terraform/wo_ses.tf create mode 100644 terraform/wo_shoc.tf diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml new file mode 100644 index 0000000..41091ae --- /dev/null +++ b/.github/workflows/ci-terraform.yaml @@ -0,0 +1,32 @@ +name: Terraform CI +on: + pull_request: + branches: [main] + paths: + - "terraform/**" + - ".github/workflows/ci-terraform.yaml" + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" + + - name: Terraform fmt + run: terraform fmt -check -recursive + + - name: Terraform init + run: terraform init -backend=false + + - name: Terraform validate + run: terraform validate diff --git a/README.md b/README.md index 1b278cd..f5c3457 100644 --- a/README.md +++ b/README.md @@ -335,9 +335,12 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr ## CI/CD +> **PLAT-86 (in progress):** CDK CD is soft-frozen (`deploy.yaml` disabled on GitHub) while ownership moves to HCP Terraform workspace `procurement-ingest-prod`. Terraform lives under `terraform/`; import map under `docs/plat-86/`. HCP becomes the sole mutate path after import + green Manual apply; then `deploy.yaml` / `cd-cdk.yaml` are retired. Post-apply smoke still targets `po-email-processor`, `workorder-email-processor`, and `procurement-api` via `scripts/post-deploy-smoke.sh`. + 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`. `cdk synth`'s Docker-bundled asset build for `po-email-processor` and `workorder-email-processor` mounts the widened `../lambdas` asset root (Phase 2, see [Deploy-Pipeline Guards](#deploy-pipeline-guards-phase-0)) as build context, not just each function's own subdirectory — the `exclude` list on both `from_asset` calls strips local-only `__pycache__`/`package/` (and `tests/`) cruft from that wider mount's source fingerprint, so CI's asset hash matches a clean local checkout, and each function's scoped `cp` glob copies only its own pipeline's `*.py` into the zip. (The exclude does not, and under `SOURCE` hashing cannot, keep the *other* pipeline's tracked source out of the fingerprint (see the PO bundling note above on `SOURCE` hashing) — but that source is identical in CI and local, so it does not cause hash divergence.) -- **CD** (`deploy.yaml`, push to `main`): CDK deploy via `cd-cdk.yaml` (OIDC auth), followed by the synchronous `post-deploy-script: scripts/post-deploy-smoke.sh` healthcheck gate (see [Deploy-Pipeline Guards](#deploy-pipeline-guards-phase-0)) — `cd-cdk.yaml`'s `stack-name` input only accepts one stack, so the smoke script itself enumerates `po-email-processor`, `workorder-email-processor`, and `procurement-api` +- **Terraform CI** (`ci-terraform.yaml`, PR to `main` when `terraform/**` changes): `fmt -check`, `init -backend=false`, `validate`. +- **CD** (`deploy.yaml`, push to `main`): CDK deploy via `cd-cdk.yaml` (OIDC auth), followed by the synchronous `post-deploy-script: scripts/post-deploy-smoke.sh` healthcheck gate (see [Deploy-Pipeline Guards](#deploy-pipeline-guards-phase-0)) — `cd-cdk.yaml`'s `stack-name` input only accepts one stack, so the smoke script itself enumerates `po-email-processor`, `workorder-email-processor`, and `procurement-api`. **Soft-frozen for PLAT-86** (workflow disabled until HCP cutover seals). - Plus dependency review and PR labeler workflows on every PR Branch protection on `main` — all changes through PR. diff --git a/docs/plat-86/WorkorderIngestStack-resources.json b/docs/plat-86/WorkorderIngestStack-resources.json new file mode 100644 index 0000000..c9bfacf --- /dev/null +++ b/docs/plat-86/WorkorderIngestStack-resources.json @@ -0,0 +1,374 @@ +[ + { + "LogicalId": "BucketNotificationsHandler050a0587b7544547bf325f094a3db8347ECC3691", + "Type": "AWS::Lambda::Function", + "PhysicalId": "WorkorderIngestStack-BucketNotificationsHandler050-fBtoo7x6Azy3", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleB6FB88EC", + "Type": "AWS::IAM::Role", + "PhysicalId": "WorkorderIngestStack-BucketNotificationsHandler050a-7KxpBKmlpFSf", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "CDKMetadata", + "Type": "AWS::CDK::Metadata", + "PhysicalId": "a85b0320-86dc-11f1-b0cf-0eccc7521e19", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "CommentsTableBBDBF0A8", + "Type": "AWS::DynamoDB::Table", + "PhysicalId": "WorkOrderComments", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "EmailBucket843A740F", + "Type": "AWS::S3::Bucket", + "PhysicalId": "workorder-ingest-emails-011934824531", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailBucketAllowBucketNotificationsToworkorderingestEmailProcessor5B0B85DABA138779", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "WorkorderIngestStack-EmailBucketAllowBucketNotificationsToworkorderingestEmailProcessor-VuairMrX6Qir", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailBucketNotificationsDDD5BEEA", + "Type": "Custom::S3BucketNotifications", + "PhysicalId": "EmailBucketNotificationsDDD5BEEA", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailBucketNotificationsHandlerPolicy7D16942E", + "Type": "AWS::IAM::Policy", + "PhysicalId": "Worko-Email-YLl5HvyOvIg2", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailBucketPolicyBBC68546", + "Type": "AWS::S3::BucketPolicy", + "PhysicalId": "workorder-ingest-emails-011934824531", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessor218EC076", + "Type": "AWS::Lambda::Function", + "PhysicalId": "workorder-email-processor", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorAiFallbackRejectedAlarm71D1F2CE", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-email-processor-ai-fallback-rejected", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorDlqA753DED5", + "Type": "AWS::SQS::Queue", + "PhysicalId": "https://sqs.us-east-1.amazonaws.com/011934824531/WorkorderIngestStack-EmailProcessorDlqA753DED5-qCTHrsoEucas", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorDlqMessagesAlarmED574757", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-email-processor-dlq-messages", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorDlqPolicyB24007F0", + "Type": "AWS::SQS::QueuePolicy", + "PhysicalId": "WorkorderIngestStack-EmailProcessorDlqPolicyB24007F0-15VK27UU5Q19", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorDurationAlarmF7530BED", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-email-processor-duration", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorErrorsAlarmC1BF0D18", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-email-processor-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorLogGroup8A79A2C8", + "Type": "AWS::Logs::LogGroup", + "PhysicalId": "/aws/lambda/workorder-email-processor", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorSenderAuthRejectedAlarm3D9534B7", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-email-processor-sender-auth-rejected", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorSenderAuthRejectedFilterC0DAEDFE", + "Type": "AWS::Logs::MetricFilter", + "PhysicalId": "EmailProcessorSenderAuthRejectedFilterC0DAEDFE-Wkbow1VNDeDM", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorServiceRole637381CE", + "Type": "AWS::IAM::Role", + "PhysicalId": "WorkorderIngestStack-EmailProcessorServiceRole63738-5l9OZXQLVvJn", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorServiceRoleDefaultPolicy0B15DAB8", + "Type": "AWS::IAM::Policy", + "PhysicalId": "Worko-Email-8PjqeVLLDLRv", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorTemplateFallbackRateAlarmF9B0866A", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-email-processor-template-fallback-rate", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorThrottlesAlarmE102F9E9", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-email-processor-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ExistingRuleSetWorkorderEmailRuleEA29F845", + "Type": "AWS::SES::ReceiptRule", + "PhysicalId": "ExistingRuleSetWorkorderEmailRuleEA29F845-PKtaDBvIg61a", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterCD1F4C3E", + "Type": "AWS::Lambda::Function", + "PhysicalId": "workorder-shoc-emitter", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterDurationAlarm3F755835", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-shoc-emitter-duration", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterDynamoDBEventSourceworkorderingestCommentsTableFD97F35C58A19C1A", + "Type": "AWS::Lambda::EventSourceMapping", + "PhysicalId": "da37d4a0-3086-4929-9ace-6eca5c20cd07", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterDynamoDBEventSourceworkorderingestWorkOrdersTable004CD4AC38D8BED2", + "Type": "AWS::Lambda::EventSourceMapping", + "PhysicalId": "cc149f4d-5375-4820-8fb8-c514accd3a85", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterErrorsAlarmCE2B702A", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-shoc-emitter-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterFailuresMessagesAlarmEC9798F9", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-shoc-emitter-failures-messages", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterFailuresQueue11D7C8E0", + "Type": "AWS::SQS::Queue", + "PhysicalId": "https://sqs.us-east-1.amazonaws.com/011934824531/workorder-shoc-emitter-failures", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterFailuresQueuePolicy0D60571C", + "Type": "AWS::SQS::QueuePolicy", + "PhysicalId": "WorkorderIngestStack-ShocEmitterFailuresQueuePolicy0D60571C-2AMBNJh0la3L", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterIteratorAgeAlarm90B55822", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-shoc-emitter-iterator-age", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterLogGroup80BDE724", + "Type": "AWS::Logs::LogGroup", + "PhysicalId": "/aws/lambda/workorder-shoc-emitter", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterRejectedMessagesAlarmAE9AFCDD", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-shoc-emitter-rejected-messages", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterRejectedQueueD754F41E", + "Type": "AWS::SQS::Queue", + "PhysicalId": "https://sqs.us-east-1.amazonaws.com/011934824531/workorder-shoc-emitter-rejected", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterRejectedQueuePolicy4B088C0D", + "Type": "AWS::SQS::QueuePolicy", + "PhysicalId": "WorkorderIngestStack-ShocEmitterRejectedQueuePolicy4B088C0D-vzXOuhHaGfNy", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterServiceRoleA0CA0C74", + "Type": "AWS::IAM::Role", + "PhysicalId": "WorkorderIngestStack-ShocEmitterServiceRoleA0CA0C74-LlFfZ6F9f6sr", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterServiceRoleDefaultPolicy5DE45717", + "Type": "AWS::IAM::Policy", + "PhysicalId": "Worko-ShocE-0txSITC4MbcW", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocEmitterThrottlesAlarm5503E07A", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-shoc-emitter-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocHmacRotatorD4073E6B", + "Type": "AWS::Lambda::Function", + "PhysicalId": "workorder-shoc-hmac-rotator", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "ShocHmacRotatorDurationAlarm0725D01A", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-shoc-hmac-rotator-duration", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocHmacRotatorErrorsAlarm55E7BCC3", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-shoc-hmac-rotator-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocHmacRotatorInvokeN0a2GKfZP0JmDqDEVhhu6A0TUv3NyNbk4YMFKNc3085C896", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "WorkorderIngestStack-ShocHmacRotatorInvokeN0a2GKfZP0JmDqDEVhhu6A0TUv3NyNbk4YMFKNc3085C8-kJaePEGWmM3r", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocHmacRotatorLogGroup1D109CC5", + "Type": "AWS::Logs::LogGroup", + "PhysicalId": "/aws/lambda/workorder-shoc-hmac-rotator", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocHmacRotatorServiceRole7F2B6917", + "Type": "AWS::IAM::Role", + "PhysicalId": "WorkorderIngestStack-ShocHmacRotatorServiceRole7F2B-uKqx4ccGQTk5", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocHmacRotatorServiceRoleDefaultPolicy492EC50B", + "Type": "AWS::IAM::Policy", + "PhysicalId": "Worko-ShocH-B5ATRJ8co1Hc", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocHmacRotatorThrottlesAlarm9963F2A3", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "workorder-shoc-hmac-rotator-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocWebhookHmacKey87DD3EC7", + "Type": "AWS::KMS::Key", + "PhysicalId": "d10fd1f0-a61a-4405-8568-85e9fd11ba18", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocWebhookHmacKeyAliasC83A07B2", + "Type": "AWS::KMS::Alias", + "PhysicalId": "alias/workorder-ingest-shoc-webhook-kms", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocWebhookHmacSecretA6CF6356", + "Type": "AWS::SecretsManager::Secret", + "PhysicalId": "arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocWebhookHmacSecretPolicy2B0A7DAC", + "Type": "AWS::SecretsManager::ResourcePolicy", + "PhysicalId": "arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ShocWebhookHmacSecretRotationA6130D74", + "Type": "AWS::SecretsManager::RotationSchedule", + "PhysicalId": "arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WebUI258D87A5", + "Type": "AWS::Lambda::Function", + "PhysicalId": "workorder-web-ui", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "WebUILogGroup8F43ABDC", + "Type": "AWS::Logs::LogGroup", + "PhysicalId": "/aws/lambda/workorder-web-ui", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WebUIServiceRoleB1B395BE", + "Type": "AWS::IAM::Role", + "PhysicalId": "WorkorderIngestStack-WebUIServiceRoleB1B395BE-0kRzDlAiLZVn", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WebUIServiceRoleDefaultPolicy8B510141", + "Type": "AWS::IAM::Policy", + "PhysicalId": "Worko-WebUI-TVYFGuD5kBVZ", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WorkOrderCommentsSystemErrorsAlarm4CD72F33", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "WorkOrderComments-system-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WorkOrderCommentsThrottlesAlarmA10AEFD9", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "WorkOrderComments-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WorkOrdersTable515B4C61", + "Type": "AWS::DynamoDB::Table", + "PhysicalId": "WorkOrders", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "WorkOrdersTableSystemErrorsAlarm117925DB", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "WorkOrders-system-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WorkOrdersTableThrottlesAlarmDACF2500", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "WorkOrders-throttles", + "Status": "CREATE_COMPLETE" + } +] diff --git a/docs/plat-86/import-map.md b/docs/plat-86/import-map.md new file mode 100644 index 0000000..0b3e998 --- /dev/null +++ b/docs/plat-86/import-map.md @@ -0,0 +1,52 @@ +# PLAT-86 import map (seahaven-prod `011934824531` / us-east-1) + +Frozen from live `DescribeStackResources` on 2026-08-06. CFN resource total: **164** (46 + 62 + 56). Estimated Terraform resources after expansion: ~220–320 — under HCP free-tier **500**. + +Workspace: one `procurement-ingest-prod` covering all three former stacks. + +## Out-of-band (data source / do not recreate) + +| Dependency | Value | +|---|---| +| SES ruleset | `INBOUND_MAIL` | +| SNS | `arn:aws:sns:us-east-1:011934824531:site-alerts` | +| SSM CMK | `/seahaven/dynamodb/cmk-arn` → `arn:aws:kms:us-east-1:011934824531:key/be5fa4cb-c546-40fe-a13d-c7bec79f5d12` | +| SSM ACM | `/procurement-api/custom-domain/certificate-arn` → `arn:aws:acm:us-east-1:011934824531:certificate/9eac4c03-6850-49f1-baa1-6c4e7fffd1c7` | +| Secret (imported shell) | `arn:aws:secretsmanager:us-east-1:011934824531:secret:procurement-ingest/web-ui-auth-token-ApAMmr` | +| Mgmt Route53 | `procurement-api.seahaven.com` alias — stays OOB | + +## CFN disposal disposition + +| Class | Disposition on CFN stack delete | +|---|---| +| DynamoDB tables, email S3 buckets, Lambda log groups | **RETAIN** (must already be TF-owned; never delete) | +| Named SQS (`workorder-shoc-emitter-*`), SHOC secret/KMS, API GW, Lambdas, alarms, SES receipt rules | TF-owned; remove from CFN via retain-on-delete or deletion_policy before stack delete | +| CDK custom resources (`Custom::S3BucketNotifications`, `BucketNotificationsHandler` Lambda/role) | **Destroy with CFN** — replaced by native `aws_s3_bucket_notification` | +| CDK Metadata | Destroy with CFN | +| CDK-generated IAM roles at path `/` | After Lambda repoint to `/tf-managed/`, delete with CFN or sweep | + +## Key physical IDs to preserve + +- Lambdas: `po-email-processor`, `po-web-ui`, `po-ingest-site-extractor`, `workorder-email-processor`, `workorder-web-ui`, `workorder-shoc-emitter`, `workorder-shoc-hmac-rotator`, `procurement-api` +- Tables: `purchase-orders`, `verified-sites`, `pending-site-review`, `WorkOrders`, `WorkOrderComments` (PascalCase kept for import) +- Buckets: `po-ingest-emails-011934824531`, `workorder-ingest-emails-011934824531` +- Queues: `workorder-shoc-emitter-failures`, `workorder-shoc-emitter-rejected`, plus CDK-named DLQs +- Domain: `procurement-api.seahaven.com` (mgmt Route53 OOB) +- API REST id: `mvul1efda2` +- SHOC KMS: alias `workorder-ingest-shoc-webhook-kms` (key id in live inventory JSON only) +- Secret: `workorder-ingest/shoc-webhook-hmac` + +## Cross-account pins (must stay byte-stable) + +- API resource policy principal: `arn:aws:iam::396287094661:role/shoc-backend-dev` +- SHOC KMS decrypt: exact ARN + `kms:ViaService` for Secrets Manager +- Webhook URL: `https://api.dev.seahaven.com/api/webhooks/work-orders` + +## Raw dumps + +- [`po-ingest-resources.json`](po-ingest-resources.json) +- [`WorkorderIngestStack-resources.json`](WorkorderIngestStack-resources.json) +- [`procurement-api-resources.json`](procurement-api-resources.json) +- [`raw-inventory.tsv`](raw-inventory.tsv) + +Import blocks: [`terraform/imports.tf`](../../terraform/imports.tf) diff --git a/docs/plat-86/po-ingest-resources.json b/docs/plat-86/po-ingest-resources.json new file mode 100644 index 0000000..12ef0fb --- /dev/null +++ b/docs/plat-86/po-ingest-resources.json @@ -0,0 +1,278 @@ +[ + { + "LogicalId": "BucketNotificationsHandler050a0587b7544547bf325f094a3db8347ECC3691", + "Type": "AWS::Lambda::Function", + "PhysicalId": "po-ingest-BucketNotificationsHandler050a0587b75445-eYKw7CRdcWxS", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleB6FB88EC", + "Type": "AWS::IAM::Role", + "PhysicalId": "po-ingest-BucketNotificationsHandler050a0587b754454-kAyd3v80XLIz", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "CDKMetadata", + "Type": "AWS::CDK::Metadata", + "PhysicalId": "e287be30-86dc-11f1-a551-0affcf7dfc53", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "EmailBucket843A740F", + "Type": "AWS::S3::Bucket", + "PhysicalId": "po-ingest-emails-011934824531", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailBucketAllowBucketNotificationsTopoingestEmailProcessor21BB6E7689B27E99", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "po-ingest-EmailBucketAllowBucketNotificationsTopoingestEmailProcessor21BB6E7689B27E99-2uvEuiUCEDgr", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailBucketNotificationsDDD5BEEA", + "Type": "Custom::S3BucketNotifications", + "PhysicalId": "EmailBucketNotificationsDDD5BEEA", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailBucketNotificationsHandlerPolicy7D16942E", + "Type": "AWS::IAM::Policy", + "PhysicalId": "po-in-Email-eep3xPAZrcL7", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailBucketPolicyBBC68546", + "Type": "AWS::S3::BucketPolicy", + "PhysicalId": "po-ingest-emails-011934824531", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessor218EC076", + "Type": "AWS::Lambda::Function", + "PhysicalId": "po-email-processor", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorAiFallbackRejectedAlarm71D1F2CE", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-email-processor-ai-fallback-rejected", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorDlqA753DED5", + "Type": "AWS::SQS::Queue", + "PhysicalId": "https://sqs.us-east-1.amazonaws.com/011934824531/po-ingest-EmailProcessorDlqA753DED5-Mn33HvhsDPEu", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorDlqMessagesAlarmED574757", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-email-processor-dlq-messages", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorDlqPolicyB24007F0", + "Type": "AWS::SQS::QueuePolicy", + "PhysicalId": "po-ingest-EmailProcessorDlqPolicyB24007F0-ZBgpMipDtvH3", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorDurationAlarmF7530BED", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-email-processor-duration", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorErrorsAlarmC1BF0D18", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-email-processor-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorLogGroup8A79A2C8", + "Type": "AWS::Logs::LogGroup", + "PhysicalId": "/aws/lambda/po-email-processor", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorSenderAuthRejectedAlarm3D9534B7", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-email-processor-sender-auth-rejected", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorSenderAuthRejectedFilterC0DAEDFE", + "Type": "AWS::Logs::MetricFilter", + "PhysicalId": "EmailProcessorSenderAuthRejectedFilterC0DAEDFE-3K6wBb003iDv", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorServiceRole637381CE", + "Type": "AWS::IAM::Role", + "PhysicalId": "po-ingest-EmailProcessorServiceRole637381CE-hG1KGKCIivCd", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorServiceRoleDefaultPolicy0B15DAB8", + "Type": "AWS::IAM::Policy", + "PhysicalId": "po-in-Email-BbPUnOcJ71or", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorTemplateFallbackRateAlarmF9B0866A", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-email-processor-template-fallback-rate", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "EmailProcessorThrottlesAlarmE102F9E9", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-email-processor-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ExistingRuleSetPoEmailRuleAC8E9C87", + "Type": "AWS::SES::ReceiptRule", + "PhysicalId": "ExistingRuleSetPoEmailRuleAC8E9C87-qwGDj9lBoL1G", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "PendingSiteReviewTableBF01B687", + "Type": "AWS::DynamoDB::Table", + "PhysicalId": "pending-site-review", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "PendingSiteReviewTableSystemErrorsAlarm36BA58D4", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "pending-site-review-system-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "PendingSiteReviewTableThrottlesAlarm15FB6DD5", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "pending-site-review-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "PurchaseOrdersTable491A23F2", + "Type": "AWS::DynamoDB::Table", + "PhysicalId": "purchase-orders", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "PurchaseOrdersTableSystemErrorsAlarmBC7F000F", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "purchase-orders-system-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "PurchaseOrdersTableThrottlesAlarmE5ACE81B", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "purchase-orders-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "SiteExtractor84CD7B81", + "Type": "AWS::Lambda::Function", + "PhysicalId": "po-ingest-site-extractor", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "SiteExtractorDurationAlarm3D503607", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-ingest-site-extractor-duration", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "SiteExtractorDynamoDBEventSourcepoingestPurchaseOrdersTable8DBC21215DEFB913", + "Type": "AWS::Lambda::EventSourceMapping", + "PhysicalId": "64fbee1f-d9c3-4cfd-aced-19b9d29940f8", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "SiteExtractorErrorsAlarm5DCF80DA", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-ingest-site-extractor-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "SiteExtractorLogGroupD475D9DD", + "Type": "AWS::Logs::LogGroup", + "PhysicalId": "/aws/lambda/po-ingest-site-extractor", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "SiteExtractorServiceRole11491F4D", + "Type": "AWS::IAM::Role", + "PhysicalId": "po-ingest-SiteExtractorServiceRole11491F4D-X3aKoS1Rwq1W", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "SiteExtractorServiceRoleDefaultPolicy81255274", + "Type": "AWS::IAM::Policy", + "PhysicalId": "po-in-SiteE-JJJqSNBTUCT4", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "SiteExtractorThrottlesAlarm63246CEB", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-ingest-site-extractor-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "VerifiedSitesTable23AD7F59", + "Type": "AWS::DynamoDB::Table", + "PhysicalId": "verified-sites", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "VerifiedSitesTableSystemErrorsAlarm0408CCA4", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "verified-sites-system-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "VerifiedSitesTableThrottlesAlarmEC141C84", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "verified-sites-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WebUI258D87A5", + "Type": "AWS::Lambda::Function", + "PhysicalId": "po-web-ui", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "WebUILogGroup8F43ABDC", + "Type": "AWS::Logs::LogGroup", + "PhysicalId": "/aws/lambda/po-web-ui", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WebUIServiceRoleB1B395BE", + "Type": "AWS::IAM::Role", + "PhysicalId": "po-ingest-WebUIServiceRoleB1B395BE-UVNhr9c8b4YR", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WebUIServiceRoleDefaultPolicy8B510141", + "Type": "AWS::IAM::Policy", + "PhysicalId": "po-in-WebUI-jVyc9nvGbIx6", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WebUiDurationAlarm1A1A227D", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-web-ui-duration", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "WebUiThrottlesAlarmA33C4B72", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "po-web-ui-throttles", + "Status": "CREATE_COMPLETE" + } +] diff --git a/docs/plat-86/procurement-api-resources.json b/docs/plat-86/procurement-api-resources.json new file mode 100644 index 0000000..64030a3 --- /dev/null +++ b/docs/plat-86/procurement-api-resources.json @@ -0,0 +1,338 @@ +[ + { + "LogicalId": "CDKMetadata", + "Type": "AWS::CDK::Metadata", + "PhysicalId": "472324e0-86ee-11f1-9b27-0afff03a5535", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApi187041AB", + "Type": "AWS::Lambda::Function", + "PhysicalId": "procurement-api", + "Status": "UPDATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApi5xxAlarmE69DCA02", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "procurement-api-5xx", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApiDomain857EE934", + "Type": "AWS::ApiGateway::DomainName", + "PhysicalId": "procurement-api.seahaven.com", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApiDomainMapprocurementapiProcurementRestApi7C05134920F11EB5", + "Type": "AWS::ApiGateway::BasePathMapping", + "PhysicalId": "procurement-api.seahaven.com|(none)", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApiDurationAlarmBEC61762", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "procurement-api-duration", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApiErrorsAlarmFA6549F2", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "procurement-api-errors", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApiLogGroupD8265E36", + "Type": "AWS::Logs::LogGroup", + "PhysicalId": "/aws/lambda/procurement-api", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApiServiceRoleD696D662", + "Type": "AWS::IAM::Role", + "PhysicalId": "procurement-api-ProcurementApiServiceRoleD696D662-W2RyELHYFwBL", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApiServiceRoleDefaultPolicy1953BD71", + "Type": "AWS::IAM::Policy", + "PhysicalId": "procu-Procu-3dauci6ha7Eg", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementApiThrottlesAlarm7F98A1AD", + "Type": "AWS::CloudWatch::Alarm", + "PhysicalId": "procurement-api-throttles", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiDD8C37D6", + "Type": "AWS::ApiGateway::RestApi", + "PhysicalId": "mvul1efda2", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiDeployment526FECC37a13ca46329dbf433cb1289dad9c6539", + "Type": "AWS::ApiGateway::Deployment", + "PhysicalId": "uax9sq", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiDeploymentStageprodD4833AB9", + "Type": "AWS::ApiGateway::Stage", + "PhysicalId": "prod", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApidocsBC7A2DEC", + "Type": "AWS::ApiGateway::Resource", + "PhysicalId": "k4vl85", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApidocsGET1935E06E", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|k4vl85|GET", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApidocsGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETdocsFCC4F675", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApidocsGETApiPermissionTestprocurementapiProcurementRest-JkMEaKq9d2lY", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApidocsGETApiPermissionprocurementapiProcurementRestApi7C051349GETdocs00B2D21A", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApidocsGETApiPermissionprocurementapiProcurementRestApi7-fVn42qrClCjF", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiopenapijson945C2370", + "Type": "AWS::ApiGateway::Resource", + "PhysicalId": "xos715", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiopenapijsonGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETopenapijson078A24BA", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiopenapijsonGETApiPermissionTestprocurementapiProcurem-VPxSN2DPhc4W", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiopenapijsonGETApiPermissionprocurementapiProcurementRestApi7C051349GETopenapijson86A01B87", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiopenapijsonGETApiPermissionprocurementapiProcurementR-I2fDmtO8vw2w", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiopenapijsonGETD1B98CF4", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|xos715|GET", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApipurchaseordersE380B6C5", + "Type": "AWS::ApiGateway::Resource", + "PhysicalId": "pfn6qm", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApipurchaseordersGET1A2C4DD8", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|pfn6qm|GET", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApipurchaseordersGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETpurchaseordersFF5C7CF2", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApipurchaseordersGETApiPermissionTestprocurementapiProcu-FPgglqrEyKO4", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApipurchaseordersGETApiPermissionprocurementapiProcurementRestApi7C051349GETpurchaseordersC47C57EA", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApipurchaseordersGETApiPermissionprocurementapiProcureme-Db7wxlMFIJ1T", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApipurchaseorderspoNumber11A73C81", + "Type": "AWS::ApiGateway::Resource", + "PhysicalId": "nclnn3", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApipurchaseorderspoNumberGET7331738A", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|nclnn3|GET", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApipurchaseorderspoNumberGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETpurchaseorderspoNumberC6737C34", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApipurchaseorderspoNumberGETApiPermissionTestprocurement-SxEdwssencAN", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApipurchaseorderspoNumberGETApiPermissionprocurementapiProcurementRestApi7C051349GETpurchaseorderspoNumber8DEED34E", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApipurchaseorderspoNumberGETApiPermissionprocurementapiP-qRCfwLHa2pa6", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiverifiedsites4516F10B", + "Type": "AWS::ApiGateway::Resource", + "PhysicalId": "vyst7e", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiverifiedsitesGET81D69959", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|vyst7e|GET", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiverifiedsitesGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETverifiedsitesA7C29C4E", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiverifiedsitesGETApiPermissionTestprocurementapiProcur-1QHpXCoVpyKN", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiverifiedsitesGETApiPermissionprocurementapiProcurementRestApi7C051349GETverifiedsites16531E92", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiverifiedsitesGETApiPermissionprocurementapiProcuremen-b5nhaktRwejs", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiverifiedsitessiteCode48F86D1E", + "Type": "AWS::ApiGateway::Resource", + "PhysicalId": "rmaawy", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiverifiedsitessiteCodeGET2EE78735", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|rmaawy|GET", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiverifiedsitessiteCodeGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETverifiedsitessiteCode63EDC375", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiverifiedsitessiteCodeGETApiPermissionTestprocurementa-2PI3jCYWgXPU", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiverifiedsitessiteCodeGETApiPermissionprocurementapiProcurementRestApi7C051349GETverifiedsitessiteCode6457DF41", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiverifiedsitessiteCodeGETApiPermissionprocurementapiPr-QQ5mW4HnxKAr", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersD3A24F7A", + "Type": "AWS::ApiGateway::Resource", + "PhysicalId": "h5iu4f", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersGET62D5D82A", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|h5iu4f|GET", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETworkorders23C66297", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersGETApiPermissionTestprocurementapiProcureme-Ed6sN9siVtQv", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersGETApiPermissionprocurementapiProcurementRestApi7C051349GETworkorders067BAE83", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersGETApiPermissionprocurementapiProcurementRe-CdkJhuPaVDJv", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdE28A1181", + "Type": "AWS::ApiGateway::Resource", + "PhysicalId": "4uk6uh", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETworkordersworkOrderId0451CF71", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersworkOrderIdGETApiPermissionTestprocurementa-iZ1Jmq8djJRs", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdGETApiPermissionprocurementapiProcurementRestApi7C051349GETworkordersworkOrderId6619DA0E", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersworkOrderIdGETApiPermissionprocurementapiPr-nbjmJdg8o0nc", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdGETFEEB7CE4", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|4uk6uh|GET", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdPATCH7361320B", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|4uk6uh|PATCH", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdPATCHApiPermissionTestprocurementapiProcurementRestApi7C051349PATCHworkordersworkOrderIdC15330EB", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersworkOrderIdPATCHApiPermissionTestprocuremen-BJYqqytadslV", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdPATCHApiPermissionprocurementapiProcurementRestApi7C051349PATCHworkordersworkOrderId4DF98886", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersworkOrderIdPATCHApiPermissionprocurementapi-O70SvTPEoe0h", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdcomments5C278482", + "Type": "AWS::ApiGateway::Resource", + "PhysicalId": "gfx0te", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdcommentsGET032FCBD8", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|gfx0te|GET", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdcommentsGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETworkordersworkOrderIdcomments9BD68D42", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersworkOrderIdcommentsGETApiPermissionTestproc-AbYwSYiRKjxQ", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdcommentsGETApiPermissionprocurementapiProcurementRestApi7C051349GETworkordersworkOrderIdcommentsACDBEBB8", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersworkOrderIdcommentsGETApiPermissionprocurem-uxRIv0NP6hKD", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdcommentsPOST98588435", + "Type": "AWS::ApiGateway::Method", + "PhysicalId": "mvul1efda2|gfx0te|POST", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdcommentsPOSTApiPermissionTestprocurementapiProcurementRestApi7C051349POSTworkordersworkOrderIdcomments2194642A", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersworkOrderIdcommentsPOSTApiPermissionTestpro-Ci7eIdxeqWFP", + "Status": "CREATE_COMPLETE" + }, + { + "LogicalId": "ProcurementRestApiworkordersworkOrderIdcommentsPOSTApiPermissionprocurementapiProcurementRestApi7C051349POSTworkordersworkOrderIdcommentsFBA9AF05", + "Type": "AWS::Lambda::Permission", + "PhysicalId": "procurement-api-ProcurementRestApiworkordersworkOrderIdcommentsPOSTApiPermissionprocure-VQGemxZkkIu1", + "Status": "CREATE_COMPLETE" + } +] diff --git a/docs/plat-86/raw-inventory.tsv b/docs/plat-86/raw-inventory.tsv new file mode 100644 index 0000000..4db2f04 --- /dev/null +++ b/docs/plat-86/raw-inventory.tsv @@ -0,0 +1,164 @@ +po-ingest AWS::Lambda::Function BucketNotificationsHandler050a0587b7544547bf325f094a3db8347ECC3691 po-ingest-BucketNotificationsHandler050a0587b75445-eYKw7CRdcWxS +po-ingest AWS::IAM::Role BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleB6FB88EC po-ingest-BucketNotificationsHandler050a0587b754454-kAyd3v80XLIz +po-ingest AWS::CDK::Metadata CDKMetadata e287be30-86dc-11f1-a551-0affcf7dfc53 +po-ingest AWS::S3::Bucket EmailBucket843A740F po-ingest-emails-011934824531 +po-ingest AWS::Lambda::Permission EmailBucketAllowBucketNotificationsTopoingestEmailProcessor21BB6E7689B27E99 po-ingest-EmailBucketAllowBucketNotificationsTopoingestEmailProcessor21BB6E7689B27E99-2uvEuiUCEDgr +po-ingest Custom::S3BucketNotifications EmailBucketNotificationsDDD5BEEA EmailBucketNotificationsDDD5BEEA +po-ingest AWS::IAM::Policy EmailBucketNotificationsHandlerPolicy7D16942E po-in-Email-eep3xPAZrcL7 +po-ingest AWS::S3::BucketPolicy EmailBucketPolicyBBC68546 po-ingest-emails-011934824531 +po-ingest AWS::Lambda::Function EmailProcessor218EC076 po-email-processor +po-ingest AWS::CloudWatch::Alarm EmailProcessorAiFallbackRejectedAlarm71D1F2CE po-email-processor-ai-fallback-rejected +po-ingest AWS::SQS::Queue EmailProcessorDlqA753DED5 https://sqs.us-east-1.amazonaws.com/011934824531/po-ingest-EmailProcessorDlqA753DED5-Mn33HvhsDPEu +po-ingest AWS::CloudWatch::Alarm EmailProcessorDlqMessagesAlarmED574757 po-email-processor-dlq-messages +po-ingest AWS::SQS::QueuePolicy EmailProcessorDlqPolicyB24007F0 po-ingest-EmailProcessorDlqPolicyB24007F0-ZBgpMipDtvH3 +po-ingest AWS::CloudWatch::Alarm EmailProcessorDurationAlarmF7530BED po-email-processor-duration +po-ingest AWS::CloudWatch::Alarm EmailProcessorErrorsAlarmC1BF0D18 po-email-processor-errors +po-ingest AWS::Logs::LogGroup EmailProcessorLogGroup8A79A2C8 /aws/lambda/po-email-processor +po-ingest AWS::CloudWatch::Alarm EmailProcessorSenderAuthRejectedAlarm3D9534B7 po-email-processor-sender-auth-rejected +po-ingest AWS::Logs::MetricFilter EmailProcessorSenderAuthRejectedFilterC0DAEDFE EmailProcessorSenderAuthRejectedFilterC0DAEDFE-3K6wBb003iDv +po-ingest AWS::IAM::Role EmailProcessorServiceRole637381CE po-ingest-EmailProcessorServiceRole637381CE-hG1KGKCIivCd +po-ingest AWS::IAM::Policy EmailProcessorServiceRoleDefaultPolicy0B15DAB8 po-in-Email-BbPUnOcJ71or +po-ingest AWS::CloudWatch::Alarm EmailProcessorTemplateFallbackRateAlarmF9B0866A po-email-processor-template-fallback-rate +po-ingest AWS::CloudWatch::Alarm EmailProcessorThrottlesAlarmE102F9E9 po-email-processor-throttles +po-ingest AWS::SES::ReceiptRule ExistingRuleSetPoEmailRuleAC8E9C87 ExistingRuleSetPoEmailRuleAC8E9C87-qwGDj9lBoL1G +po-ingest AWS::DynamoDB::Table PendingSiteReviewTableBF01B687 pending-site-review +po-ingest AWS::CloudWatch::Alarm PendingSiteReviewTableSystemErrorsAlarm36BA58D4 pending-site-review-system-errors +po-ingest AWS::CloudWatch::Alarm PendingSiteReviewTableThrottlesAlarm15FB6DD5 pending-site-review-throttles +po-ingest AWS::DynamoDB::Table PurchaseOrdersTable491A23F2 purchase-orders +po-ingest AWS::CloudWatch::Alarm PurchaseOrdersTableSystemErrorsAlarmBC7F000F purchase-orders-system-errors +po-ingest AWS::CloudWatch::Alarm PurchaseOrdersTableThrottlesAlarmE5ACE81B purchase-orders-throttles +po-ingest AWS::Lambda::Function SiteExtractor84CD7B81 po-ingest-site-extractor +po-ingest AWS::CloudWatch::Alarm SiteExtractorDurationAlarm3D503607 po-ingest-site-extractor-duration +po-ingest AWS::Lambda::EventSourceMapping SiteExtractorDynamoDBEventSourcepoingestPurchaseOrdersTable8DBC21215DEFB913 64fbee1f-d9c3-4cfd-aced-19b9d29940f8 +po-ingest AWS::CloudWatch::Alarm SiteExtractorErrorsAlarm5DCF80DA po-ingest-site-extractor-errors +po-ingest AWS::Logs::LogGroup SiteExtractorLogGroupD475D9DD /aws/lambda/po-ingest-site-extractor +po-ingest AWS::IAM::Role SiteExtractorServiceRole11491F4D po-ingest-SiteExtractorServiceRole11491F4D-X3aKoS1Rwq1W +po-ingest AWS::IAM::Policy SiteExtractorServiceRoleDefaultPolicy81255274 po-in-SiteE-JJJqSNBTUCT4 +po-ingest AWS::CloudWatch::Alarm SiteExtractorThrottlesAlarm63246CEB po-ingest-site-extractor-throttles +po-ingest AWS::DynamoDB::Table VerifiedSitesTable23AD7F59 verified-sites +po-ingest AWS::CloudWatch::Alarm VerifiedSitesTableSystemErrorsAlarm0408CCA4 verified-sites-system-errors +po-ingest AWS::CloudWatch::Alarm VerifiedSitesTableThrottlesAlarmEC141C84 verified-sites-throttles +po-ingest AWS::Lambda::Function WebUI258D87A5 po-web-ui +po-ingest AWS::Logs::LogGroup WebUILogGroup8F43ABDC /aws/lambda/po-web-ui +po-ingest AWS::IAM::Role WebUIServiceRoleB1B395BE po-ingest-WebUIServiceRoleB1B395BE-UVNhr9c8b4YR +po-ingest AWS::IAM::Policy WebUIServiceRoleDefaultPolicy8B510141 po-in-WebUI-jVyc9nvGbIx6 +po-ingest AWS::CloudWatch::Alarm WebUiDurationAlarm1A1A227D po-web-ui-duration +po-ingest AWS::CloudWatch::Alarm WebUiThrottlesAlarmA33C4B72 po-web-ui-throttles +WorkorderIngestStack AWS::Lambda::Function BucketNotificationsHandler050a0587b7544547bf325f094a3db8347ECC3691 WorkorderIngestStack-BucketNotificationsHandler050-fBtoo7x6Azy3 +WorkorderIngestStack AWS::IAM::Role BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleB6FB88EC WorkorderIngestStack-BucketNotificationsHandler050a-7KxpBKmlpFSf +WorkorderIngestStack AWS::CDK::Metadata CDKMetadata a85b0320-86dc-11f1-b0cf-0eccc7521e19 +WorkorderIngestStack AWS::DynamoDB::Table CommentsTableBBDBF0A8 WorkOrderComments +WorkorderIngestStack AWS::S3::Bucket EmailBucket843A740F workorder-ingest-emails-011934824531 +WorkorderIngestStack AWS::Lambda::Permission EmailBucketAllowBucketNotificationsToworkorderingestEmailProcessor5B0B85DABA138779 WorkorderIngestStack-EmailBucketAllowBucketNotificationsToworkorderingestEmailProcessor-VuairMrX6Qir +WorkorderIngestStack Custom::S3BucketNotifications EmailBucketNotificationsDDD5BEEA EmailBucketNotificationsDDD5BEEA +WorkorderIngestStack AWS::IAM::Policy EmailBucketNotificationsHandlerPolicy7D16942E Worko-Email-YLl5HvyOvIg2 +WorkorderIngestStack AWS::S3::BucketPolicy EmailBucketPolicyBBC68546 workorder-ingest-emails-011934824531 +WorkorderIngestStack AWS::Lambda::Function EmailProcessor218EC076 workorder-email-processor +WorkorderIngestStack AWS::CloudWatch::Alarm EmailProcessorAiFallbackRejectedAlarm71D1F2CE workorder-email-processor-ai-fallback-rejected +WorkorderIngestStack AWS::SQS::Queue EmailProcessorDlqA753DED5 https://sqs.us-east-1.amazonaws.com/011934824531/WorkorderIngestStack-EmailProcessorDlqA753DED5-qCTHrsoEucas +WorkorderIngestStack AWS::CloudWatch::Alarm EmailProcessorDlqMessagesAlarmED574757 workorder-email-processor-dlq-messages +WorkorderIngestStack AWS::SQS::QueuePolicy EmailProcessorDlqPolicyB24007F0 WorkorderIngestStack-EmailProcessorDlqPolicyB24007F0-15VK27UU5Q19 +WorkorderIngestStack AWS::CloudWatch::Alarm EmailProcessorDurationAlarmF7530BED workorder-email-processor-duration +WorkorderIngestStack AWS::CloudWatch::Alarm EmailProcessorErrorsAlarmC1BF0D18 workorder-email-processor-errors +WorkorderIngestStack AWS::Logs::LogGroup EmailProcessorLogGroup8A79A2C8 /aws/lambda/workorder-email-processor +WorkorderIngestStack AWS::CloudWatch::Alarm EmailProcessorSenderAuthRejectedAlarm3D9534B7 workorder-email-processor-sender-auth-rejected +WorkorderIngestStack AWS::Logs::MetricFilter EmailProcessorSenderAuthRejectedFilterC0DAEDFE EmailProcessorSenderAuthRejectedFilterC0DAEDFE-Wkbow1VNDeDM +WorkorderIngestStack AWS::IAM::Role EmailProcessorServiceRole637381CE WorkorderIngestStack-EmailProcessorServiceRole63738-5l9OZXQLVvJn +WorkorderIngestStack AWS::IAM::Policy EmailProcessorServiceRoleDefaultPolicy0B15DAB8 Worko-Email-8PjqeVLLDLRv +WorkorderIngestStack AWS::CloudWatch::Alarm EmailProcessorTemplateFallbackRateAlarmF9B0866A workorder-email-processor-template-fallback-rate +WorkorderIngestStack AWS::CloudWatch::Alarm EmailProcessorThrottlesAlarmE102F9E9 workorder-email-processor-throttles +WorkorderIngestStack AWS::SES::ReceiptRule ExistingRuleSetWorkorderEmailRuleEA29F845 ExistingRuleSetWorkorderEmailRuleEA29F845-PKtaDBvIg61a +WorkorderIngestStack AWS::Lambda::Function ShocEmitterCD1F4C3E workorder-shoc-emitter +WorkorderIngestStack AWS::CloudWatch::Alarm ShocEmitterDurationAlarm3F755835 workorder-shoc-emitter-duration +WorkorderIngestStack AWS::Lambda::EventSourceMapping ShocEmitterDynamoDBEventSourceworkorderingestCommentsTableFD97F35C58A19C1A da37d4a0-3086-4929-9ace-6eca5c20cd07 +WorkorderIngestStack AWS::Lambda::EventSourceMapping ShocEmitterDynamoDBEventSourceworkorderingestWorkOrdersTable004CD4AC38D8BED2 cc149f4d-5375-4820-8fb8-c514accd3a85 +WorkorderIngestStack AWS::CloudWatch::Alarm ShocEmitterErrorsAlarmCE2B702A workorder-shoc-emitter-errors +WorkorderIngestStack AWS::CloudWatch::Alarm ShocEmitterFailuresMessagesAlarmEC9798F9 workorder-shoc-emitter-failures-messages +WorkorderIngestStack AWS::SQS::Queue ShocEmitterFailuresQueue11D7C8E0 https://sqs.us-east-1.amazonaws.com/011934824531/workorder-shoc-emitter-failures +WorkorderIngestStack AWS::SQS::QueuePolicy ShocEmitterFailuresQueuePolicy0D60571C WorkorderIngestStack-ShocEmitterFailuresQueuePolicy0D60571C-2AMBNJh0la3L +WorkorderIngestStack AWS::CloudWatch::Alarm ShocEmitterIteratorAgeAlarm90B55822 workorder-shoc-emitter-iterator-age +WorkorderIngestStack AWS::Logs::LogGroup ShocEmitterLogGroup80BDE724 /aws/lambda/workorder-shoc-emitter +WorkorderIngestStack AWS::CloudWatch::Alarm ShocEmitterRejectedMessagesAlarmAE9AFCDD workorder-shoc-emitter-rejected-messages +WorkorderIngestStack AWS::SQS::Queue ShocEmitterRejectedQueueD754F41E https://sqs.us-east-1.amazonaws.com/011934824531/workorder-shoc-emitter-rejected +WorkorderIngestStack AWS::SQS::QueuePolicy ShocEmitterRejectedQueuePolicy4B088C0D WorkorderIngestStack-ShocEmitterRejectedQueuePolicy4B088C0D-vzXOuhHaGfNy +WorkorderIngestStack AWS::IAM::Role ShocEmitterServiceRoleA0CA0C74 WorkorderIngestStack-ShocEmitterServiceRoleA0CA0C74-LlFfZ6F9f6sr +WorkorderIngestStack AWS::IAM::Policy ShocEmitterServiceRoleDefaultPolicy5DE45717 Worko-ShocE-0txSITC4MbcW +WorkorderIngestStack AWS::CloudWatch::Alarm ShocEmitterThrottlesAlarm5503E07A workorder-shoc-emitter-throttles +WorkorderIngestStack AWS::Lambda::Function ShocHmacRotatorD4073E6B workorder-shoc-hmac-rotator +WorkorderIngestStack AWS::CloudWatch::Alarm ShocHmacRotatorDurationAlarm0725D01A workorder-shoc-hmac-rotator-duration +WorkorderIngestStack AWS::CloudWatch::Alarm ShocHmacRotatorErrorsAlarm55E7BCC3 workorder-shoc-hmac-rotator-errors +WorkorderIngestStack AWS::Lambda::Permission ShocHmacRotatorInvokeN0a2GKfZP0JmDqDEVhhu6A0TUv3NyNbk4YMFKNc3085C896 WorkorderIngestStack-ShocHmacRotatorInvokeN0a2GKfZP0JmDqDEVhhu6A0TUv3NyNbk4YMFKNc3085C8-kJaePEGWmM3r +WorkorderIngestStack AWS::Logs::LogGroup ShocHmacRotatorLogGroup1D109CC5 /aws/lambda/workorder-shoc-hmac-rotator +WorkorderIngestStack AWS::IAM::Role ShocHmacRotatorServiceRole7F2B6917 WorkorderIngestStack-ShocHmacRotatorServiceRole7F2B-uKqx4ccGQTk5 +WorkorderIngestStack AWS::IAM::Policy ShocHmacRotatorServiceRoleDefaultPolicy492EC50B Worko-ShocH-B5ATRJ8co1Hc +WorkorderIngestStack AWS::CloudWatch::Alarm ShocHmacRotatorThrottlesAlarm9963F2A3 workorder-shoc-hmac-rotator-throttles +WorkorderIngestStack AWS::KMS::Key ShocWebhookHmacKey87DD3EC7 d10fd1f0-a61a-4405-8568-85e9fd11ba18 +WorkorderIngestStack AWS::KMS::Alias ShocWebhookHmacKeyAliasC83A07B2 alias/workorder-ingest-shoc-webhook-kms +WorkorderIngestStack AWS::SecretsManager::Secret ShocWebhookHmacSecretA6CF6356 arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB +WorkorderIngestStack AWS::SecretsManager::ResourcePolicy ShocWebhookHmacSecretPolicy2B0A7DAC arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB +WorkorderIngestStack AWS::SecretsManager::RotationSchedule ShocWebhookHmacSecretRotationA6130D74 arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB +WorkorderIngestStack AWS::Lambda::Function WebUI258D87A5 workorder-web-ui +WorkorderIngestStack AWS::Logs::LogGroup WebUILogGroup8F43ABDC /aws/lambda/workorder-web-ui +WorkorderIngestStack AWS::IAM::Role WebUIServiceRoleB1B395BE WorkorderIngestStack-WebUIServiceRoleB1B395BE-0kRzDlAiLZVn +WorkorderIngestStack AWS::IAM::Policy WebUIServiceRoleDefaultPolicy8B510141 Worko-WebUI-TVYFGuD5kBVZ +WorkorderIngestStack AWS::CloudWatch::Alarm WorkOrderCommentsSystemErrorsAlarm4CD72F33 WorkOrderComments-system-errors +WorkorderIngestStack AWS::CloudWatch::Alarm WorkOrderCommentsThrottlesAlarmA10AEFD9 WorkOrderComments-throttles +WorkorderIngestStack AWS::DynamoDB::Table WorkOrdersTable515B4C61 WorkOrders +WorkorderIngestStack AWS::CloudWatch::Alarm WorkOrdersTableSystemErrorsAlarm117925DB WorkOrders-system-errors +WorkorderIngestStack AWS::CloudWatch::Alarm WorkOrdersTableThrottlesAlarmDACF2500 WorkOrders-throttles +procurement-api AWS::CDK::Metadata CDKMetadata 472324e0-86ee-11f1-9b27-0afff03a5535 +procurement-api AWS::Lambda::Function ProcurementApi187041AB procurement-api +procurement-api AWS::CloudWatch::Alarm ProcurementApi5xxAlarmE69DCA02 procurement-api-5xx +procurement-api AWS::ApiGateway::DomainName ProcurementApiDomain857EE934 procurement-api.seahaven.com +procurement-api AWS::ApiGateway::BasePathMapping ProcurementApiDomainMapprocurementapiProcurementRestApi7C05134920F11EB5 procurement-api.seahaven.com|(none) +procurement-api AWS::CloudWatch::Alarm ProcurementApiDurationAlarmBEC61762 procurement-api-duration +procurement-api AWS::CloudWatch::Alarm ProcurementApiErrorsAlarmFA6549F2 procurement-api-errors +procurement-api AWS::Logs::LogGroup ProcurementApiLogGroupD8265E36 /aws/lambda/procurement-api +procurement-api AWS::IAM::Role ProcurementApiServiceRoleD696D662 procurement-api-ProcurementApiServiceRoleD696D662-W2RyELHYFwBL +procurement-api AWS::IAM::Policy ProcurementApiServiceRoleDefaultPolicy1953BD71 procu-Procu-3dauci6ha7Eg +procurement-api AWS::CloudWatch::Alarm ProcurementApiThrottlesAlarm7F98A1AD procurement-api-throttles +procurement-api AWS::ApiGateway::RestApi ProcurementRestApiDD8C37D6 mvul1efda2 +procurement-api AWS::ApiGateway::Deployment ProcurementRestApiDeployment526FECC37a13ca46329dbf433cb1289dad9c6539 uax9sq +procurement-api AWS::ApiGateway::Stage ProcurementRestApiDeploymentStageprodD4833AB9 prod +procurement-api AWS::ApiGateway::Resource ProcurementRestApidocsBC7A2DEC k4vl85 +procurement-api AWS::ApiGateway::Method ProcurementRestApidocsGET1935E06E mvul1efda2|k4vl85|GET +procurement-api AWS::Lambda::Permission ProcurementRestApidocsGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETdocsFCC4F675 procurement-api-ProcurementRestApidocsGETApiPermissionTestprocurementapiProcurementRest-JkMEaKq9d2lY +procurement-api AWS::Lambda::Permission ProcurementRestApidocsGETApiPermissionprocurementapiProcurementRestApi7C051349GETdocs00B2D21A procurement-api-ProcurementRestApidocsGETApiPermissionprocurementapiProcurementRestApi7-fVn42qrClCjF +procurement-api AWS::ApiGateway::Resource ProcurementRestApiopenapijson945C2370 xos715 +procurement-api AWS::Lambda::Permission ProcurementRestApiopenapijsonGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETopenapijson078A24BA procurement-api-ProcurementRestApiopenapijsonGETApiPermissionTestprocurementapiProcurem-VPxSN2DPhc4W +procurement-api AWS::Lambda::Permission ProcurementRestApiopenapijsonGETApiPermissionprocurementapiProcurementRestApi7C051349GETopenapijson86A01B87 procurement-api-ProcurementRestApiopenapijsonGETApiPermissionprocurementapiProcurementR-I2fDmtO8vw2w +procurement-api AWS::ApiGateway::Method ProcurementRestApiopenapijsonGETD1B98CF4 mvul1efda2|xos715|GET +procurement-api AWS::ApiGateway::Resource ProcurementRestApipurchaseordersE380B6C5 pfn6qm +procurement-api AWS::ApiGateway::Method ProcurementRestApipurchaseordersGET1A2C4DD8 mvul1efda2|pfn6qm|GET +procurement-api AWS::Lambda::Permission ProcurementRestApipurchaseordersGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETpurchaseordersFF5C7CF2 procurement-api-ProcurementRestApipurchaseordersGETApiPermissionTestprocurementapiProcu-FPgglqrEyKO4 +procurement-api AWS::Lambda::Permission ProcurementRestApipurchaseordersGETApiPermissionprocurementapiProcurementRestApi7C051349GETpurchaseordersC47C57EA procurement-api-ProcurementRestApipurchaseordersGETApiPermissionprocurementapiProcureme-Db7wxlMFIJ1T +procurement-api AWS::ApiGateway::Resource ProcurementRestApipurchaseorderspoNumber11A73C81 nclnn3 +procurement-api AWS::ApiGateway::Method ProcurementRestApipurchaseorderspoNumberGET7331738A mvul1efda2|nclnn3|GET +procurement-api AWS::Lambda::Permission ProcurementRestApipurchaseorderspoNumberGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETpurchaseorderspoNumberC6737C34 procurement-api-ProcurementRestApipurchaseorderspoNumberGETApiPermissionTestprocurement-SxEdwssencAN +procurement-api AWS::Lambda::Permission ProcurementRestApipurchaseorderspoNumberGETApiPermissionprocurementapiProcurementRestApi7C051349GETpurchaseorderspoNumber8DEED34E procurement-api-ProcurementRestApipurchaseorderspoNumberGETApiPermissionprocurementapiP-qRCfwLHa2pa6 +procurement-api AWS::ApiGateway::Resource ProcurementRestApiverifiedsites4516F10B vyst7e +procurement-api AWS::ApiGateway::Method ProcurementRestApiverifiedsitesGET81D69959 mvul1efda2|vyst7e|GET +procurement-api AWS::Lambda::Permission ProcurementRestApiverifiedsitesGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETverifiedsitesA7C29C4E procurement-api-ProcurementRestApiverifiedsitesGETApiPermissionTestprocurementapiProcur-1QHpXCoVpyKN +procurement-api AWS::Lambda::Permission ProcurementRestApiverifiedsitesGETApiPermissionprocurementapiProcurementRestApi7C051349GETverifiedsites16531E92 procurement-api-ProcurementRestApiverifiedsitesGETApiPermissionprocurementapiProcuremen-b5nhaktRwejs +procurement-api AWS::ApiGateway::Resource ProcurementRestApiverifiedsitessiteCode48F86D1E rmaawy +procurement-api AWS::ApiGateway::Method ProcurementRestApiverifiedsitessiteCodeGET2EE78735 mvul1efda2|rmaawy|GET +procurement-api AWS::Lambda::Permission ProcurementRestApiverifiedsitessiteCodeGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETverifiedsitessiteCode63EDC375 procurement-api-ProcurementRestApiverifiedsitessiteCodeGETApiPermissionTestprocurementa-2PI3jCYWgXPU +procurement-api AWS::Lambda::Permission ProcurementRestApiverifiedsitessiteCodeGETApiPermissionprocurementapiProcurementRestApi7C051349GETverifiedsitessiteCode6457DF41 procurement-api-ProcurementRestApiverifiedsitessiteCodeGETApiPermissionprocurementapiPr-QQ5mW4HnxKAr +procurement-api AWS::ApiGateway::Resource ProcurementRestApiworkordersD3A24F7A h5iu4f +procurement-api AWS::ApiGateway::Method ProcurementRestApiworkordersGET62D5D82A mvul1efda2|h5iu4f|GET +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETworkorders23C66297 procurement-api-ProcurementRestApiworkordersGETApiPermissionTestprocurementapiProcureme-Ed6sN9siVtQv +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersGETApiPermissionprocurementapiProcurementRestApi7C051349GETworkorders067BAE83 procurement-api-ProcurementRestApiworkordersGETApiPermissionprocurementapiProcurementRe-CdkJhuPaVDJv +procurement-api AWS::ApiGateway::Resource ProcurementRestApiworkordersworkOrderIdE28A1181 4uk6uh +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersworkOrderIdGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETworkordersworkOrderId0451CF71 procurement-api-ProcurementRestApiworkordersworkOrderIdGETApiPermissionTestprocurementa-iZ1Jmq8djJRs +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersworkOrderIdGETApiPermissionprocurementapiProcurementRestApi7C051349GETworkordersworkOrderId6619DA0E procurement-api-ProcurementRestApiworkordersworkOrderIdGETApiPermissionprocurementapiPr-nbjmJdg8o0nc +procurement-api AWS::ApiGateway::Method ProcurementRestApiworkordersworkOrderIdGETFEEB7CE4 mvul1efda2|4uk6uh|GET +procurement-api AWS::ApiGateway::Method ProcurementRestApiworkordersworkOrderIdPATCH7361320B mvul1efda2|4uk6uh|PATCH +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersworkOrderIdPATCHApiPermissionTestprocurementapiProcurementRestApi7C051349PATCHworkordersworkOrderIdC15330EB procurement-api-ProcurementRestApiworkordersworkOrderIdPATCHApiPermissionTestprocuremen-BJYqqytadslV +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersworkOrderIdPATCHApiPermissionprocurementapiProcurementRestApi7C051349PATCHworkordersworkOrderId4DF98886 procurement-api-ProcurementRestApiworkordersworkOrderIdPATCHApiPermissionprocurementapi-O70SvTPEoe0h +procurement-api AWS::ApiGateway::Resource ProcurementRestApiworkordersworkOrderIdcomments5C278482 gfx0te +procurement-api AWS::ApiGateway::Method ProcurementRestApiworkordersworkOrderIdcommentsGET032FCBD8 mvul1efda2|gfx0te|GET +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersworkOrderIdcommentsGETApiPermissionTestprocurementapiProcurementRestApi7C051349GETworkordersworkOrderIdcomments9BD68D42 procurement-api-ProcurementRestApiworkordersworkOrderIdcommentsGETApiPermissionTestproc-AbYwSYiRKjxQ +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersworkOrderIdcommentsGETApiPermissionprocurementapiProcurementRestApi7C051349GETworkordersworkOrderIdcommentsACDBEBB8 procurement-api-ProcurementRestApiworkordersworkOrderIdcommentsGETApiPermissionprocurem-uxRIv0NP6hKD +procurement-api AWS::ApiGateway::Method ProcurementRestApiworkordersworkOrderIdcommentsPOST98588435 mvul1efda2|gfx0te|POST +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersworkOrderIdcommentsPOSTApiPermissionTestprocurementapiProcurementRestApi7C051349POSTworkordersworkOrderIdcomments2194642A procurement-api-ProcurementRestApiworkordersworkOrderIdcommentsPOSTApiPermissionTestpro-Ci7eIdxeqWFP +procurement-api AWS::Lambda::Permission ProcurementRestApiworkordersworkOrderIdcommentsPOSTApiPermissionprocurementapiProcurementRestApi7C051349POSTworkordersworkOrderIdcommentsFBA9AF05 procurement-api-ProcurementRestApiworkordersworkOrderIdcommentsPOSTApiPermissionprocure-VQGemxZkkIu1 diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..b435231 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,11 @@ +build/ +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +!terraform.tfvars.example +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..7e949f1 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,74 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.8.0" + constraints = "~> 2.0" + hashes = [ + "h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=", + "h1:cMBtvdHEgvTmglbioVehZCaOIPocumu9+vlGw5Dsaro=", + "h1:jdmKm+xl6ZcQrijxapnZ94RVuz/G4vk7hsIa1N0VT5Q=", + "h1:oRWx6ZDIdlNBF90L8TzV9X7pQ+P403hoCDiBfmUfhC0=", + "zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2", + "zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f", + "zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a", + "zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea", + "zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997", + "zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0", + "zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940", + "zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa", + "zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368", + "zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4", + "zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.58.0" + constraints = "~> 6.57" + hashes = [ + "h1:1im6ypdeXLXq3sHElserTE62qmIqNiHLAyC3R5EnFFw=", + "h1:2kpake4zZKRX5437QVIRU3qFYH6Bjw/QE1fgVCPOrUg=", + "h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=", + "h1:UFot9S97tuAPvjKvoxm08sDG/gKYdDK+lMwsZKtLieY=", + "zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250", + "zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f", + "zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48", + "zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba", + "zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7", + "zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56", + "zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6", + "zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80", + "zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75", + "zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a", + "zh:9078589ec881cee7ed9403af262c98ff257fb3e1baae72ff6429a398b1c730af", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c", + "zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae", + "zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca", + "zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056", + ] +} + +provider "registry.terraform.io/hashicorp/external" { + version = "2.4.0" + constraints = "~> 2.0" + hashes = [ + "h1:AmY6ZeIvqoTT5ZjzD+P49PeQH6Va1QLMkX+7MUQfYoA=", + "zh:0772afb42b658468ac5e15df33bf2080456f8f0b8ab163bfe9c50d2b2ea02135", + "zh:0ac31a9aaa43dfcff5944b791596cdc94e153348e4bb4642282d034dff548134", + "zh:32d8492b1bdcc956ca3c6d00c6392d0a83942ff11d4820c7ee63ca6796e06950", + "zh:3c0482e894429f528ce6655a76ab0d8a9f7c0dacc6c828865e1515d4a7dbb852", + "zh:61e68100b4db2f930b31491f23c602126382fd5e51252be1b551f0e17f8ddbee", + "zh:6d60f615a0ad85eb962c9eb94f25e3eba7a72684ce276ba5dfb23f36b295a8f8", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:9ced2745eb5f1346203027d2dd7bf856ad1d279a25730ff7dbc6eec187aaca0c", + "zh:a8378558a177d43f55aa0d79d4fae91a704695122a1b109668c1daa8fb76f09d", + "zh:aadd98086133d3ebea67437d56512fdcc6dfb3bd34dfc23f276c0db9272e27b4", + "zh:beff701b653841e70441978137768f54e7dc6c27e7bf12a4589087f01f5bbcee", + "zh:c91c2223b29fdbc0044d20e1936ccc051d010727a13f2ff1e75e51f09bff33a3", + "zh:d491f9c2d32a39dc4031628469ae7c8aec0074312a7c1f0286b173cdcf854a54", + ] +} diff --git a/terraform/api.tf b/terraform/api.tf new file mode 100644 index 0000000..cc890f4 --- /dev/null +++ b/terraform/api.tf @@ -0,0 +1,328 @@ +locals { + api_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ShocBackendDevDataRead" + Effect = "Allow" + Principal = { + AWS = local.shoc_consumer_role_arn + } + Action = "execute-api:Invoke" + Resource = [ + "arn:aws:execute-api:${var.aws_region}:${local.account_id}:${local.api_rest_api_id}/${local.api_stage_name}/GET/work-orders", + "arn:aws:execute-api:${var.aws_region}:${local.account_id}:${local.api_rest_api_id}/${local.api_stage_name}/GET/work-orders/*", + "arn:aws:execute-api:${var.aws_region}:${local.account_id}:${local.api_rest_api_id}/${local.api_stage_name}/GET/purchase-orders", + "arn:aws:execute-api:${var.aws_region}:${local.account_id}:${local.api_rest_api_id}/${local.api_stage_name}/GET/purchase-orders/*", + "arn:aws:execute-api:${var.aws_region}:${local.account_id}:${local.api_rest_api_id}/${local.api_stage_name}/GET/verified-sites", + "arn:aws:execute-api:${var.aws_region}:${local.account_id}:${local.api_rest_api_id}/${local.api_stage_name}/GET/verified-sites/*", + ] + }, + { + Sid = "DocsTokenGatedRoutes" + Effect = "Allow" + Principal = { AWS = "*" } + Action = "execute-api:Invoke" + Resource = [ + "arn:aws:execute-api:${var.aws_region}:${local.account_id}:${local.api_rest_api_id}/${local.api_stage_name}/GET/docs", + "arn:aws:execute-api:${var.aws_region}:${local.account_id}:${local.api_rest_api_id}/${local.api_stage_name}/GET/openapi.json", + ] + } + ] + }) + + # Live resource IDs (import). parent_key null => API root. + api_resources = { + work_orders = { + id = "h5iu4f" + parent_key = null + path_part = "work-orders" + } + work_order_id = { + id = "4uk6uh" + parent_key = "work_orders" + path_part = "{workOrderId}" + } + work_order_comments = { + id = "gfx0te" + parent_key = "work_order_id" + path_part = "comments" + } + purchase_orders = { + id = "pfn6qm" + parent_key = null + path_part = "purchase-orders" + } + po_number = { + id = "nclnn3" + parent_key = "purchase_orders" + path_part = "{poNumber}" + } + verified_sites = { + id = "vyst7e" + parent_key = null + path_part = "verified-sites" + } + site_code = { + id = "rmaawy" + parent_key = "verified_sites" + path_part = "{siteCode}" + } + docs = { + id = "k4vl85" + parent_key = null + path_part = "docs" + } + openapi_json = { + id = "xos715" + parent_key = null + path_part = "openapi.json" + } + } + + api_iam_methods = { + work_orders_get = { resource = "work_orders", method = "GET" } + work_order_id_get = { resource = "work_order_id", method = "GET" } + work_order_id_patch = { resource = "work_order_id", method = "PATCH" } + work_order_comments_get = { resource = "work_order_comments", method = "GET" } + work_order_comments_post = { resource = "work_order_comments", method = "POST" } + purchase_orders_get = { resource = "purchase_orders", method = "GET" } + po_number_get = { resource = "po_number", method = "GET" } + verified_sites_get = { resource = "verified_sites", method = "GET" } + site_code_get = { resource = "site_code", method = "GET" } + } + + api_none_methods = { + docs_get = { resource = "docs", method = "GET" } + openapi_json_get = { resource = "openapi_json", method = "GET" } + } +} + +resource "aws_cloudwatch_log_group" "procurement_api" { + name = "/aws/lambda/procurement-api" + retention_in_days = 60 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_lambda_function" "procurement_api" { + function_name = "procurement-api" + role = aws_iam_role.procurement_api.arn + handler = "handler.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 256 + timeout = 30 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.lambda["procurement_api"].key + source_code_hash = data.archive_file.lambda["procurement_api"].output_base64sha256 + + environment { + variables = { + VERIFIED_SITES_TABLE = aws_dynamodb_table.verified_sites.name + WORK_ORDERS_TABLE = aws_dynamodb_table.work_orders.name + COMMENTS_TABLE = aws_dynamodb_table.work_order_comments.name + PO_TABLE = aws_dynamodb_table.purchase_orders.name + WEB_UI_AUTH_TOKEN_SECRET_ARN = var.web_ui_auth_token_secret_arn + } + } + + depends_on = [ + aws_s3_object.lambda, + aws_cloudwatch_log_group.procurement_api, + aws_iam_role_policy_attachment.procurement_api_basic, + aws_iam_role_policy.procurement_api_ddb, + aws_iam_role_policy.procurement_api_kms, + aws_iam_role_policy.procurement_api_secrets, + ] +} + +resource "aws_api_gateway_rest_api" "procurement" { + name = "procurement-api" + description = "Read API over procurement-ingest work orders + purchase orders; token-gated OpenAPI docs at /docs" + policy = local.api_policy + + endpoint_configuration { + types = ["REGIONAL"] + } +} + +resource "aws_api_gateway_resource" "work_orders" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + parent_id = aws_api_gateway_rest_api.procurement.root_resource_id + path_part = "work-orders" +} + +resource "aws_api_gateway_resource" "work_order_id" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + parent_id = aws_api_gateway_resource.work_orders.id + path_part = "{workOrderId}" +} + +resource "aws_api_gateway_resource" "work_order_comments" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + parent_id = aws_api_gateway_resource.work_order_id.id + path_part = "comments" +} + +resource "aws_api_gateway_resource" "purchase_orders" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + parent_id = aws_api_gateway_rest_api.procurement.root_resource_id + path_part = "purchase-orders" +} + +resource "aws_api_gateway_resource" "po_number" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + parent_id = aws_api_gateway_resource.purchase_orders.id + path_part = "{poNumber}" +} + +resource "aws_api_gateway_resource" "verified_sites" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + parent_id = aws_api_gateway_rest_api.procurement.root_resource_id + path_part = "verified-sites" +} + +resource "aws_api_gateway_resource" "site_code" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + parent_id = aws_api_gateway_resource.verified_sites.id + path_part = "{siteCode}" +} + +resource "aws_api_gateway_resource" "docs" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + parent_id = aws_api_gateway_rest_api.procurement.root_resource_id + path_part = "docs" +} + +resource "aws_api_gateway_resource" "openapi_json" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + parent_id = aws_api_gateway_rest_api.procurement.root_resource_id + path_part = "openapi.json" +} + +locals { + api_resource_ids = { + work_orders = aws_api_gateway_resource.work_orders.id + work_order_id = aws_api_gateway_resource.work_order_id.id + work_order_comments = aws_api_gateway_resource.work_order_comments.id + purchase_orders = aws_api_gateway_resource.purchase_orders.id + po_number = aws_api_gateway_resource.po_number.id + verified_sites = aws_api_gateway_resource.verified_sites.id + site_code = aws_api_gateway_resource.site_code.id + docs = aws_api_gateway_resource.docs.id + openapi_json = aws_api_gateway_resource.openapi_json.id + } +} + +resource "aws_api_gateway_method" "iam" { + for_each = local.api_iam_methods + + rest_api_id = aws_api_gateway_rest_api.procurement.id + resource_id = local.api_resource_ids[each.value.resource] + http_method = each.value.method + authorization = "AWS_IAM" +} + +resource "aws_api_gateway_method" "none" { + for_each = local.api_none_methods + + rest_api_id = aws_api_gateway_rest_api.procurement.id + resource_id = local.api_resource_ids[each.value.resource] + http_method = each.value.method + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "iam" { + for_each = local.api_iam_methods + + rest_api_id = aws_api_gateway_rest_api.procurement.id + resource_id = local.api_resource_ids[each.value.resource] + http_method = aws_api_gateway_method.iam[each.key].http_method + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.procurement_api.invoke_arn +} + +resource "aws_api_gateway_integration" "none" { + for_each = local.api_none_methods + + rest_api_id = aws_api_gateway_rest_api.procurement.id + resource_id = local.api_resource_ids[each.value.resource] + http_method = aws_api_gateway_method.none[each.key].http_method + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.procurement_api.invoke_arn +} + +resource "aws_lambda_permission" "api_gateway" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.procurement_api.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_api_gateway_rest_api.procurement.execution_arn}/*/*" +} + +resource "aws_api_gateway_deployment" "procurement" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + + triggers = { + redeployment = sha1(jsonencode({ + resources = local.api_resources + iam = local.api_iam_methods + none = local.api_none_methods + policy = local.api_policy + lambda_hash = data.archive_file.lambda["procurement_api"].output_base64sha256 + })) + } + + lifecycle { + create_before_destroy = true + } + + depends_on = [ + aws_api_gateway_integration.iam, + aws_api_gateway_integration.none, + ] +} + +resource "aws_api_gateway_stage" "prod" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + deployment_id = aws_api_gateway_deployment.procurement.id + stage_name = local.api_stage_name + + xray_tracing_enabled = false +} + +resource "aws_api_gateway_method_settings" "prod_all" { + rest_api_id = aws_api_gateway_rest_api.procurement.id + stage_name = aws_api_gateway_stage.prod.stage_name + method_path = "*/*" + + settings { + metrics_enabled = false + logging_level = "OFF" + data_trace_enabled = false + throttling_burst_limit = 100 + throttling_rate_limit = 50 + } +} + +resource "aws_api_gateway_domain_name" "procurement" { + domain_name = local.api_domain_name + regional_certificate_arn = data.aws_ssm_parameter.procurement_api_cert_arn.value + security_policy = "TLS_1_2" + + endpoint_configuration { + types = ["REGIONAL"] + } +} + +resource "aws_api_gateway_base_path_mapping" "procurement" { + api_id = aws_api_gateway_rest_api.procurement.id + stage_name = aws_api_gateway_stage.prod.stage_name + domain_name = aws_api_gateway_domain_name.procurement.domain_name +} diff --git a/terraform/api_alarms.tf b/terraform/api_alarms.tf new file mode 100644 index 0000000..90eb56f --- /dev/null +++ b/terraform/api_alarms.tf @@ -0,0 +1,73 @@ +resource "aws_cloudwatch_metric_alarm" "procurement_api_errors" { + 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)" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Errors" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.procurement_api.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "procurement_api_throttles" { + alarm_name = "procurement-api-throttles" + alarm_description = "procurement-api Lambda throttled" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Throttles" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.procurement_api.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "procurement_api_duration" { + alarm_name = "procurement-api-duration" + alarm_description = "procurement-api p99 duration >= 22.5s (75% of the 30s timeout; scans degrading toward timeout)" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 3 + datapoints_to_alarm = 2 + metric_name = "Duration" + namespace = "AWS/Lambda" + period = 300 + extended_statistic = "p99" + threshold = 22500 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.procurement_api.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "procurement_api_5xx" { + alarm_name = "procurement-api-5xx" + alarm_description = "procurement-api gateway 5XX responses" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "5XXError" + namespace = "AWS/ApiGateway" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + ApiName = "procurement-api" + Stage = local.api_stage_name + } +} diff --git a/terraform/artifacts.tf b/terraform/artifacts.tf new file mode 100644 index 0000000..a1919bd --- /dev/null +++ b/terraform/artifacts.tf @@ -0,0 +1,55 @@ +# HCP plan and apply run on separate workers. archive_file paths from plan are +# not on the apply worker, so zip bytes are carried in the plan via +# content_base64 and uploaded to S3 at apply time for Lambda to consume. +# +# Package build runs during plan via external data (local-exec provisioners +# only run on apply; archive_file needs build/ present at plan time). + +data "external" "package_build" { + program = ["bash", "${path.module}/build_packages_external.sh"] +} + +resource "aws_s3_bucket" "artifacts" { + bucket = "procurement-ingest-artifacts-${local.account_id}" +} + +resource "aws_s3_bucket_public_access_block" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +locals { + lambda_packages = { + po_email_processor = "po-email-processor.zip" + po_web_ui = "po-web-ui.zip" + po_site_extractor = "po-ingest-site-extractor.zip" + wo_email_processor = "workorder-email-processor.zip" + wo_web_ui = "workorder-web-ui.zip" + wo_shoc_emitter = "workorder-shoc-emitter.zip" + wo_shoc_hmac_rotator = "workorder-shoc-hmac-rotator.zip" + procurement_api = "procurement-api.zip" + } +} + +data "archive_file" "lambda" { + for_each = local.lambda_packages + + type = "zip" + source_dir = "${path.module}/build/${each.key}" + output_path = "${path.module}/build/${each.value}" + + depends_on = [data.external.package_build] +} + +resource "aws_s3_object" "lambda" { + for_each = local.lambda_packages + + bucket = aws_s3_bucket.artifacts.id + key = each.value + content_base64 = filebase64(data.archive_file.lambda[each.key].output_path) + source_hash = data.archive_file.lambda[each.key].output_base64sha256 +} diff --git a/terraform/build_packages.sh b/terraform/build_packages.sh new file mode 100755 index 0000000..73bcb18 --- /dev/null +++ b/terraform/build_packages.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Package Lambda zips for HCP plan/apply. Runs on the Terraform worker. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +BUILD="${ROOT}/build" +SRC="$(cd "${ROOT}/../lambdas" && pwd)" + +# Copy only regular files that resolve inside SRC (no symlink escape). +copy_src_file() { + local rel="$1" + local dest="$2" + local src_path="${SRC}/${rel}" + + if [[ -L "${src_path}" ]]; then + echo "error: refusing symlink source: ${src_path}" >&2 + exit 1 + fi + if [[ ! -f "${src_path}" ]]; then + echo "error: missing regular file: ${src_path}" >&2 + exit 1 + fi + + local resolved + resolved="$(cd "$(dirname "${src_path}")" && pwd)/$(basename "${src_path}")" + case "${resolved}" in + "${SRC}"/*) ;; + *) + echo "error: path escapes src tree: ${resolved}" >&2 + exit 1 + ;; + esac + + mkdir -p "$(dirname "${dest}")" + cp -P "${src_path}" "${dest}" +} + +copy_py_dir() { + local rel_dir="$1" + local dest_dir="$2" + local f + mkdir -p "${dest_dir}" + for f in "${SRC}/${rel_dir}"/*.py; do + [[ -f "${f}" ]] || continue + copy_src_file "${rel_dir}/$(basename "${f}")" "${dest_dir}/$(basename "${f}")" + done +} + +copy_shared_all() { + local dest_dir="$1" + local f + for f in "${SRC}/shared"/*.py; do + [[ -f "${f}" ]] || continue + copy_src_file "shared/$(basename "${f}")" "${dest_dir}/$(basename "${f}")" + done +} + +maybe_pip_install() { + local dest_dir="$1" + local req="${dest_dir}/requirements.txt" + if [[ ! -f "${req}" ]]; then + return 0 + fi + + local deps + deps="$(grep -Ev '^[[:space:]]*(#|$)' "${req}" || true)" + if [[ -z "${deps}" ]]; then + rm -f "${req}" + return 0 + fi + + # boto3-only pins are Dependabot anchors; runtime provides boto3. + if ! printf '%s\n' "${deps}" | grep -Eqv '^[[:space:]]*boto3([= ]|$)'; then + rm -f "${req}" + return 0 + fi + + python3 -m pip install \ + --quiet \ + --disable-pip-version-check \ + -r "${req}" \ + -t "${dest_dir}/" \ + --platform manylinux2014_aarch64 \ + --implementation cp \ + --python-version 3.12 \ + --only-binary=:all: \ + --upgrade + + rm -rf "${dest_dir}/boto3" "${dest_dir}/botocore" \ + "${dest_dir}/s3transfer" "${dest_dir}/jmespath" \ + "${dest_dir}/"*.dist-info 2>/dev/null || true + rm -f "${req}" +} + +rm -rf "${BUILD}" +mkdir -p \ + "${BUILD}/po_email_processor" \ + "${BUILD}/po_web_ui" \ + "${BUILD}/po_site_extractor" \ + "${BUILD}/wo_email_processor" \ + "${BUILD}/wo_web_ui" \ + "${BUILD}/wo_shoc_emitter" \ + "${BUILD}/wo_shoc_hmac_rotator" \ + "${BUILD}/procurement_api" + +copy_py_dir "po/email_processor" "${BUILD}/po_email_processor" +copy_shared_all "${BUILD}/po_email_processor" +if [[ -f "${SRC}/po/email_processor/requirements.txt" ]]; then + copy_src_file "po/email_processor/requirements.txt" "${BUILD}/po_email_processor/requirements.txt" +fi +maybe_pip_install "${BUILD}/po_email_processor" + +copy_py_dir "po/web_ui" "${BUILD}/po_web_ui" +copy_src_file "shared/web_ui_auth.py" "${BUILD}/po_web_ui/web_ui_auth.py" +if [[ -f "${SRC}/po/web_ui/requirements.txt" ]]; then + copy_src_file "po/web_ui/requirements.txt" "${BUILD}/po_web_ui/requirements.txt" +fi +maybe_pip_install "${BUILD}/po_web_ui" + +copy_py_dir "po/site_extractor" "${BUILD}/po_site_extractor" +if [[ -f "${SRC}/po/site_extractor/requirements.txt" ]]; then + copy_src_file "po/site_extractor/requirements.txt" "${BUILD}/po_site_extractor/requirements.txt" +fi +maybe_pip_install "${BUILD}/po_site_extractor" + +copy_py_dir "wo/email_processor" "${BUILD}/wo_email_processor" +copy_shared_all "${BUILD}/wo_email_processor" +if [[ -f "${SRC}/wo/email_processor/requirements.txt" ]]; then + copy_src_file "wo/email_processor/requirements.txt" "${BUILD}/wo_email_processor/requirements.txt" +fi +maybe_pip_install "${BUILD}/wo_email_processor" + +copy_py_dir "wo/web_ui" "${BUILD}/wo_web_ui" +copy_src_file "shared/web_ui_auth.py" "${BUILD}/wo_web_ui/web_ui_auth.py" +if [[ -f "${SRC}/wo/web_ui/requirements.txt" ]]; then + copy_src_file "wo/web_ui/requirements.txt" "${BUILD}/wo_web_ui/requirements.txt" +fi +maybe_pip_install "${BUILD}/wo_web_ui" + +copy_py_dir "wo/shoc_emitter" "${BUILD}/wo_shoc_emitter" + +copy_py_dir "wo/shoc_hmac_rotator" "${BUILD}/wo_shoc_hmac_rotator" + +copy_py_dir "api" "${BUILD}/procurement_api" +for f in openapi.json docs.html redoc.standalone.js fonts.css; do + copy_src_file "api/${f}" "${BUILD}/procurement_api/${f}" +done +copy_src_file "shared/web_ui_auth.py" "${BUILD}/procurement_api/web_ui_auth.py" +if [[ -f "${SRC}/api/requirements.txt" ]]; then + copy_src_file "api/requirements.txt" "${BUILD}/procurement_api/requirements.txt" +fi +maybe_pip_install "${BUILD}/procurement_api" diff --git a/terraform/build_packages_external.sh b/terraform/build_packages_external.sh new file mode 100755 index 0000000..26137ca --- /dev/null +++ b/terraform/build_packages_external.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Terraform external data source entrypoint. Stdout must be JSON only. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +"${ROOT}/build_packages.sh" >&2 + +hash="$( + { + find -P \ + "${ROOT}/build/po_email_processor" \ + "${ROOT}/build/po_web_ui" \ + "${ROOT}/build/po_site_extractor" \ + "${ROOT}/build/wo_email_processor" \ + "${ROOT}/build/wo_web_ui" \ + "${ROOT}/build/wo_shoc_emitter" \ + "${ROOT}/build/wo_shoc_hmac_rotator" \ + "${ROOT}/build/procurement_api" \ + -type f -print0 2>/dev/null \ + | sort -z \ + | xargs -0 sha256sum + } | sha256sum | awk '{print $1}' +)" + +printf '{"status":"ok","hash":"%s"}\n' "${hash}" diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000..9bd7b79 --- /dev/null +++ b/terraform/iam.tf @@ -0,0 +1,658 @@ +data "aws_iam_policy_document" "lambda_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +locals { + bedrock_invoke_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + ] + Resource = [ + "arn:aws:bedrock:${var.aws_region}:${local.account_id}:inference-profile/${local.bedrock_model_id}", + "arn:aws:bedrock:${var.aws_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", + ] + } + ] + }) + + dynamodb_cmk_rw_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt*", + ] + Resource = [data.aws_ssm_parameter.dynamodb_cmk_arn.value] + Condition = { + StringEquals = { + "kms:ViaService" = "dynamodb.${var.aws_region}.amazonaws.com" + } + } + } + ] + }) + + dynamodb_cmk_read_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:DescribeKey", + ] + Resource = [data.aws_ssm_parameter.dynamodb_cmk_arn.value] + Condition = { + StringEquals = { + "kms:ViaService" = "dynamodb.${var.aws_region}.amazonaws.com" + } + } + } + ] + }) + + web_ui_auth_secret_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = [var.web_ui_auth_token_secret_arn] + } + ] + }) +} + +# --------------------------------------------------------------------------- +# po-email-processor +# --------------------------------------------------------------------------- + +resource "aws_iam_role" "po_email_processor" { + name = "po-email-processor" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "po_email_processor_basic" { + role = aws_iam_role.po_email_processor.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "po_email_processor_s3" { + name = "s3-read-emails" + role = aws_iam_role.po_email_processor.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:ListBucket", + ] + Resource = [ + aws_s3_bucket.po_emails.arn, + "${aws_s3_bucket.po_emails.arn}/*", + ] + } + ] + }) +} + +resource "aws_iam_role_policy" "po_email_processor_ddb" { + name = "dynamodb-rw" + role = aws_iam_role.po_email_processor.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:ConditionCheckItem", + "dynamodb:DeleteItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + ] + Resource = [ + aws_dynamodb_table.purchase_orders.arn, + "${aws_dynamodb_table.purchase_orders.arn}/index/*", + ] + } + ] + }) +} + +resource "aws_iam_role_policy" "po_email_processor_kms" { + name = "dynamodb-cmk" + role = aws_iam_role.po_email_processor.id + policy = local.dynamodb_cmk_rw_policy_json +} + +resource "aws_iam_role_policy" "po_email_processor_bedrock" { + name = "bedrock-invoke" + role = aws_iam_role.po_email_processor.id + policy = local.bedrock_invoke_policy_json +} + +resource "aws_iam_role_policy" "po_email_processor_dlq" { + name = "sqs-dlq-send" + role = aws_iam_role.po_email_processor.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["sqs:SendMessage"] + Resource = [aws_sqs_queue.po_email_processor_dlq.arn] + } + ] + }) +} + +# --------------------------------------------------------------------------- +# po-web-ui +# --------------------------------------------------------------------------- + +resource "aws_iam_role" "po_web_ui" { + name = "po-web-ui" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "po_web_ui_basic" { + role = aws_iam_role.po_web_ui.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "po_web_ui_ddb" { + name = "dynamodb-read" + role = aws_iam_role.po_web_ui.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "dynamodb:BatchGetItem", + "dynamodb:ConditionCheckItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + ] + Resource = [ + aws_dynamodb_table.purchase_orders.arn, + "${aws_dynamodb_table.purchase_orders.arn}/index/*", + ] + } + ] + }) +} + +resource "aws_iam_role_policy" "po_web_ui_kms" { + name = "dynamodb-cmk" + role = aws_iam_role.po_web_ui.id + policy = local.dynamodb_cmk_read_policy_json +} + +resource "aws_iam_role_policy" "po_web_ui_secrets" { + name = "secretsmanager-get" + role = aws_iam_role.po_web_ui.id + policy = local.web_ui_auth_secret_policy_json +} + +# --------------------------------------------------------------------------- +# po-site-extractor +# --------------------------------------------------------------------------- + +resource "aws_iam_role" "po_site_extractor" { + name = "po-site-extractor" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "po_site_extractor_basic" { + role = aws_iam_role.po_site_extractor.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "po_site_extractor_ddb" { + name = "dynamodb-rw-sites" + role = aws_iam_role.po_site_extractor.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:ConditionCheckItem", + "dynamodb:DeleteItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + ] + Resource = [ + aws_dynamodb_table.verified_sites.arn, + "${aws_dynamodb_table.verified_sites.arn}/index/*", + aws_dynamodb_table.pending_site_review.arn, + "${aws_dynamodb_table.pending_site_review.arn}/index/*", + ] + }, + { + Effect = "Allow" + Action = [ + "dynamodb:DescribeStream", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:ListStreams", + ] + Resource = [aws_dynamodb_table.purchase_orders.stream_arn] + }, + { + Effect = "Allow" + Action = ["dynamodb:DescribeTable"] + Resource = [aws_dynamodb_table.purchase_orders.arn] + } + ] + }) +} + +resource "aws_iam_role_policy" "po_site_extractor_kms" { + name = "dynamodb-cmk" + role = aws_iam_role.po_site_extractor.id + policy = local.dynamodb_cmk_read_policy_json +} + +# --------------------------------------------------------------------------- +# workorder-email-processor +# --------------------------------------------------------------------------- + +resource "aws_iam_role" "wo_email_processor" { + name = "workorder-email-processor" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "wo_email_processor_basic" { + role = aws_iam_role.wo_email_processor.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "wo_email_processor_s3" { + name = "s3-read-emails" + role = aws_iam_role.wo_email_processor.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:ListBucket", + ] + Resource = [ + aws_s3_bucket.wo_emails.arn, + "${aws_s3_bucket.wo_emails.arn}/*", + ] + } + ] + }) +} + +resource "aws_iam_role_policy" "wo_email_processor_ddb" { + name = "dynamodb-rw" + role = aws_iam_role.wo_email_processor.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:ConditionCheckItem", + "dynamodb:DeleteItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + ] + Resource = [ + aws_dynamodb_table.work_orders.arn, + "${aws_dynamodb_table.work_orders.arn}/index/*", + aws_dynamodb_table.work_order_comments.arn, + "${aws_dynamodb_table.work_order_comments.arn}/index/*", + ] + } + ] + }) +} + +resource "aws_iam_role_policy" "wo_email_processor_bedrock" { + name = "bedrock-invoke" + role = aws_iam_role.wo_email_processor.id + policy = local.bedrock_invoke_policy_json +} + +resource "aws_iam_role_policy" "wo_email_processor_dlq" { + name = "sqs-dlq-send" + role = aws_iam_role.wo_email_processor.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["sqs:SendMessage"] + Resource = [aws_sqs_queue.wo_email_processor_dlq.arn] + } + ] + }) +} + +# --------------------------------------------------------------------------- +# workorder-web-ui +# --------------------------------------------------------------------------- + +resource "aws_iam_role" "wo_web_ui" { + name = "workorder-web-ui" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "wo_web_ui_basic" { + role = aws_iam_role.wo_web_ui.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "wo_web_ui_ddb" { + name = "dynamodb-read" + role = aws_iam_role.wo_web_ui.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "dynamodb:BatchGetItem", + "dynamodb:ConditionCheckItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + ] + Resource = [ + aws_dynamodb_table.work_orders.arn, + "${aws_dynamodb_table.work_orders.arn}/index/*", + aws_dynamodb_table.work_order_comments.arn, + "${aws_dynamodb_table.work_order_comments.arn}/index/*", + ] + } + ] + }) +} + +resource "aws_iam_role_policy" "wo_web_ui_secrets" { + name = "secretsmanager-get" + role = aws_iam_role.wo_web_ui.id + policy = local.web_ui_auth_secret_policy_json +} + +# --------------------------------------------------------------------------- +# workorder-shoc-emitter +# --------------------------------------------------------------------------- + +resource "aws_iam_role" "wo_shoc_emitter" { + name = "workorder-shoc-emitter" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "wo_shoc_emitter_basic" { + role = aws_iam_role.wo_shoc_emitter.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "wo_shoc_emitter_streams" { + name = "dynamodb-stream-read" + role = aws_iam_role.wo_shoc_emitter.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "dynamodb:DescribeStream", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:ListStreams", + ] + Resource = [ + aws_dynamodb_table.work_orders.stream_arn, + aws_dynamodb_table.work_order_comments.stream_arn, + ] + }, + { + Effect = "Allow" + Action = ["dynamodb:DescribeTable"] + Resource = [ + aws_dynamodb_table.work_orders.arn, + aws_dynamodb_table.work_order_comments.arn, + ] + } + ] + }) +} + +resource "aws_iam_role_policy" "wo_shoc_emitter_secrets" { + name = "secretsmanager-get" + role = aws_iam_role.wo_shoc_emitter.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"] + Resource = [var.shoc_hmac_secret_arn] + } + ] + }) +} + +resource "aws_iam_role_policy" "wo_shoc_emitter_kms" { + name = "shoc-hmac-kms" + role = aws_iam_role.wo_shoc_emitter.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = [aws_kms_key.shoc_webhook.arn] + Condition = { + StringEquals = { + "kms:ViaService" = "secretsmanager.${var.aws_region}.amazonaws.com" + } + } + } + ] + }) +} + +resource "aws_iam_role_policy" "wo_shoc_emitter_sqs" { + name = "sqs-send" + role = aws_iam_role.wo_shoc_emitter.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["sqs:SendMessage"] + Resource = [ + aws_sqs_queue.shoc_emitter_rejected.arn, + aws_sqs_queue.shoc_emitter_failures.arn, + ] + } + ] + }) +} + +# --------------------------------------------------------------------------- +# workorder-shoc-hmac-rotator +# --------------------------------------------------------------------------- + +resource "aws_iam_role" "wo_shoc_hmac_rotator" { + name = "workorder-shoc-hmac-rotator" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "wo_shoc_hmac_rotator_basic" { + role = aws_iam_role.wo_shoc_hmac_rotator.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "wo_shoc_hmac_rotator_secrets" { + name = "secretsmanager-rotate" + role = aws_iam_role.wo_shoc_hmac_rotator.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:UpdateSecretVersionStage", + ] + Resource = [var.shoc_hmac_secret_arn] + } + ] + }) +} + +resource "aws_iam_role_policy" "wo_shoc_hmac_rotator_kms" { + name = "shoc-hmac-kms" + role = aws_iam_role.wo_shoc_hmac_rotator.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt*", + ] + Resource = [aws_kms_key.shoc_webhook.arn] + Condition = { + StringEquals = { + "kms:ViaService" = "secretsmanager.${var.aws_region}.amazonaws.com" + } + } + } + ] + }) +} + +# --------------------------------------------------------------------------- +# procurement-api +# --------------------------------------------------------------------------- + +resource "aws_iam_role" "procurement_api" { + name = "procurement-api" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = local.boundary_arn +} + +resource "aws_iam_role_policy_attachment" "procurement_api_basic" { + role = aws_iam_role.procurement_api.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "procurement_api_ddb" { + name = "dynamodb-read" + role = aws_iam_role.procurement_api.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "dynamodb:BatchGetItem", + "dynamodb:ConditionCheckItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + ] + Resource = [ + aws_dynamodb_table.purchase_orders.arn, + "${aws_dynamodb_table.purchase_orders.arn}/index/*", + aws_dynamodb_table.verified_sites.arn, + "${aws_dynamodb_table.verified_sites.arn}/index/*", + aws_dynamodb_table.work_orders.arn, + "${aws_dynamodb_table.work_orders.arn}/index/*", + aws_dynamodb_table.work_order_comments.arn, + "${aws_dynamodb_table.work_order_comments.arn}/index/*", + ] + } + ] + }) +} + +resource "aws_iam_role_policy" "procurement_api_kms" { + name = "dynamodb-cmk" + role = aws_iam_role.procurement_api.id + policy = local.dynamodb_cmk_read_policy_json +} + +resource "aws_iam_role_policy" "procurement_api_secrets" { + name = "secretsmanager-get" + role = aws_iam_role.procurement_api.id + policy = local.web_ui_auth_secret_policy_json +} diff --git a/terraform/imports.tf b/terraform/imports.tf new file mode 100644 index 0000000..7351759 --- /dev/null +++ b/terraform/imports.tf @@ -0,0 +1,595 @@ +# Import map: docs/plat-86/import-map.md (seahaven-prod, 2026-08-06). +# IAM roles are NOT imported — new /tf-managed/ roles replace CDK roles on cutover. + +# --- DynamoDB --- +import { + to = aws_dynamodb_table.purchase_orders + id = "purchase-orders" +} + +import { + to = aws_dynamodb_table.verified_sites + id = "verified-sites" +} + +import { + to = aws_dynamodb_table.pending_site_review + id = "pending-site-review" +} + +import { + to = aws_dynamodb_table.work_orders + id = "WorkOrders" +} + +import { + to = aws_dynamodb_table.work_order_comments + id = "WorkOrderComments" +} + +# --- S3 email buckets --- +import { + to = aws_s3_bucket.po_emails + id = "po-ingest-emails-011934824531" +} + +import { + to = aws_s3_bucket.wo_emails + id = "workorder-ingest-emails-011934824531" +} + +# --- SQS --- +import { + to = aws_sqs_queue.po_email_processor_dlq + id = "https://sqs.us-east-1.amazonaws.com/011934824531/po-ingest-EmailProcessorDlqA753DED5-Mn33HvhsDPEu" +} + +import { + to = aws_sqs_queue.wo_email_processor_dlq + id = "https://sqs.us-east-1.amazonaws.com/011934824531/WorkorderIngestStack-EmailProcessorDlqA753DED5-qCTHrsoEucas" +} + +import { + to = aws_sqs_queue.shoc_emitter_failures + id = "https://sqs.us-east-1.amazonaws.com/011934824531/workorder-shoc-emitter-failures" +} + +import { + to = aws_sqs_queue.shoc_emitter_rejected + id = "https://sqs.us-east-1.amazonaws.com/011934824531/workorder-shoc-emitter-rejected" +} + +# --- Lambdas --- +import { + to = aws_lambda_function.po_email_processor + id = "po-email-processor" +} + +import { + to = aws_lambda_function.po_web_ui + id = "po-web-ui" +} + +import { + to = aws_lambda_function.po_site_extractor + id = "po-ingest-site-extractor" +} + +import { + to = aws_lambda_function.wo_email_processor + id = "workorder-email-processor" +} + +import { + to = aws_lambda_function.wo_web_ui + id = "workorder-web-ui" +} + +import { + to = aws_lambda_function.wo_shoc_emitter + id = "workorder-shoc-emitter" +} + +import { + to = aws_lambda_function.wo_shoc_hmac_rotator + id = "workorder-shoc-hmac-rotator" +} + +import { + to = aws_lambda_function.procurement_api + id = "procurement-api" +} + +# --- Event source mappings --- +import { + to = aws_lambda_event_source_mapping.po_site_extractor + id = "64fbee1f-d9c3-4cfd-aced-19b9d29940f8" +} + +import { + to = aws_lambda_event_source_mapping.wo_shoc_emitter_work_orders + id = "cc149f4d-5375-4820-8fb8-c514accd3a85" +} + +import { + to = aws_lambda_event_source_mapping.wo_shoc_emitter_comments + id = "da37d4a0-3086-4929-9ace-6eca5c20cd07" +} + +# --- Log groups --- +import { + to = aws_cloudwatch_log_group.po_email_processor + id = "/aws/lambda/po-email-processor" +} + +import { + to = aws_cloudwatch_log_group.po_web_ui + id = "/aws/lambda/po-web-ui" +} + +import { + to = aws_cloudwatch_log_group.po_site_extractor + id = "/aws/lambda/po-ingest-site-extractor" +} + +import { + to = aws_cloudwatch_log_group.wo_email_processor + id = "/aws/lambda/workorder-email-processor" +} + +import { + to = aws_cloudwatch_log_group.wo_web_ui + id = "/aws/lambda/workorder-web-ui" +} + +import { + to = aws_cloudwatch_log_group.wo_shoc_emitter + id = "/aws/lambda/workorder-shoc-emitter" +} + +import { + to = aws_cloudwatch_log_group.wo_shoc_hmac_rotator + id = "/aws/lambda/workorder-shoc-hmac-rotator" +} + +import { + to = aws_cloudwatch_log_group.procurement_api + id = "/aws/lambda/procurement-api" +} + +# --- SES receipt rules --- +import { + to = aws_ses_receipt_rule.po_email + id = "INBOUND_MAIL:ExistingRuleSetPoEmailRuleAC8E9C87-qwGDj9lBoL1G" +} + +import { + to = aws_ses_receipt_rule.wo_email + id = "INBOUND_MAIL:ExistingRuleSetWorkorderEmailRuleEA29F845-PKtaDBvIg61a" +} + +# --- SHOC KMS + secret --- +import { + to = aws_kms_key.shoc_webhook + id = "d10fd1f0-a61a-4405-8568-85e9fd11ba18" +} + +import { + to = aws_kms_alias.shoc_webhook + id = "alias/workorder-ingest-shoc-webhook-kms" +} + +import { + to = aws_secretsmanager_secret.shoc_webhook_hmac + id = "arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB" +} + +import { + to = aws_secretsmanager_secret_rotation.shoc_webhook_hmac + id = "arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB" +} + +# --- Metric filters --- +import { + to = aws_cloudwatch_log_metric_filter.po_sender_auth_rejected + id = "/aws/lambda/po-email-processor:EmailProcessorSenderAuthRejectedFilterC0DAEDFE-3K6wBb003iDv" +} + +import { + to = aws_cloudwatch_log_metric_filter.wo_sender_auth_rejected + id = "/aws/lambda/workorder-email-processor:EmailProcessorSenderAuthRejectedFilterC0DAEDFE-Wkbow1VNDeDM" +} + +# --- API Gateway --- +import { + to = aws_api_gateway_rest_api.procurement + id = "mvul1efda2" +} + +import { + to = aws_api_gateway_domain_name.procurement + id = "procurement-api.seahaven.com" +} + +import { + to = aws_api_gateway_stage.prod + id = "mvul1efda2/prod" +} + +import { + to = aws_api_gateway_base_path_mapping.procurement + id = "procurement-api.seahaven.com/" +} + +import { + to = aws_api_gateway_deployment.procurement + id = "mvul1efda2/uax9sq" +} + +import { + to = aws_api_gateway_resource.work_orders + id = "mvul1efda2/h5iu4f" +} + +import { + to = aws_api_gateway_resource.work_order_id + id = "mvul1efda2/4uk6uh" +} + +import { + to = aws_api_gateway_resource.work_order_comments + id = "mvul1efda2/gfx0te" +} + +import { + to = aws_api_gateway_resource.purchase_orders + id = "mvul1efda2/pfn6qm" +} + +import { + to = aws_api_gateway_resource.po_number + id = "mvul1efda2/nclnn3" +} + +import { + to = aws_api_gateway_resource.verified_sites + id = "mvul1efda2/vyst7e" +} + +import { + to = aws_api_gateway_resource.site_code + id = "mvul1efda2/rmaawy" +} + +import { + to = aws_api_gateway_resource.docs + id = "mvul1efda2/k4vl85" +} + +import { + to = aws_api_gateway_resource.openapi_json + id = "mvul1efda2/xos715" +} + +import { + to = aws_api_gateway_method.iam["work_orders_get"] + id = "mvul1efda2/h5iu4f/GET" +} + +import { + to = aws_api_gateway_method.iam["work_order_id_get"] + id = "mvul1efda2/4uk6uh/GET" +} + +import { + to = aws_api_gateway_method.iam["work_order_id_patch"] + id = "mvul1efda2/4uk6uh/PATCH" +} + +import { + to = aws_api_gateway_method.iam["work_order_comments_get"] + id = "mvul1efda2/gfx0te/GET" +} + +import { + to = aws_api_gateway_method.iam["work_order_comments_post"] + id = "mvul1efda2/gfx0te/POST" +} + +import { + to = aws_api_gateway_method.iam["purchase_orders_get"] + id = "mvul1efda2/pfn6qm/GET" +} + +import { + to = aws_api_gateway_method.iam["po_number_get"] + id = "mvul1efda2/nclnn3/GET" +} + +import { + to = aws_api_gateway_method.iam["verified_sites_get"] + id = "mvul1efda2/vyst7e/GET" +} + +import { + to = aws_api_gateway_method.iam["site_code_get"] + id = "mvul1efda2/rmaawy/GET" +} + +import { + to = aws_api_gateway_method.none["docs_get"] + id = "mvul1efda2/k4vl85/GET" +} + +import { + to = aws_api_gateway_method.none["openapi_json_get"] + id = "mvul1efda2/xos715/GET" +} + +import { + to = aws_api_gateway_integration.iam["work_orders_get"] + id = "mvul1efda2/h5iu4f/GET" +} + +import { + to = aws_api_gateway_integration.iam["work_order_id_get"] + id = "mvul1efda2/4uk6uh/GET" +} + +import { + to = aws_api_gateway_integration.iam["work_order_id_patch"] + id = "mvul1efda2/4uk6uh/PATCH" +} + +import { + to = aws_api_gateway_integration.iam["work_order_comments_get"] + id = "mvul1efda2/gfx0te/GET" +} + +import { + to = aws_api_gateway_integration.iam["work_order_comments_post"] + id = "mvul1efda2/gfx0te/POST" +} + +import { + to = aws_api_gateway_integration.iam["purchase_orders_get"] + id = "mvul1efda2/pfn6qm/GET" +} + +import { + to = aws_api_gateway_integration.iam["po_number_get"] + id = "mvul1efda2/nclnn3/GET" +} + +import { + to = aws_api_gateway_integration.iam["verified_sites_get"] + id = "mvul1efda2/vyst7e/GET" +} + +import { + to = aws_api_gateway_integration.iam["site_code_get"] + id = "mvul1efda2/rmaawy/GET" +} + +import { + to = aws_api_gateway_integration.none["docs_get"] + id = "mvul1efda2/k4vl85/GET" +} + +import { + to = aws_api_gateway_integration.none["openapi_json_get"] + id = "mvul1efda2/xos715/GET" +} + +# --- CloudWatch alarms (PO) --- +import { + to = aws_cloudwatch_metric_alarm.po_email_processor_errors + id = "po-email-processor-errors" +} + +import { + to = aws_cloudwatch_metric_alarm.po_email_processor_throttles + id = "po-email-processor-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.po_email_processor_dlq + id = "po-email-processor-dlq-messages" +} + +import { + to = aws_cloudwatch_metric_alarm.po_email_processor_duration + id = "po-email-processor-duration" +} + +import { + to = aws_cloudwatch_metric_alarm.po_email_processor_sender_auth_rejected + id = "po-email-processor-sender-auth-rejected" +} + +import { + to = aws_cloudwatch_metric_alarm.po_email_processor_fallback_rate + id = "po-email-processor-template-fallback-rate" +} + +import { + to = aws_cloudwatch_metric_alarm.po_email_processor_ai_fallback_rejected + id = "po-email-processor-ai-fallback-rejected" +} + +import { + to = aws_cloudwatch_metric_alarm.po_web_ui_throttles + id = "po-web-ui-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.po_web_ui_duration + id = "po-web-ui-duration" +} + +import { + to = aws_cloudwatch_metric_alarm.po_site_extractor_errors + id = "po-ingest-site-extractor-errors" +} + +import { + to = aws_cloudwatch_metric_alarm.po_site_extractor_throttles + id = "po-ingest-site-extractor-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.po_site_extractor_duration + id = "po-ingest-site-extractor-duration" +} + +import { + to = aws_cloudwatch_metric_alarm.po_ddb_throttles["purchase-orders"] + id = "purchase-orders-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.po_ddb_system_errors["purchase-orders"] + id = "purchase-orders-system-errors" +} + +import { + to = aws_cloudwatch_metric_alarm.po_ddb_throttles["verified-sites"] + id = "verified-sites-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.po_ddb_system_errors["verified-sites"] + id = "verified-sites-system-errors" +} + +import { + to = aws_cloudwatch_metric_alarm.po_ddb_throttles["pending-site-review"] + id = "pending-site-review-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.po_ddb_system_errors["pending-site-review"] + id = "pending-site-review-system-errors" +} + +# --- CloudWatch alarms (WO) --- +import { + to = aws_cloudwatch_metric_alarm.wo_email_processor_errors + id = "workorder-email-processor-errors" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_email_processor_throttles + id = "workorder-email-processor-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_email_processor_dlq + id = "workorder-email-processor-dlq-messages" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_email_processor_duration + id = "workorder-email-processor-duration" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_email_processor_sender_auth_rejected + id = "workorder-email-processor-sender-auth-rejected" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_email_processor_fallback_rate + id = "workorder-email-processor-template-fallback-rate" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_email_processor_ai_fallback_rejected + id = "workorder-email-processor-ai-fallback-rejected" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_shoc_hmac_rotator_errors + id = "workorder-shoc-hmac-rotator-errors" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_shoc_hmac_rotator_throttles + id = "workorder-shoc-hmac-rotator-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_shoc_hmac_rotator_duration + id = "workorder-shoc-hmac-rotator-duration" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_shoc_emitter_errors + id = "workorder-shoc-emitter-errors" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_shoc_emitter_throttles + id = "workorder-shoc-emitter-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_shoc_emitter_duration + id = "workorder-shoc-emitter-duration" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_shoc_emitter_iterator_age + id = "workorder-shoc-emitter-iterator-age" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_shoc_emitter_failures_messages + id = "workorder-shoc-emitter-failures-messages" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_shoc_emitter_rejected_messages + id = "workorder-shoc-emitter-rejected-messages" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_ddb_throttles["WorkOrders"] + id = "WorkOrders-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_ddb_system_errors["WorkOrders"] + id = "WorkOrders-system-errors" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_ddb_throttles["WorkOrderComments"] + id = "WorkOrderComments-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.wo_ddb_system_errors["WorkOrderComments"] + id = "WorkOrderComments-system-errors" +} + +# --- CloudWatch alarms (API) --- +import { + to = aws_cloudwatch_metric_alarm.procurement_api_errors + id = "procurement-api-errors" +} + +import { + to = aws_cloudwatch_metric_alarm.procurement_api_throttles + id = "procurement-api-throttles" +} + +import { + to = aws_cloudwatch_metric_alarm.procurement_api_duration + id = "procurement-api-duration" +} + +import { + to = aws_cloudwatch_metric_alarm.procurement_api_5xx + id = "procurement-api-5xx" +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..bc1ee7e --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,56 @@ +locals { + account_id = "011934824531" + boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary" + + bedrock_model_id = "us.anthropic.claude-haiku-4-5-20251001-v1:0" + + shoc_consumer_role_arn = var.shoc_consumer_role_arn + + po_email_bucket_name = "po-ingest-emails-${local.account_id}" + wo_email_bucket_name = "workorder-ingest-emails-${local.account_id}" + + po_email_processor_dlq_name = "po-ingest-EmailProcessorDlqA753DED5-Mn33HvhsDPEu" + wo_email_processor_dlq_name = "WorkorderIngestStack-EmailProcessorDlqA753DED5-qCTHrsoEucas" + + po_ses_rule_name = "ExistingRuleSetPoEmailRuleAC8E9C87-qwGDj9lBoL1G" + wo_ses_rule_name = "ExistingRuleSetWorkorderEmailRuleEA29F845-PKtaDBvIg61a" + + po_sender_auth_filter_name = "EmailProcessorSenderAuthRejectedFilterC0DAEDFE-3K6wBb003iDv" + wo_sender_auth_filter_name = "EmailProcessorSenderAuthRejectedFilterC0DAEDFE-Wkbow1VNDeDM" + + api_rest_api_id = "mvul1efda2" + api_root_id = "babtawlzki" + api_domain_name = "procurement-api.seahaven.com" + api_stage_name = "prod" + api_deployment_id = "uax9sq" + + ddb_alarm_operations = [ + "GetItem", + "BatchGetItem", + "Query", + "Scan", + "PutItem", + "UpdateItem", + "DeleteItem", + "BatchWriteItem", + ] + + ddb_throttle_expr = "getitem + batchgetitem + query + scan + putitem + updateitem + deleteitem + batchwriteitem" + + # Active SES receipt rule set in prod is INBOUND_MAIL (import-map OOB). + ses_rule_set_name = data.aws_ses_active_receipt_rule_set.inbound.rule_set_name +} + +data "aws_sns_topic" "site_alerts" { + name = "site-alerts" +} + +data "aws_ssm_parameter" "dynamodb_cmk_arn" { + name = "/seahaven/dynamodb/cmk-arn" +} + +data "aws_ssm_parameter" "procurement_api_cert_arn" { + name = "/procurement-api/custom-domain/certificate-arn" +} + +data "aws_ses_active_receipt_rule_set" "inbound" {} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..6d2cb0b --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,34 @@ +output "po_email_processor_arn" { + description = "ARN of po-email-processor" + value = aws_lambda_function.po_email_processor.arn +} + +output "wo_email_processor_arn" { + description = "ARN of workorder-email-processor" + value = aws_lambda_function.wo_email_processor.arn +} + +output "procurement_api_arn" { + description = "ARN of procurement-api Lambda" + value = aws_lambda_function.procurement_api.arn +} + +output "procurement_api_invoke_url" { + description = "API Gateway stage invoke URL" + value = aws_api_gateway_stage.prod.invoke_url +} + +output "procurement_api_custom_domain" { + description = "Custom domain (Route53 alias stays OOB in mgmt)" + value = "https://${aws_api_gateway_domain_name.procurement.domain_name}/" +} + +output "shoc_hmac_secret_arn" { + description = "SHOC webhook HMAC secret ARN" + value = aws_secretsmanager_secret.shoc_webhook_hmac.arn +} + +output "artifacts_bucket" { + description = "Lambda artifact bucket" + value = aws_s3_bucket.artifacts.bucket +} diff --git a/terraform/po_alarms.tf b/terraform/po_alarms.tf new file mode 100644 index 0000000..152cbb0 --- /dev/null +++ b/terraform/po_alarms.tf @@ -0,0 +1,270 @@ +resource "aws_cloudwatch_log_metric_filter" "po_sender_auth_rejected" { + name = local.po_sender_auth_filter_name + log_group_name = aws_cloudwatch_log_group.po_email_processor.name + pattern = "\"sender_auth_rejected\"" + + metric_transformation { + name = "po-email-processor-sender-auth-rejected" + namespace = "Seahaven/ProcurementIngest" + value = "1" + default_value = "0" + } +} + +resource "aws_cloudwatch_metric_alarm" "po_email_processor_errors" { + alarm_name = "po-email-processor-errors" + alarm_description = "po-email-processor async invocation errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Errors" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.po_email_processor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "po_email_processor_throttles" { + alarm_name = "po-email-processor-throttles" + alarm_description = "po-email-processor invocation throttles" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Throttles" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.po_email_processor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "po_email_processor_dlq" { + alarm_name = "po-email-processor-dlq-messages" + alarm_description = "po-email-processor DLQ has messages (dropped PO emails)" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "ApproximateNumberOfMessagesVisible" + namespace = "AWS/SQS" + period = 300 + statistic = "Maximum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + QueueName = aws_sqs_queue.po_email_processor_dlq.name + } +} + +resource "aws_cloudwatch_metric_alarm" "po_email_processor_duration" { + alarm_name = "po-email-processor-duration" + alarm_description = "po-email-processor p99 duration approaching the 60s timeout" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 3 + datapoints_to_alarm = 2 + metric_name = "Duration" + namespace = "AWS/Lambda" + period = 300 + extended_statistic = "p99" + threshold = 45000 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.po_email_processor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "po_email_processor_sender_auth_rejected" { + alarm_name = "po-email-processor-sender-auth-rejected" + alarm_description = "po-email-processor rejected inbound mail on sender authentication (possible allowlist/DKIM-domain drift silently dropping real mail)" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 6 + datapoints_to_alarm = 2 + metric_name = "po-email-processor-sender-auth-rejected" + namespace = "Seahaven/ProcurementIngest" + period = 300 + statistic = "Sum" + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] +} + +resource "aws_cloudwatch_metric_alarm" "po_email_processor_fallback_rate" { + 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" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 4 + datapoints_to_alarm = 2 + threshold = 20 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + metric_query { + id = "expr_1" + expression = "IF((FILL(fb,0)+FILL(tmpl,0))>=8, 100*FILL(fb,0)/(FILL(fb,0)+FILL(tmpl,0)), 0)" + label = "TemplateFallbackRatePct" + return_data = true + } + + metric_query { + id = "fb" + metric { + metric_name = "ParseOutcome" + namespace = "Seahaven/PoIngest" + period = 21600 + stat = "Sum" + dimensions = { + ParseMethod = "ai_fallback" + } + } + return_data = false + } + + metric_query { + id = "tmpl" + metric { + metric_name = "ParseOutcome" + namespace = "Seahaven/PoIngest" + period = 21600 + stat = "Sum" + dimensions = { + ParseMethod = "template" + } + } + return_data = false + } +} + +resource "aws_cloudwatch_metric_alarm" "po_email_processor_ai_fallback_rejected" { + 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)" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 4 + datapoints_to_alarm = 2 + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + metric_query { + id = "expr_1" + expression = "IF(FILL(rej,0)>=1, FILL(rej,0), 0)" + label = "AiFallbackRejectedCount" + return_data = true + } + + metric_query { + id = "rej" + metric { + metric_name = "ParseOutcome" + namespace = "Seahaven/PoIngest" + period = 21600 + stat = "Sum" + dimensions = { + ParseMethod = "ai_fallback_rejected" + } + } + return_data = false + } +} + +resource "aws_cloudwatch_metric_alarm" "po_web_ui_throttles" { + alarm_name = "po-web-ui-throttles" + alarm_description = "po-web-ui invocation throttles" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Throttles" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.po_web_ui.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "po_web_ui_duration" { + alarm_name = "po-web-ui-duration" + alarm_description = "po-web-ui p99 duration approaching the 60s timeout" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 3 + datapoints_to_alarm = 2 + metric_name = "Duration" + namespace = "AWS/Lambda" + period = 300 + extended_statistic = "p99" + threshold = 45000 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.po_web_ui.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "po_site_extractor_errors" { + alarm_name = "po-ingest-site-extractor-errors" + alarm_description = "po-ingest-site-extractor invocation errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Errors" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.po_site_extractor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "po_site_extractor_throttles" { + alarm_name = "po-ingest-site-extractor-throttles" + alarm_description = "po-ingest-site-extractor invocation throttles" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Throttles" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.po_site_extractor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "po_site_extractor_duration" { + alarm_name = "po-ingest-site-extractor-duration" + alarm_description = "po-ingest-site-extractor p99 duration approaching the 60s timeout" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 3 + datapoints_to_alarm = 2 + metric_name = "Duration" + namespace = "AWS/Lambda" + period = 300 + extended_statistic = "p99" + threshold = 45000 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.po_site_extractor.function_name + } +} diff --git a/terraform/po_ddb.tf b/terraform/po_ddb.tf new file mode 100644 index 0000000..0091316 --- /dev/null +++ b/terraform/po_ddb.tf @@ -0,0 +1,134 @@ +resource "aws_dynamodb_table" "purchase_orders" { + name = "purchase-orders" + billing_mode = "PAY_PER_REQUEST" + hash_key = "po_number" + + attribute { + name = "po_number" + type = "S" + } + + stream_enabled = true + stream_view_type = "NEW_IMAGE" + + server_side_encryption { + enabled = true + kms_key_arn = data.aws_ssm_parameter.dynamodb_cmk_arn.value + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_dynamodb_table" "verified_sites" { + name = "verified-sites" + billing_mode = "PAY_PER_REQUEST" + hash_key = "siteCode" + + attribute { + name = "siteCode" + type = "S" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_dynamodb_table" "pending_site_review" { + name = "pending-site-review" + billing_mode = "PAY_PER_REQUEST" + hash_key = "po_number" + + attribute { + name = "po_number" + type = "S" + } + + lifecycle { + prevent_destroy = true + } +} + +locals { + po_ddb_alarm_tables = { + "purchase-orders" = aws_dynamodb_table.purchase_orders.name + "verified-sites" = aws_dynamodb_table.verified_sites.name + "pending-site-review" = aws_dynamodb_table.pending_site_review.name + } +} + +resource "aws_cloudwatch_metric_alarm" "po_ddb_throttles" { + for_each = local.po_ddb_alarm_tables + + alarm_name = "${each.key}-throttles" + alarm_description = "${each.key} DynamoDB throttled requests" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + metric_query { + id = "expr_1" + expression = local.ddb_throttle_expr + label = "Sum of throttled requests across all operations" + return_data = true + } + + dynamic "metric_query" { + for_each = local.ddb_alarm_operations + content { + id = lower(metric_query.value) + metric { + metric_name = "ThrottledRequests" + namespace = "AWS/DynamoDB" + period = 300 + stat = "Sum" + dimensions = { + TableName = each.value + Operation = metric_query.value + } + } + return_data = false + } + } +} + +resource "aws_cloudwatch_metric_alarm" "po_ddb_system_errors" { + for_each = local.po_ddb_alarm_tables + + alarm_name = "${each.key}-system-errors" + alarm_description = "${each.key} DynamoDB server-side (5xx) errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + metric_query { + id = "expr_1" + expression = local.ddb_throttle_expr + label = "Sum of system errors across all operations" + return_data = true + } + + dynamic "metric_query" { + for_each = local.ddb_alarm_operations + content { + id = lower(metric_query.value) + metric { + metric_name = "SystemErrors" + namespace = "AWS/DynamoDB" + period = 300 + stat = "Sum" + dimensions = { + TableName = each.value + Operation = metric_query.value + } + } + return_data = false + } + } +} diff --git a/terraform/po_lambda.tf b/terraform/po_lambda.tf new file mode 100644 index 0000000..ba9de54 --- /dev/null +++ b/terraform/po_lambda.tf @@ -0,0 +1,134 @@ +resource "aws_cloudwatch_log_group" "po_email_processor" { + name = "/aws/lambda/po-email-processor" + retention_in_days = 60 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_cloudwatch_log_group" "po_web_ui" { + name = "/aws/lambda/po-web-ui" + retention_in_days = 60 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_cloudwatch_log_group" "po_site_extractor" { + name = "/aws/lambda/po-ingest-site-extractor" + retention_in_days = 60 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_lambda_function" "po_email_processor" { + function_name = "po-email-processor" + role = aws_iam_role.po_email_processor.arn + handler = "handler.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 256 + timeout = 60 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.lambda["po_email_processor"].key + source_code_hash = data.archive_file.lambda["po_email_processor"].output_base64sha256 + + dead_letter_config { + target_arn = aws_sqs_queue.po_email_processor_dlq.arn + } + + environment { + variables = { + PO_TABLE = aws_dynamodb_table.purchase_orders.name + ALLOWED_DKIM_DOMAINS = "amazon.coupahost.com" + BEDROCK_MODEL_ID = local.bedrock_model_id + } + } + + depends_on = [ + aws_s3_object.lambda, + aws_cloudwatch_log_group.po_email_processor, + aws_iam_role_policy_attachment.po_email_processor_basic, + aws_iam_role_policy.po_email_processor_s3, + aws_iam_role_policy.po_email_processor_ddb, + aws_iam_role_policy.po_email_processor_kms, + aws_iam_role_policy.po_email_processor_bedrock, + aws_iam_role_policy.po_email_processor_dlq, + ] +} + +resource "aws_lambda_function" "po_web_ui" { + function_name = "po-web-ui" + role = aws_iam_role.po_web_ui.arn + handler = "handler.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 256 + timeout = 60 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.lambda["po_web_ui"].key + source_code_hash = data.archive_file.lambda["po_web_ui"].output_base64sha256 + + environment { + variables = { + PO_TABLE = aws_dynamodb_table.purchase_orders.name + WEB_UI_AUTH_TOKEN_SECRET_ARN = var.web_ui_auth_token_secret_arn + } + } + + depends_on = [ + aws_s3_object.lambda, + aws_cloudwatch_log_group.po_web_ui, + aws_iam_role_policy_attachment.po_web_ui_basic, + aws_iam_role_policy.po_web_ui_ddb, + aws_iam_role_policy.po_web_ui_kms, + aws_iam_role_policy.po_web_ui_secrets, + ] +} + +resource "aws_lambda_function" "po_site_extractor" { + function_name = "po-ingest-site-extractor" + role = aws_iam_role.po_site_extractor.arn + handler = "handler.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 256 + timeout = 60 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.lambda["po_site_extractor"].key + source_code_hash = data.archive_file.lambda["po_site_extractor"].output_base64sha256 + + environment { + variables = { + VERIFIED_SITES_TABLE = aws_dynamodb_table.verified_sites.name + PENDING_REVIEW_TABLE = aws_dynamodb_table.pending_site_review.name + } + } + + depends_on = [ + aws_s3_object.lambda, + aws_cloudwatch_log_group.po_site_extractor, + aws_iam_role_policy_attachment.po_site_extractor_basic, + aws_iam_role_policy.po_site_extractor_ddb, + aws_iam_role_policy.po_site_extractor_kms, + ] +} + +resource "aws_lambda_event_source_mapping" "po_site_extractor" { + event_source_arn = aws_dynamodb_table.purchase_orders.stream_arn + function_name = aws_lambda_function.po_site_extractor.arn + starting_position = "TRIM_HORIZON" + batch_size = 10 + maximum_batching_window_in_seconds = 30 + bisect_batch_on_function_error = true + maximum_retry_attempts = 3 + parallelization_factor = 1 + enabled = true +} diff --git a/terraform/po_s3.tf b/terraform/po_s3.tf new file mode 100644 index 0000000..cfd6b61 --- /dev/null +++ b/terraform/po_s3.tf @@ -0,0 +1,96 @@ +resource "aws_s3_bucket" "po_emails" { + bucket = local.po_email_bucket_name + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "po_emails" { + bucket = aws_s3_bucket.po_emails.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "po_emails" { + bucket = aws_s3_bucket.po_emails.id + + rule { + id = "expire-90-days" + status = "Enabled" + + filter {} + + expiration { + days = 90 + } + } +} + +resource "aws_s3_bucket_policy" "po_emails" { + bucket = aws_s3_bucket.po_emails.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { Service = "ses.amazonaws.com" } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.po_emails.arn}/inbound/*" + Condition = { + StringEquals = { + "aws:SourceAccount" = local.account_id + } + } + } + ] + }) +} + +resource "aws_s3_bucket_notification" "po_emails" { + bucket = aws_s3_bucket.po_emails.id + + lambda_function { + id = "po-email-processor-inbound" + lambda_function_arn = aws_lambda_function.po_email_processor.arn + events = ["s3:ObjectCreated:*"] + filter_prefix = "inbound/" + } + + depends_on = [aws_lambda_permission.po_emails_invoke] +} + +resource "aws_lambda_permission" "po_emails_invoke" { + statement_id = "AllowS3InvokePoEmailProcessor" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.po_email_processor.function_name + principal = "s3.amazonaws.com" + source_arn = aws_s3_bucket.po_emails.arn +} + +resource "aws_sqs_queue" "po_email_processor_dlq" { + name = local.po_email_processor_dlq_name + message_retention_seconds = 1209600 + sqs_managed_sse_enabled = true +} + +resource "aws_sqs_queue_policy" "po_email_processor_dlq" { + queue_url = aws_sqs_queue.po_email_processor_dlq.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Deny" + Principal = { AWS = "*" } + Action = "sqs:*" + Resource = aws_sqs_queue.po_email_processor_dlq.arn + Condition = { + Bool = { "aws:SecureTransport" = "false" } + } + } + ] + }) +} diff --git a/terraform/po_ses.tf b/terraform/po_ses.tf new file mode 100644 index 0000000..e75509f --- /dev/null +++ b/terraform/po_ses.tf @@ -0,0 +1,14 @@ +resource "aws_ses_receipt_rule" "po_email" { + name = local.po_ses_rule_name + rule_set_name = local.ses_rule_set_name + recipients = ["amazon_po@int.seahaven.com"] + enabled = true + scan_enabled = false + tls_policy = "Optional" + + s3_action { + bucket_name = aws_s3_bucket.po_emails.id + object_key_prefix = "inbound/" + position = 1 + } +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..7923203 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "procurement-ingest" + ManagedBy = "terraform" + Workspace = "procurement-ingest-prod" + } + } +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..ddd2d5b --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,8 @@ +# Wire these as HCP workspace Terraform variables (never commit real .tfvars). +# Exact ARNs only; secret VALUES must never appear in this repo. +web_ui_auth_token_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:procurement-ingest/web-ui-auth-token-ApAMmr" +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). +shoc_consumer_role_arn = "arn:aws:iam::396287094661:role/shoc-backend-dev" diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..1df0930 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,25 @@ +variable "aws_region" { + type = string + description = "AWS region for all resources" + default = "us-east-1" +} + +variable "web_ui_auth_token_secret_arn" { + type = string + description = "Secrets Manager ARN for the shared web UI / docs auth token (exact ARN, including suffix)" +} + +variable "shoc_hmac_secret_arn" { + type = string + description = "Secrets Manager ARN for the SHOC webhook HMAC secret (exact ARN, including suffix)" +} + +variable "shoc_webhook_url" { + type = string + description = "SHOC webhook HTTPS endpoint URL (required; no default — set explicitly in HCP workspace vars)" +} + +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." +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..3cdad53 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.0" + } + external = { + source = "hashicorp/external" + version = "~> 2.0" + } + } + + cloud { + organization = "seahaven" + + workspaces { + name = "procurement-ingest-prod" + } + } +} diff --git a/terraform/wo_alarms.tf b/terraform/wo_alarms.tf new file mode 100644 index 0000000..1f2c5ed --- /dev/null +++ b/terraform/wo_alarms.tf @@ -0,0 +1,344 @@ +resource "aws_cloudwatch_log_metric_filter" "wo_sender_auth_rejected" { + name = local.wo_sender_auth_filter_name + log_group_name = aws_cloudwatch_log_group.wo_email_processor.name + pattern = "\"sender_auth_rejected\"" + + metric_transformation { + name = "workorder-email-processor-sender-auth-rejected" + namespace = "Seahaven/ProcurementIngest" + value = "1" + default_value = "0" + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_email_processor_errors" { + alarm_name = "workorder-email-processor-errors" + alarm_description = "workorder-email-processor async invocation errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Errors" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_email_processor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_email_processor_throttles" { + alarm_name = "workorder-email-processor-throttles" + alarm_description = "workorder-email-processor invocation throttles" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Throttles" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_email_processor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_email_processor_dlq" { + alarm_name = "workorder-email-processor-dlq-messages" + alarm_description = "workorder-email-processor DLQ has visible messages (dropped emails)" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "ApproximateNumberOfMessagesVisible" + namespace = "AWS/SQS" + period = 300 + statistic = "Maximum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + QueueName = aws_sqs_queue.wo_email_processor_dlq.name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_email_processor_duration" { + alarm_name = "workorder-email-processor-duration" + alarm_description = "workorder-email-processor p95 duration approaching the 60s timeout" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 3 + datapoints_to_alarm = 2 + metric_name = "Duration" + namespace = "AWS/Lambda" + period = 300 + extended_statistic = "p95" + threshold = 45000 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_email_processor.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_email_processor_sender_auth_rejected" { + alarm_name = "workorder-email-processor-sender-auth-rejected" + alarm_description = "workorder-email-processor rejected inbound mail on sender authentication (possible allowlist/DKIM-domain drift silently dropping real mail)" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 6 + datapoints_to_alarm = 2 + metric_name = "workorder-email-processor-sender-auth-rejected" + namespace = "Seahaven/ProcurementIngest" + period = 300 + statistic = "Sum" + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] +} + +resource "aws_cloudwatch_metric_alarm" "wo_email_processor_fallback_rate" { + 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" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + datapoints_to_alarm = 2 + threshold = 15 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + metric_query { + id = "expr_1" + expression = "IF((FILL(fb,0)+FILL(rej,0)+FILL(tmpl,0))>=10, 100*(FILL(fb,0)+FILL(rej,0))/(FILL(fb,0)+FILL(rej,0)+FILL(tmpl,0)), 0)" + label = "TemplateFallbackRatePct" + return_data = true + } + + metric_query { + id = "fb" + metric { + metric_name = "ParseOutcome" + namespace = "Seahaven/WorkorderIngest" + period = 900 + stat = "Sum" + dimensions = { + ParseMethod = "ai_fallback" + } + } + return_data = false + } + + metric_query { + id = "rej" + metric { + metric_name = "ParseOutcome" + namespace = "Seahaven/WorkorderIngest" + period = 900 + stat = "Sum" + dimensions = { + ParseMethod = "ai_fallback_rejected" + } + } + return_data = false + } + + metric_query { + id = "tmpl" + metric { + metric_name = "ParseOutcome" + namespace = "Seahaven/WorkorderIngest" + period = 900 + stat = "Sum" + dimensions = { + ParseMethod = "template" + } + } + return_data = false + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_email_processor_ai_fallback_rejected" { + 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)" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 6 + datapoints_to_alarm = 2 + metric_name = "ParseOutcome" + namespace = "Seahaven/WorkorderIngest" + period = 300 + statistic = "Sum" + threshold = 1 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + ParseMethod = "ai_fallback_rejected" + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_shoc_hmac_rotator_errors" { + alarm_name = "workorder-shoc-hmac-rotator-errors" + alarm_description = "workorder-shoc-hmac-rotator invocation errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Errors" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_shoc_hmac_rotator.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_shoc_hmac_rotator_throttles" { + alarm_name = "workorder-shoc-hmac-rotator-throttles" + alarm_description = "workorder-shoc-hmac-rotator invocation throttles" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Throttles" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_shoc_hmac_rotator.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_shoc_hmac_rotator_duration" { + alarm_name = "workorder-shoc-hmac-rotator-duration" + alarm_description = "workorder-shoc-hmac-rotator p99 duration approaching the 60s timeout" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 3 + datapoints_to_alarm = 2 + metric_name = "Duration" + namespace = "AWS/Lambda" + period = 300 + extended_statistic = "p99" + threshold = 45000 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_shoc_hmac_rotator.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_shoc_emitter_errors" { + alarm_name = "workorder-shoc-emitter-errors" + alarm_description = "workorder-shoc-emitter invocation errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Errors" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_shoc_emitter.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_shoc_emitter_throttles" { + alarm_name = "workorder-shoc-emitter-throttles" + alarm_description = "workorder-shoc-emitter invocation throttles" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "Throttles" + namespace = "AWS/Lambda" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_shoc_emitter.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_shoc_emitter_duration" { + alarm_name = "workorder-shoc-emitter-duration" + alarm_description = "workorder-shoc-emitter p99 duration approaching the 60s timeout" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 3 + datapoints_to_alarm = 2 + metric_name = "Duration" + namespace = "AWS/Lambda" + period = 300 + extended_statistic = "p99" + threshold = 45000 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_shoc_emitter.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_shoc_emitter_iterator_age" { + alarm_name = "workorder-shoc-emitter-iterator-age" + alarm_description = "workorder-shoc-emitter stream lag >= 10 min (SHOC receiver likely down; shard blocking on retries)" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 3 + datapoints_to_alarm = 2 + metric_name = "IteratorAge" + namespace = "AWS/Lambda" + period = 300 + statistic = "Maximum" + threshold = 600000 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = aws_lambda_function.wo_shoc_emitter.function_name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_shoc_emitter_failures_messages" { + alarm_name = "workorder-shoc-emitter-failures-messages" + alarm_description = "workorder-shoc-emitter retry-exhausted stream records parked (ESM failure metadata; replay rebuilds from DynamoDB)" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "ApproximateNumberOfMessagesVisible" + namespace = "AWS/SQS" + period = 300 + statistic = "Maximum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + QueueName = aws_sqs_queue.shoc_emitter_failures.name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_shoc_emitter_rejected_messages" { + alarm_name = "workorder-shoc-emitter-rejected-messages" + alarm_description = "workorder-shoc-emitter parked non-retryable 4xx deliveries (contract bug; inspect payloads and replay)" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "ApproximateNumberOfMessagesVisible" + namespace = "AWS/SQS" + period = 300 + statistic = "Maximum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + QueueName = aws_sqs_queue.shoc_emitter_rejected.name + } +} diff --git a/terraform/wo_ddb.tf b/terraform/wo_ddb.tf new file mode 100644 index 0000000..536f530 --- /dev/null +++ b/terraform/wo_ddb.tf @@ -0,0 +1,122 @@ +resource "aws_dynamodb_table" "work_orders" { + name = "WorkOrders" + billing_mode = "PAY_PER_REQUEST" + hash_key = "work_order_id" + + attribute { + name = "work_order_id" + type = "S" + } + + stream_enabled = true + stream_view_type = "NEW_AND_OLD_IMAGES" + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_dynamodb_table" "work_order_comments" { + name = "WorkOrderComments" + billing_mode = "PAY_PER_REQUEST" + hash_key = "work_order_id" + range_key = "comment_id" + + attribute { + name = "work_order_id" + type = "S" + } + + attribute { + name = "comment_id" + type = "S" + } + + stream_enabled = true + stream_view_type = "NEW_AND_OLD_IMAGES" + + lifecycle { + prevent_destroy = true + } +} + +locals { + wo_ddb_alarm_tables = { + "WorkOrders" = aws_dynamodb_table.work_orders.name + "WorkOrderComments" = aws_dynamodb_table.work_order_comments.name + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_ddb_throttles" { + for_each = local.wo_ddb_alarm_tables + + alarm_name = "${each.key}-throttles" + alarm_description = "${each.key} DynamoDB throttled requests" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + metric_query { + id = "expr_1" + expression = local.ddb_throttle_expr + label = "Sum of throttled requests across all operations" + return_data = true + } + + dynamic "metric_query" { + for_each = local.ddb_alarm_operations + content { + id = lower(metric_query.value) + metric { + metric_name = "ThrottledRequests" + namespace = "AWS/DynamoDB" + period = 300 + stat = "Sum" + dimensions = { + TableName = each.value + Operation = metric_query.value + } + } + return_data = false + } + } +} + +resource "aws_cloudwatch_metric_alarm" "wo_ddb_system_errors" { + for_each = local.wo_ddb_alarm_tables + + alarm_name = "${each.key}-system-errors" + alarm_description = "${each.key} DynamoDB server-side (5xx) errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 0 + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + metric_query { + id = "expr_1" + expression = local.ddb_throttle_expr + label = "Sum of system errors across all operations" + return_data = true + } + + dynamic "metric_query" { + for_each = local.ddb_alarm_operations + content { + id = lower(metric_query.value) + metric { + metric_name = "SystemErrors" + namespace = "AWS/DynamoDB" + period = 300 + stat = "Sum" + dimensions = { + TableName = each.value + Operation = metric_query.value + } + } + return_data = false + } + } +} diff --git a/terraform/wo_lambda.tf b/terraform/wo_lambda.tf new file mode 100644 index 0000000..cccc2f6 --- /dev/null +++ b/terraform/wo_lambda.tf @@ -0,0 +1,84 @@ +resource "aws_cloudwatch_log_group" "wo_email_processor" { + name = "/aws/lambda/workorder-email-processor" + retention_in_days = 60 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_cloudwatch_log_group" "wo_web_ui" { + name = "/aws/lambda/workorder-web-ui" + retention_in_days = 60 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_lambda_function" "wo_email_processor" { + function_name = "workorder-email-processor" + role = aws_iam_role.wo_email_processor.arn + handler = "handler.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 256 + timeout = 60 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.lambda["wo_email_processor"].key + source_code_hash = data.archive_file.lambda["wo_email_processor"].output_base64sha256 + + dead_letter_config { + target_arn = aws_sqs_queue.wo_email_processor_dlq.arn + } + + environment { + variables = { + WORK_ORDERS_TABLE = aws_dynamodb_table.work_orders.name + COMMENTS_TABLE = aws_dynamodb_table.work_order_comments.name + ALLOWED_DKIM_DOMAINS = "seahaven.com" + BEDROCK_MODEL_ID = local.bedrock_model_id + } + } + + depends_on = [ + aws_s3_object.lambda, + aws_cloudwatch_log_group.wo_email_processor, + aws_iam_role_policy_attachment.wo_email_processor_basic, + aws_iam_role_policy.wo_email_processor_s3, + aws_iam_role_policy.wo_email_processor_ddb, + aws_iam_role_policy.wo_email_processor_bedrock, + aws_iam_role_policy.wo_email_processor_dlq, + ] +} + +resource "aws_lambda_function" "wo_web_ui" { + function_name = "workorder-web-ui" + role = aws_iam_role.wo_web_ui.arn + handler = "handler.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 128 + timeout = 15 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.lambda["wo_web_ui"].key + source_code_hash = data.archive_file.lambda["wo_web_ui"].output_base64sha256 + + environment { + variables = { + WORK_ORDERS_TABLE = aws_dynamodb_table.work_orders.name + COMMENTS_TABLE = aws_dynamodb_table.work_order_comments.name + WEB_UI_AUTH_TOKEN_SECRET_ARN = var.web_ui_auth_token_secret_arn + } + } + + depends_on = [ + aws_s3_object.lambda, + aws_cloudwatch_log_group.wo_web_ui, + aws_iam_role_policy_attachment.wo_web_ui_basic, + aws_iam_role_policy.wo_web_ui_ddb, + aws_iam_role_policy.wo_web_ui_secrets, + ] +} diff --git a/terraform/wo_s3.tf b/terraform/wo_s3.tf new file mode 100644 index 0000000..fe2333a --- /dev/null +++ b/terraform/wo_s3.tf @@ -0,0 +1,96 @@ +resource "aws_s3_bucket" "wo_emails" { + bucket = local.wo_email_bucket_name + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "wo_emails" { + bucket = aws_s3_bucket.wo_emails.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "wo_emails" { + bucket = aws_s3_bucket.wo_emails.id + + rule { + id = "expire-90-days" + status = "Enabled" + + filter {} + + expiration { + days = 90 + } + } +} + +resource "aws_s3_bucket_policy" "wo_emails" { + bucket = aws_s3_bucket.wo_emails.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { Service = "ses.amazonaws.com" } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.wo_emails.arn}/inbound/*" + Condition = { + StringEquals = { + "aws:SourceAccount" = local.account_id + } + } + } + ] + }) +} + +resource "aws_s3_bucket_notification" "wo_emails" { + bucket = aws_s3_bucket.wo_emails.id + + lambda_function { + id = "wo-email-processor-inbound" + lambda_function_arn = aws_lambda_function.wo_email_processor.arn + events = ["s3:ObjectCreated:*"] + filter_prefix = "inbound/" + } + + depends_on = [aws_lambda_permission.wo_emails_invoke] +} + +resource "aws_lambda_permission" "wo_emails_invoke" { + statement_id = "AllowS3InvokeWoEmailProcessor" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.wo_email_processor.function_name + principal = "s3.amazonaws.com" + source_arn = aws_s3_bucket.wo_emails.arn +} + +resource "aws_sqs_queue" "wo_email_processor_dlq" { + name = local.wo_email_processor_dlq_name + message_retention_seconds = 1209600 + sqs_managed_sse_enabled = true +} + +resource "aws_sqs_queue_policy" "wo_email_processor_dlq" { + queue_url = aws_sqs_queue.wo_email_processor_dlq.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Deny" + Principal = { AWS = "*" } + Action = "sqs:*" + Resource = aws_sqs_queue.wo_email_processor_dlq.arn + Condition = { + Bool = { "aws:SecureTransport" = "false" } + } + } + ] + }) +} diff --git a/terraform/wo_ses.tf b/terraform/wo_ses.tf new file mode 100644 index 0000000..9fda262 --- /dev/null +++ b/terraform/wo_ses.tf @@ -0,0 +1,14 @@ +resource "aws_ses_receipt_rule" "wo_email" { + name = local.wo_ses_rule_name + rule_set_name = local.ses_rule_set_name + recipients = ["apm@int.seahaven.com"] + enabled = true + scan_enabled = false + tls_policy = "Optional" + + s3_action { + bucket_name = aws_s3_bucket.wo_emails.id + object_key_prefix = "inbound/" + position = 1 + } +} diff --git a/terraform/wo_shoc.tf b/terraform/wo_shoc.tf new file mode 100644 index 0000000..d576b6f --- /dev/null +++ b/terraform/wo_shoc.tf @@ -0,0 +1,345 @@ +data "aws_iam_policy_document" "shoc_webhook_kms" { + statement { + sid = "EnableRootAccountPermissions" + effect = "Allow" + actions = ["kms:*"] + resources = ["*"] + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${local.account_id}:root"] + } + } + + statement { + sid = "ShocBackendDevDecrypt" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = ["*"] + + principals { + type = "AWS" + identifiers = [local.shoc_consumer_role_arn] + } + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["secretsmanager.${var.aws_region}.amazonaws.com"] + } + } + + statement { + sid = "AllowSecretsManagerViaServiceAccount" + effect = "Allow" + actions = [ + "kms:Decrypt", + "kms:Encrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + ] + resources = ["*"] + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${local.account_id}:root"] + } + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["secretsmanager.${var.aws_region}.amazonaws.com"] + } + } + + statement { + sid = "AllowSecretsManagerGrants" + effect = "Allow" + actions = [ + "kms:CreateGrant", + "kms:DescribeKey", + ] + resources = ["*"] + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${local.account_id}:root"] + } + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["secretsmanager.${var.aws_region}.amazonaws.com"] + } + } + + statement { + sid = "ShocHmacRotator" + effect = "Allow" + actions = [ + "kms:Decrypt", + "kms:Encrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + ] + resources = ["*"] + + principals { + type = "AWS" + identifiers = [aws_iam_role.wo_shoc_hmac_rotator.arn] + } + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["secretsmanager.${var.aws_region}.amazonaws.com"] + } + } + + statement { + sid = "ShocEmitterDecrypt" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = ["*"] + + principals { + type = "AWS" + identifiers = [aws_iam_role.wo_shoc_emitter.arn] + } + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["secretsmanager.${var.aws_region}.amazonaws.com"] + } + } +} + +resource "aws_kms_key" "shoc_webhook" { + description = "Dedicated CMK for the workorder-ingest/shoc-webhook-hmac secret (cross-account readable by the SHOC backend)" + enable_key_rotation = true + deletion_window_in_days = 7 + policy = data.aws_iam_policy_document.shoc_webhook_kms.json +} + +resource "aws_kms_alias" "shoc_webhook" { + name = "alias/workorder-ingest-shoc-webhook-kms" + target_key_id = aws_kms_key.shoc_webhook.key_id +} + +resource "aws_secretsmanager_secret" "shoc_webhook_hmac" { + name = "workorder-ingest/shoc-webhook-hmac" + description = "HMAC signing keys for the SHOC work-order webhook (docs/shoc-webhook-contract.md section 6)" + kms_key_id = aws_kms_key.shoc_webhook.arn + + tags = { + Purpose = "shoc-webhook-hmac" + ManagedBy = "terraform" + } +} + +resource "aws_secretsmanager_secret_policy" "shoc_webhook_hmac" { + secret_arn = aws_secretsmanager_secret.shoc_webhook_hmac.arn + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + AWS = local.shoc_consumer_role_arn + } + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + ] + Resource = "*" + }, + { + Effect = "Deny" + Principal = { + AWS = "arn:aws:iam::${local.account_id}:root" + } + Action = "secretsmanager:DeleteSecret" + Resource = "*" + } + ] + }) +} + +resource "aws_cloudwatch_log_group" "wo_shoc_hmac_rotator" { + name = "/aws/lambda/workorder-shoc-hmac-rotator" + retention_in_days = 60 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_lambda_function" "wo_shoc_hmac_rotator" { + function_name = "workorder-shoc-hmac-rotator" + role = aws_iam_role.wo_shoc_hmac_rotator.arn + handler = "handler.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 128 + timeout = 60 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.lambda["wo_shoc_hmac_rotator"].key + source_code_hash = data.archive_file.lambda["wo_shoc_hmac_rotator"].output_base64sha256 + + depends_on = [ + aws_s3_object.lambda, + aws_cloudwatch_log_group.wo_shoc_hmac_rotator, + aws_iam_role_policy_attachment.wo_shoc_hmac_rotator_basic, + aws_iam_role_policy.wo_shoc_hmac_rotator_secrets, + aws_iam_role_policy.wo_shoc_hmac_rotator_kms, + ] +} + +resource "aws_lambda_permission" "wo_shoc_hmac_rotator" { + statement_id = "AllowSecretsManagerRotate" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.wo_shoc_hmac_rotator.function_name + principal = "secretsmanager.amazonaws.com" + source_account = local.account_id + source_arn = aws_secretsmanager_secret.shoc_webhook_hmac.arn +} + +resource "aws_secretsmanager_secret_rotation" "shoc_webhook_hmac" { + secret_id = aws_secretsmanager_secret.shoc_webhook_hmac.id + rotation_lambda_arn = aws_lambda_function.wo_shoc_hmac_rotator.arn + + rotation_rules { + automatically_after_days = 30 + } + + depends_on = [aws_lambda_permission.wo_shoc_hmac_rotator] +} + +resource "aws_sqs_queue" "shoc_emitter_failures" { + name = "workorder-shoc-emitter-failures" + message_retention_seconds = 1209600 + sqs_managed_sse_enabled = true +} + +resource "aws_sqs_queue_policy" "shoc_emitter_failures" { + queue_url = aws_sqs_queue.shoc_emitter_failures.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Deny" + Principal = { AWS = "*" } + Action = "sqs:*" + Resource = aws_sqs_queue.shoc_emitter_failures.arn + Condition = { + Bool = { "aws:SecureTransport" = "false" } + } + } + ] + }) +} + +resource "aws_sqs_queue" "shoc_emitter_rejected" { + name = "workorder-shoc-emitter-rejected" + message_retention_seconds = 1209600 + sqs_managed_sse_enabled = true +} + +resource "aws_sqs_queue_policy" "shoc_emitter_rejected" { + queue_url = aws_sqs_queue.shoc_emitter_rejected.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Deny" + Principal = { AWS = "*" } + Action = "sqs:*" + Resource = aws_sqs_queue.shoc_emitter_rejected.arn + Condition = { + Bool = { "aws:SecureTransport" = "false" } + } + } + ] + }) +} + +resource "aws_cloudwatch_log_group" "wo_shoc_emitter" { + name = "/aws/lambda/workorder-shoc-emitter" + retention_in_days = 60 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_lambda_function" "wo_shoc_emitter" { + function_name = "workorder-shoc-emitter" + role = aws_iam_role.wo_shoc_emitter.arn + handler = "handler.handler" + runtime = "python3.12" + architectures = ["arm64"] + memory_size = 256 + timeout = 60 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.lambda["wo_shoc_emitter"].key + source_code_hash = data.archive_file.lambda["wo_shoc_emitter"].output_base64sha256 + + environment { + variables = { + HMAC_SECRET_ARN = var.shoc_hmac_secret_arn + REJECTED_QUEUE_URL = aws_sqs_queue.shoc_emitter_rejected.url + SHOC_WEBHOOK_URL = var.shoc_webhook_url + } + } + + depends_on = [ + aws_s3_object.lambda, + aws_cloudwatch_log_group.wo_shoc_emitter, + aws_iam_role_policy_attachment.wo_shoc_emitter_basic, + aws_iam_role_policy.wo_shoc_emitter_streams, + aws_iam_role_policy.wo_shoc_emitter_secrets, + aws_iam_role_policy.wo_shoc_emitter_kms, + aws_iam_role_policy.wo_shoc_emitter_sqs, + ] +} + +resource "aws_lambda_event_source_mapping" "wo_shoc_emitter_work_orders" { + event_source_arn = aws_dynamodb_table.work_orders.stream_arn + function_name = aws_lambda_function.wo_shoc_emitter.arn + starting_position = "LATEST" + batch_size = 10 + parallelization_factor = 1 + maximum_record_age_in_seconds = 86400 + maximum_retry_attempts = -1 + bisect_batch_on_function_error = false + function_response_types = ["ReportBatchItemFailures"] + enabled = true + + destination_config { + on_failure { + destination_arn = aws_sqs_queue.shoc_emitter_failures.arn + } + } +} + +resource "aws_lambda_event_source_mapping" "wo_shoc_emitter_comments" { + event_source_arn = aws_dynamodb_table.work_order_comments.stream_arn + function_name = aws_lambda_function.wo_shoc_emitter.arn + starting_position = "LATEST" + batch_size = 10 + parallelization_factor = 1 + maximum_record_age_in_seconds = 86400 + maximum_retry_attempts = -1 + bisect_batch_on_function_error = false + function_response_types = ["ReportBatchItemFailures"] + enabled = true + + destination_config { + on_failure { + destination_arn = aws_sqs_queue.shoc_emitter_failures.arn + } + } +}