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/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 17e2d30..e2efb14 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,3 +10,6 @@ permissions: jobs: ci: uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8 + with: + run-cdk-synth: false + run-tests: true diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml.frozen similarity index 57% rename from .github/workflows/deploy.yaml rename to .github/workflows/deploy.yaml.frozen index 9c1d81e..7eb323b 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml.frozen @@ -1,7 +1,9 @@ +# Frozen for PLAT-76. HCP Terraform is the sole deploy path. +# Do not restore this workflow; the mgmt CDK stack is the rollback target +# until DNS cutover and stack delete. name: Deploy on: - push: - branches: [main] + workflow_dispatch: # CD frozen for PLAT-76; do not restore push-to-main permissions: id-token: write diff --git a/.gitignore b/.gitignore index d69549d..84f9bff 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ cdk.out/ .env .env.* + +terraform/build/ +.terraform/ +*.tfvars diff --git a/README.md b/README.md index ef7a837..3b14703 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,60 @@ # Sea Haven Door Unlock API ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) -![AWS CDK](https://img.shields.io/badge/AWS-CDK-FF9900?logo=amazonaws&logoColor=white) +![Terraform](https://img.shields.io/badge/HCP-Terraform-7B42BC?logo=terraform&logoColor=white) ![CI](https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api/actions/workflows/ci.yaml/badge.svg) -AWS Lambda middleware that allows Yealink desk phones to unlock the front door and manage lockdown profiles via LenelS2 Elements. +AWS Lambda middleware that allows Yealink desk phones to unlock the front door and manage lockdown profiles via LenelS2 Elements. Target account is **seahaven-prod** (`011934824531`) under HCP Terraform workspace `seahaven-door-unlock-api-prod`. ``` Yealink T54W/T58W → HTTPS GET (?token=) → API Gateway (token authorizer) → Lambda → LenelS2 Elements API ``` +Phones keep `https://doorunlock.seahaven.com`. Cutover is a Route 53 A-record flip in the mgmt zone (`Z06652411XKH89KTZD3XA`). DNS is not managed in this Terraform. + ## Architecture - **API Gateway (HTTP API)** — `GET /unlock`, `GET /lockdown`, `GET /lockdown/status` with throttling (5 burst / 2 sustained req/sec) -- **Token authorizer Lambda** — a REQUEST-type Lambda authorizer validates the `?token=` query-string value (the same shared secret the phones already send) against the `/seahaven/door-unlock/auth-token` SSM parameter, so unauthenticated callers are rejected at the gateway (401/403) before any handler runs. Identity source is `$request.querystring.token`; results are cached 5 minutes. Fail-closed. The handlers also re-validate the token as defense-in-depth. +- **Token authorizer Lambda** — a REQUEST-type Lambda authorizer validates the `?token=` query-string value (the same shared secret the phones already send) against the `/seahaven/door-unlock/auth-token` SSM parameter, so unauthenticated callers are rejected at the gateway (401/403) before any handler runs. Identity source is `$request.querystring.token`; results are cached 5 minutes. Fail-closed. The handlers also re-validate the token as defense-in-depth. API Gateway invokes the authorizer through a Lambda resource policy, not `AuthorizerCredentialsArn`. - **Unlock Lambda** — validates a shared auth token, calls the Elements `TemporaryUnlock` command - **Lockdown Lambda** — toggles lockdown profiles (start/stop) and checks status, returns Yealink XML TextScreen responses -- **Lockdown Poller Lambda** — VPC-connected, polls Elements API every 15 seconds for lockdown status (runs 4x per 1-minute EventBridge schedule) -- **SSM Parameter Store** — stores the Elements API key, auth token, door ID, and phone IPs -- **Secrets Manager** — stores the Yealink phone admin password -- **Custom Domain** — `doorunlock.seahaven.com` via Route 53 + ACM wildcard cert -- **CloudWatch alarms** — one error alarm per Lambda (unlock, lockdown, authorizer, poller); each fires on `Errors > 0` and notifies the cross-stack `site-alerts` SNS topic (ALARM state only) +- **Lockdown Poller Lambda** — polls the public Elements API every minute. No VPC. +- **BLF sync Lambda** — daily 09:00 UTC EventBridge job that writes 3CX department BLFs +- **SSM Parameter Store** — Elements API key, auth token, and door ID (created out of band; Terraform never reads SecureString values) +- **Secrets Manager** — 3CX XAPI credentials (`afterhours-shift-manager/3cx-*`), referenced by ARN only +- **Custom Domain** — `doorunlock.seahaven.com` via an out-of-band ACM certificate in prod plus an API Gateway domain mapping. Route 53 stays in mgmt. +- **CloudWatch alarms** — one error alarm per Lambda (unlock, lockdown, authorizer, poller, blf-sync); each fires on `Errors > 0` and notifies the prod `site-alerts` SNS topic (ALARM state only) -## Infrastructure (CDK) +## Infrastructure (HCP Terraform) -All infrastructure is defined as code with the **AWS CDK v2 (TypeScript)**; `aws-cdk-lib` is pinned to `2.261.0`. The whole system is a single CloudFormation stack. +All live infrastructure is defined in `terraform/` and applied from HCP Terraform workspace `seahaven-door-unlock-api-prod` (manual apply). The AWS provider is pinned at `6.58.0`. Functions run Node 24 arm64 under path `/tf-managed/` with permissions boundary `seahaven-lambda-execution-boundary-seahaven-door-unlock-api`. + +CDK sources (`bin/`, `lib/`) remain in the repo as the mgmt rollback target until that stack is deleted. GitHub Actions CDK deploy is frozen (`.github/workflows/deploy.yaml.frozen`). ### Layout ``` -bin/app.ts # CDK app entry point -lib/door-unlock-stack.ts # DoorUnlockStack — all resource definitions +terraform/ # HCP Terraform (working directory) lambda/ -├── unlock/unlock-handler.ts # Unlock Lambda -├── lockdown/lockdown-handler.ts # Lockdown Lambda -├── poller/lockdown-poller.ts # Lockdown Poller Lambda -├── authorizer/authorizer-handler.ts # Token authorizer Lambda -└── blf-sync/blf-sync-handler.ts # 3CX department BLF sync Lambda -cdk.json # CDK config (app command, watch, context flags) +├── unlock/unlock-handler.ts +├── lockdown/lockdown-handler.ts +├── poller/lockdown-poller.ts +├── authorizer/authorizer-handler.ts +└── blf-sync/blf-sync-handler.ts ``` -### `bin/app.ts` +HCP plan workers may not have Node. `terraform/build_packages_external.sh` bootstraps Node 24 if needed, then esbuild-bundles the five handlers during plan. Packages upload through `aws_s3_object.content_base64` because plan and apply run on different workers. -Instantiates `DoorUnlockStack` with an explicit `stackName` of `seahaven-door-unlock-api`, pinned to account `328440206208` / `us-east-1`. +EventBridge schedules are created **disabled** (`enable_schedules = false`) until live-path proof and DNS cutover. -### `lib/door-unlock-stack.ts` - -Defines every resource the stack owns: - -- The five Lambda functions (Node 24.x, arm64, 60-day log retention), bundled from TypeScript with esbuild -- The HTTP API (`door-unlock-api`), its `GET /unlock`, `GET /lockdown`, and `GET /lockdown/status` routes, throttling, and JSON access logging -- The `HttpLambdaAuthorizer` token authorizer (identity source `$request.querystring.token`, 5-minute result cache) -- The EventBridge rule that invokes the poller once a minute, plus the poller's VPC config and security group (imported VPC/subnets, egress to the Elements API and phone LAN) -- The EventBridge rule that invokes department BLF sync daily at 09:00 UTC, plus imports of the afterhours 3CX XAPI secrets -- The custom domain, ACM certificate import, and Route 53 A record for `doorunlock.seahaven.com` -- Imports of the SSM parameters, the phone-password secret, the afterhours 3CX XAPI secrets, and the `site-alerts` SNS topic, with the corresponding `grantRead` IAM permissions -- The five per-Lambda CloudWatch error alarms - -### `cdk.json` - -CDK configuration committed to the repo. The `app` command runs `npx tsx bin/app.ts`, so the TypeScript entry point executes directly via `tsx` (no separate compile step). It also carries the `watch` include/exclude globs and the CDK feature-flag `context`. - -### Commands - -```bash -npx cdk synth # synthesize the CloudFormation template -npx cdk diff # diff against the deployed stack -npx cdk deploy # deploy (see Manual Deployment below) -``` - -The same commands are also exposed as npm scripts (`npm run synth`, `npm run diff`, `npm run deploy`). +Do not put secret values in Terraform, `*.tfvars`, chat, or PRs. Create SSM and Secrets Manager objects out of band; Terraform uses names and ARNs only. ## Documentation -The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This project's `seahaven-door-unlock-api` stack is represented there as a Mermaid subgraph. +The canonical map of Sea Haven's AWS infrastructure lives in Confluence. - **[AWS Architecture Map](https://seahaven.atlassian.net/wiki/spaces/IT/pages/1540098)** (Confluence, IT space, page 1540098) +- **[Door Unlock API](https://seahaven.atlassian.net/wiki/spaces/IT/pages/55640066)** (ops page) ## Lockdown Profiles @@ -97,29 +76,24 @@ Pressing the line key toggles the lockdown on/off and displays the current statu | `/seahaven/door-unlock/elements-api-key` | SecureString | LenelS2 Elements API key | | `/seahaven/door-unlock/auth-token` | SecureString | Shared secret embedded in the Yealink DSS key URL | | `/seahaven/door-unlock/door-id` | String | Elements device ID for the front door reader | -| `/seahaven/door-unlock/phone-ips` | String | Comma-separated phone IPs for lockdown poller | + +Phone LED/Push XML is not in the live path. `/seahaven/door-unlock/phone-ips` and `door-unlock-api/phone-password` are not used by this Terraform. ## Secrets Manager | Secret | Description | |--------|-------------| -| `door-unlock-api/phone-password` | Yealink phone admin password for Push XML | +| `afterhours-shift-manager/3cx-domain` | 3CX XAPI hostname | +| `afterhours-shift-manager/3cx-client-id` | 3CX XAPI client id | +| `afterhours-shift-manager/3cx-client-secret` | 3CX XAPI client secret | ## CI/CD -GitHub Actions, using the Sea Haven reusable workflows: +- **`.github/workflows/ci.yaml`** — on pull requests to `main`, runs TypeScript tests. CDK synth is off. +- **`.github/workflows/ci-terraform.yaml`** — on pull requests that touch `terraform/`, runs `terraform fmt`, `init -backend=false`, and `validate`. +- **HCP Terraform** — workspace `seahaven-door-unlock-api-prod` in project `seahaven-prod`. Manual apply. Auto-apply stays off until the stack is sealed. -- **`.github/workflows/ci.yaml`** — on pull requests to `main`, runs the `ci-typescript-cdk` reusable workflow (build, lint, synth). -- **`.github/workflows/deploy.yaml`** — on push to `main`, runs the `cd-cdk` reusable workflow which assumes the `githubdeploy-seahaven-door-unlock-api` OIDC role (`AWS_DEPLOY_ROLE_ARN` repo secret) and runs `cdk deploy`. - -The legacy CodePipeline/CodeBuild deploy path has been fully decommissioned. - -## Manual Deployment - -```bash -npm install -npx cdk deploy -``` +Do not run `cdk deploy` against prod. The GitHub CDK deploy workflow is frozen. ## Phone Configuration diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..6fc03b9 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,104 @@ +# 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.8.0" + hashes = [ + "h1:/+W2xjGkapDYS4LC8Cp8pjCteWlc7g6Lk0vhr66e2Os=", + "h1:8qxUTDirwHEV/Ve0HQBt6KLk+ubO3pnsVbmAiJHchDs=", + "h1:Co+NFFxp7FcWEPVzAvh/oNNZHvMKpZpMbW64d1lKyWY=", + "h1:KikbbYGsqMcyadPPklFghWNbpSxPE1l8uWli76Qwzsg=", + "h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=", + "h1:cMBtvdHEgvTmglbioVehZCaOIPocumu9+vlGw5Dsaro=", + "h1:i4jOdktQW0SDbjM3IC2ZSqdd881FzVY+V11XKkLPHrk=", + "h1:jdmKm+xl6ZcQrijxapnZ94RVuz/G4vk7hsIa1N0VT5Q=", + "h1:mhVBtd0G63hS4yVLA6F8IMX3YTbJUY+y5ulKpD+42Qk=", + "h1:oRWx6ZDIdlNBF90L8TzV9X7pQ+P403hoCDiBfmUfhC0=", + "h1:q33yagTRGTgqU1gbI8vffyxyetUU5IHx5FES6mhGfVk=", + "h1:vF/BGdh7qD5KPeoOeS8xBFckpx+qcjjtY+BAV6A/qNE=", + "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.58.0" + hashes = [ + "h1:1im6ypdeXLXq3sHElserTE62qmIqNiHLAyC3R5EnFFw=", + "h1:1q4Xce8Evn4cQ1lRiL3oyJFq1HWc8uIFcOhmTuo+uFY=", + "h1:2kpake4zZKRX5437QVIRU3qFYH6Bjw/QE1fgVCPOrUg=", + "h1:Dw939o/hYwdaPFmR+E6KPcBQMd7mkJ3Zt6PI/+FAn78=", + "h1:E8NJv9jxspjVPN7L77vnSyz7hNF69Ud8r8pueA4Z6tM=", + "h1:IOfu434S9D0f0sdon195DYB76+cMANhR7JJYlW/YPIQ=", + "h1:J6vzyr0hQK0rhSB4bmV57gLKEepViEFhjPLBLPnNJp4=", + "h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=", + "h1:UFot9S97tuAPvjKvoxm08sDG/gKYdDK+lMwsZKtLieY=", + "h1:Uvv252S1/53E9YPcuzdyndoXtfiM5nv4x01r9iskQiY=", + "h1:g6QU4Zlnd378s58o+6r522yDg6KXbgpZFjUB6xAYHbY=", + "h1:hxfuRSlsWtFzEGlvwsyrKx9cb4wavAIKa544ozP37aA=", + "h1:jG5U9zNrxZk1WCdcpJwG8AZjrVPCNH7xMgHT+/qTCHY=", + "h1:k7hfjhQ0C/wjIZVSXmDoCtF91JYoaaRgAaC8/GlqmNQ=", + "h1:rYJvvjOwAsVIoto3zEZLNImNdP+Wm2FJJ+HCBuC1rw4=", + "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.1" + constraints = "2.4.1" + hashes = [ + "h1:4hK908va9vH7vp7EPjRlJ2mbBIOstvkzqAB02Eck/yU=", + "h1:6gg0YmPDjvL9CyMxVBs23eiDMvwVdF8WBN6LowfGpUQ=", + "h1:I70Xn54arSqkbsfYCCXGcg86ETQpZVikZGbZ85FxgiY=", + "h1:KMAutL+XqXk88oBUXckcIrFYXDgchaX8ale/cbGS9tY=", + "h1:Lei7JgX2+fJY+qLgQhTQYEFn4R2rSvTgQSF+YeP67uk=", + "h1:QPiKUwsz1ANMsCkHMUE5SrQsxsYLnJni5lnLMwV+CLI=", + "h1:VpNKqlWiwV5e6jgspQ0OqqC1hHZ6uiOzM7/irRAmj4o=", + "h1:coQ14IXe6JlawkqAeWhd5+8Ie7x4bzBSinTjg2Bg0PA=", + "h1:jmhWY/AGeiPdTSGI7AhtotTapFvlki/h0VNfkj1/Ub0=", + "h1:pNEBlwrLd7hJ+bBZwxVM3jfV9HmsueDD83xyXAFEQR8=", + "h1:tjBuzqoTLSm/kY3zt8x41dVL+lbo15Ok77zTLFGL2hk=", + "h1:zjrJHMtysM0ZYRFWp3BWgM5+DJVRXDRs7BJIWDHhRYk=", + "zh:4729bb3f5a6162c6662e51ddd6fce39206632c98109db9969fba65fd063da1af", + "zh:4be9471fcf2dfc72bb70a91f526e3c6e35f5f9f656b2ee6eebcb8acdeeecfb48", + "zh:526625afb495fb01e3cf48cc4bd974340ac0b7c6fe5f1a6daac5f8aed3836f9e", + "zh:6c1287366f8841288108cda7801092940c0dc80a5bad28bf012825c921bcc5ef", + "zh:707b2aa4f93a0d8682e81890bfaa5c7199f3e6d2fdbafbf4f89996cb7f0e291e", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7fb382f0b266e98d5c78a39426b063aefffce566f12b19fd722a93e8127108af", + "zh:89092c1ab478457266ca4c064c4ee77225a2de01a37a1a15c542f978e3db8a32", + "zh:b066a49f3fe3ff49427f2b93b919c7c34ed01236fd594283cb1baf1521566464", + "zh:d5d66dd91f7a58e397e10360f45a92a1a3a66388a67036e30b426b873f58667c", + "zh:df2479f69e15843dc1671127018b7fcc1a0ddc8f93afb61e730c1ad5c3e365e8", + "zh:f1881dec0cd543bc351299a5bc08743716b7eed1043a43da4347fd42b1cc0563", + "zh:fe03df7ead78b43137ef9d805ae638daa86516345d3c44e81a2944d3d256e587", + ] +} diff --git a/terraform/acm.tf b/terraform/acm.tf new file mode 100644 index 0000000..b133629 --- /dev/null +++ b/terraform/acm.tf @@ -0,0 +1,13 @@ +# ACM certificate for doorunlock.seahaven.com. +# +# The certificate is an out-of-band bootstrap dependency and is deliberately NOT +# created here. It was requested in seahaven-prod ahead of this configuration +# (arn:aws:acm:us-east-1:011934824531:certificate/c972e630-047f-4b6d-ae9b-2a7702cbdfce) +# and validated by DNS in the mgmt hosted zone. Declaring an aws_acm_certificate +# resource as well would request a second certificate for the same domain on +# the first apply, so this configuration only reads the issued one. +data "aws_acm_certificate" "doorunlock" { + domain = var.domain_name + statuses = ["ISSUED"] + most_recent = true +} diff --git a/terraform/alarms.tf b/terraform/alarms.tf new file mode 100644 index 0000000..ed65992 --- /dev/null +++ b/terraform/alarms.tf @@ -0,0 +1,44 @@ +locals { + alarm_functions = { + unlock = { + function_name = aws_lambda_function.unlock.function_name + description = "door-unlock-api-unlock Lambda is erroring — physical door unlock calls may be failing" + } + lockdown = { + function_name = aws_lambda_function.lockdown.function_name + description = "door-unlock-api-lockdown Lambda is erroring — lockdown commands may not be executing" + } + authorizer = { + function_name = aws_lambda_function.authorizer.function_name + description = "door-unlock-api-authorizer Lambda is erroring — all API requests will be rejected" + } + poller = { + function_name = aws_lambda_function.poller.function_name + description = "door-unlock-api-lockdown-poller Lambda is erroring — lockdown state polling may be broken" + } + blf_sync = { + function_name = aws_lambda_function.blf_sync.function_name + description = "door-unlock-api-blf-sync Lambda is erroring — department BLF updates may not be applying" + } + } +} + +resource "aws_cloudwatch_metric_alarm" "lambda_errors" { + for_each = local.alarm_functions + + alarm_name = "${each.value.function_name}-errors" + alarm_description = each.value.description + namespace = "AWS/Lambda" + metric_name = "Errors" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [data.aws_sns_topic.site_alerts.arn] + + dimensions = { + FunctionName = each.value.function_name + } +} diff --git a/terraform/apigateway.tf b/terraform/apigateway.tf new file mode 100644 index 0000000..07f280a --- /dev/null +++ b/terraform/apigateway.tf @@ -0,0 +1,131 @@ +resource "aws_apigatewayv2_api" "this" { + name = local.project + protocol_type = "HTTP" + description = "Yealink door unlock and lockdown HTTP API" +} + +# Invoke permission is a Lambda resource policy, not AuthorizerCredentialsArn. +# The credentials-role path returned 500 without invoking the authorizer +# (PLAT-102); resource policy matches the route grants. +resource "aws_apigatewayv2_authorizer" "token" { + api_id = aws_apigatewayv2_api.this.id + name = "${local.project}-token-authorizer" + authorizer_type = "REQUEST" + authorizer_uri = aws_lambda_function.authorizer.invoke_arn + authorizer_payload_format_version = "2.0" + authorizer_result_ttl_in_seconds = 300 + enable_simple_responses = true + identity_sources = ["$request.querystring.token"] +} + +resource "aws_apigatewayv2_integration" "unlock" { + api_id = aws_apigatewayv2_api.this.id + integration_type = "AWS_PROXY" + integration_method = "POST" + integration_uri = aws_lambda_function.unlock.invoke_arn + payload_format_version = "2.0" + timeout_milliseconds = 20000 +} + +resource "aws_apigatewayv2_integration" "lockdown" { + api_id = aws_apigatewayv2_api.this.id + integration_type = "AWS_PROXY" + integration_method = "POST" + integration_uri = aws_lambda_function.lockdown.invoke_arn + payload_format_version = "2.0" + timeout_milliseconds = 15000 +} + +resource "aws_apigatewayv2_route" "unlock" { + api_id = aws_apigatewayv2_api.this.id + route_key = "GET /unlock" + target = "integrations/${aws_apigatewayv2_integration.unlock.id}" + authorization_type = "CUSTOM" + authorizer_id = aws_apigatewayv2_authorizer.token.id +} + +resource "aws_apigatewayv2_route" "lockdown" { + api_id = aws_apigatewayv2_api.this.id + route_key = "GET /lockdown" + target = "integrations/${aws_apigatewayv2_integration.lockdown.id}" + authorization_type = "CUSTOM" + authorizer_id = aws_apigatewayv2_authorizer.token.id +} + +resource "aws_apigatewayv2_route" "lockdown_status" { + api_id = aws_apigatewayv2_api.this.id + route_key = "GET /lockdown/status" + target = "integrations/${aws_apigatewayv2_integration.lockdown.id}" + authorization_type = "CUSTOM" + authorizer_id = aws_apigatewayv2_authorizer.token.id +} + +resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.this.id + name = "$default" + auto_deploy = true + + access_log_settings { + destination_arn = aws_cloudwatch_log_group.api_access.arn + format = "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"requestTime\":\"$context.requestTime\",\"method\":\"$context.httpMethod\",\"routeKey\":\"$context.routeKey\",\"status\":\"$context.status\",\"protocol\":\"$context.protocol\",\"responseLength\":\"$context.responseLength\",\"integrationError\":\"$context.integrationErrorMessage\"}" + } + + default_route_settings { + throttling_burst_limit = 5 + throttling_rate_limit = 2 + } + + depends_on = [ + aws_apigatewayv2_route.unlock, + aws_apigatewayv2_route.lockdown, + aws_apigatewayv2_route.lockdown_status, + ] +} + +resource "aws_lambda_permission" "unlock_route" { + statement_id = "AllowApiGatewayInvokeUnlock" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.unlock.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.this.execution_arn}/*/GET/unlock" +} + +resource "aws_lambda_permission" "lockdown_route" { + statement_id = "AllowApiGatewayInvokeLockdown" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.lockdown.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.this.execution_arn}/*/GET/lockdown" +} + +resource "aws_lambda_permission" "lockdown_status_route" { + statement_id = "AllowApiGatewayInvokeLockdownStatus" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.lockdown.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.this.execution_arn}/*/GET/lockdown/status" +} + +resource "aws_lambda_permission" "authorizer" { + statement_id = "AllowApiGatewayInvokeAuthorizer" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.authorizer.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.this.execution_arn}/authorizers/${aws_apigatewayv2_authorizer.token.id}" +} + +resource "aws_apigatewayv2_domain_name" "this" { + domain_name = var.domain_name + + domain_name_configuration { + certificate_arn = data.aws_acm_certificate.doorunlock.arn + endpoint_type = "REGIONAL" + security_policy = "TLS_1_2" + } +} + +resource "aws_apigatewayv2_api_mapping" "this" { + api_id = aws_apigatewayv2_api.this.id + domain_name = aws_apigatewayv2_domain_name.this.id + stage = aws_apigatewayv2_stage.default.id +} diff --git a/terraform/artifacts.tf b/terraform/artifacts.tf new file mode 100644 index 0000000..902ffb0 --- /dev/null +++ b/terraform/artifacts.tf @@ -0,0 +1,104 @@ +# Lambda packaging. +# +# HCP plan and apply run on separate workers, so a zip written during plan is +# not on disk at apply time. The bytes are therefore carried inside the plan as +# content_base64 on aws_s3_object and uploaded at apply, and the functions +# read from S3 rather than from a local file. +# +# The build itself runs during plan through an external data source: +# local-exec provisioners only run on apply, and archive_file needs build/ to +# already exist when the plan is computed. + +data "external" "package_build" { + program = ["bash", "${path.module}/build_packages_external.sh"] +} + +resource "aws_s3_bucket" "artifacts" { + bucket = local.artifacts_bucket_name + + tags = { + Purpose = "Lambda deployment packages for door-unlock-api" + } +} + +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 +} + +resource "aws_s3_bucket_ownership_controls" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + id = "expire-noncurrent-packages" + status = "Enabled" + + filter {} + + noncurrent_version_expiration { + noncurrent_days = 180 + } + } + + rule { + id = "abort-incomplete-multipart" + status = "Enabled" + + filter {} + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } + + depends_on = [aws_s3_bucket_versioning.artifacts] +} + +data "archive_file" "function" { + for_each = local.function_packages + + type = "zip" + source_dir = "${path.module}/build/functions/${each.key}" + output_path = "${path.module}/build/packages/${each.key}.zip" + + depends_on = [data.external.package_build] +} + +resource "aws_s3_object" "function" { + for_each = local.function_packages + + bucket = aws_s3_bucket.artifacts.id + key = "functions/${each.key}.zip" + content_base64 = filebase64(data.archive_file.function[each.key].output_path) + source_hash = data.archive_file.function[each.key].output_base64sha256 +} diff --git a/terraform/build_packages.sh b/terraform/build_packages.sh new file mode 100755 index 0000000..a813a5b --- /dev/null +++ b/terraform/build_packages.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Bundle the five TypeScript handlers for HCP plan/apply. +# Runs on the Terraform worker during plan (see artifacts.tf). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +BUILD="${ROOT}/build" +REPO="$(cd "${ROOT}/.." && pwd)" +NODE_PREFIX="${BUILD}/.node" + +ensure_node() { + if command -v node >/dev/null 2>&1; then + local major + major="$(node -v | sed 's/^v//;s/\..*//')" + if [ "${major}" -ge 20 ]; then + return + fi + fi + + local version="24.11.1" + local os arch tarball + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + case "$(uname -m)" in + x86_64 | amd64) arch="x64" ;; + arm64 | aarch64) arch="arm64" ;; + *) + echo "error: unsupported arch $(uname -m)" >&2 + exit 1 + ;; + esac + tarball="node-v${version}-${os}-${arch}" + mkdir -p "${NODE_PREFIX}" + curl -fsSL "https://nodejs.org/dist/v${version}/${tarball}.tar.xz" \ + | tar -xJ -C "${NODE_PREFIX}" --strip-components=1 + export PATH="${NODE_PREFIX}/bin:${PATH}" +} + +rm -rf "${BUILD}" +mkdir -p "${BUILD}/packages" +ensure_node + +if [ ! -d "${REPO}/node_modules/esbuild" ]; then + (cd "${REPO}" && npm ci) +fi + +bundle() { + local name="$1" + local src="$2" + local outfile="$3" + mkdir -p "${BUILD}/functions/${name}" + npx --prefix "${REPO}" esbuild "${src}" \ + --bundle \ + --platform=node \ + --target=node24 \ + --outfile="${BUILD}/functions/${name}/${outfile}" \ + --external:@aws-sdk/* +} + +bundle unlock "${REPO}/lambda/unlock/unlock-handler.ts" unlock-handler.js +bundle lockdown "${REPO}/lambda/lockdown/lockdown-handler.ts" lockdown-handler.js +bundle authorizer "${REPO}/lambda/authorizer/authorizer-handler.ts" authorizer-handler.js +bundle poller "${REPO}/lambda/poller/lockdown-poller.ts" lockdown-poller.js +bundle blf_sync "${REPO}/lambda/blf-sync/blf-sync-handler.ts" blf-sync-handler.js diff --git a/terraform/build_packages_external.sh b/terraform/build_packages_external.sh new file mode 100755 index 0000000..38ea006 --- /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)" +# artifacts.tf already launches this file with bash, so +x is not required +# here. Invoke the inner script the same way so HCP plan does not depend on +# the git executable bit. +bash "${ROOT}/build_packages.sh" >&2 + +if command -v sha256sum >/dev/null 2>&1; then + SHA=(sha256sum) +else + SHA=(shasum -a 256) +fi + +hash="$( + { + find -P "${ROOT}/build" -type f -print0 2>/dev/null \ + | sort -z \ + | xargs -0 "${SHA[@]}" + } | "${SHA[@]}" | awk '{print $1}' +)" + +printf '{"status":"ok","hash":"%s"}\n' "${hash}" diff --git a/terraform/data.tf b/terraform/data.tf new file mode 100644 index 0000000..bf5251e --- /dev/null +++ b/terraform/data.tf @@ -0,0 +1,39 @@ +data "aws_caller_identity" "current" {} + +check "correct_account" { + assert { + condition = data.aws_caller_identity.current.account_id == local.account_id + error_message = "This configuration targets account ${local.account_id}, but the credentials resolve to ${data.aws_caller_identity.current.account_id}." + } +} + +data "aws_sns_topic" "site_alerts" { + name = "site-alerts" +} + +# Non-secret door id. Fetching the value is safe for state and fails the plan +# closed if the OOB parameter is missing. SecureString parameters are never +# read through data sources (that would put secret values in HCP state). +data "aws_ssm_parameter" "door_id" { + name = local.door_id_param +} + +check "door_id_present" { + assert { + condition = length(data.aws_ssm_parameter.door_id.value) > 0 + error_message = "SSM parameter ${local.door_id_param} is missing or empty; create it out of band before apply." + } +} + +# Secret *metadata* only (ARN). Never add aws_secretsmanager_secret_version. +data "aws_secretsmanager_secret" "three_cx_domain" { + name = local.three_cx_domain_secret_name +} + +data "aws_secretsmanager_secret" "three_cx_client_id" { + name = local.three_cx_client_id_secret_name +} + +data "aws_secretsmanager_secret" "three_cx_client_secret" { + name = local.three_cx_client_secret_secret_name +} diff --git a/terraform/events.tf b/terraform/events.tf new file mode 100644 index 0000000..912bfb9 --- /dev/null +++ b/terraform/events.tf @@ -0,0 +1,43 @@ +locals { + schedules = { + "lockdown-poller-schedule" = { + description = "Poll LenelS2 lockdown status every minute" + schedule = "rate(1 minute)" + function_arn = aws_lambda_function.poller.arn + function_name = aws_lambda_function.poller.function_name + } + "blf-sync-schedule" = { + description = "Sync 3CX department BLFs daily at 09:00 UTC" + schedule = "cron(0 9 * * ? *)" + function_arn = aws_lambda_function.blf_sync.arn + function_name = aws_lambda_function.blf_sync.function_name + } + } +} + +resource "aws_cloudwatch_event_rule" "schedule" { + for_each = local.schedules + + name = "${local.project}-${each.key}" + description = each.value.description + schedule_expression = each.value.schedule + state = var.enable_schedules ? "ENABLED" : "DISABLED" +} + +resource "aws_cloudwatch_event_target" "schedule" { + for_each = local.schedules + + rule = aws_cloudwatch_event_rule.schedule[each.key].name + target_id = "${local.project}-${each.key}" + arn = each.value.function_arn +} + +resource "aws_lambda_permission" "schedule" { + for_each = local.schedules + + statement_id = "AllowEventBridgeInvoke-${each.key}" + action = "lambda:InvokeFunction" + function_name = each.value.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.schedule[each.key].arn +} diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000..b2300bb --- /dev/null +++ b/terraform/iam.tf @@ -0,0 +1,157 @@ +data "aws_iam_policy_document" "lambda_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "ssm_elements_and_auth" { + statement { + sid = "ReadElementsAndAuth" + effect = "Allow" + actions = ["ssm:GetParameter"] + resources = [ + local.elements_api_key_arn, + local.auth_token_arn, + ] + } +} + +data "aws_iam_policy_document" "ssm_unlock" { + statement { + sid = "ReadUnlockParams" + effect = "Allow" + actions = ["ssm:GetParameter"] + resources = [ + local.elements_api_key_arn, + local.auth_token_arn, + local.door_id_arn, + ] + } +} + +data "aws_iam_policy_document" "ssm_auth_only" { + statement { + sid = "ReadAuthToken" + effect = "Allow" + actions = ["ssm:GetParameter"] + resources = [local.auth_token_arn] + } +} + +data "aws_iam_policy_document" "ssm_elements_only" { + statement { + sid = "ReadElementsApiKey" + effect = "Allow" + actions = ["ssm:GetParameter"] + resources = [local.elements_api_key_arn] + } +} + +data "aws_iam_policy_document" "three_cx_secrets" { + statement { + sid = "ReadThreeCxSecrets" + effect = "Allow" + actions = ["secretsmanager:GetSecretValue"] + resources = [ + data.aws_secretsmanager_secret.three_cx_domain.arn, + data.aws_secretsmanager_secret.three_cx_client_id.arn, + data.aws_secretsmanager_secret.three_cx_client_secret.arn, + ] + } +} + +resource "aws_iam_role" "unlock" { + name = "${local.project}-unlock" + 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" "unlock_basic" { + role = aws_iam_role.unlock.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "unlock" { + name = "unlock" + role = aws_iam_role.unlock.id + policy = data.aws_iam_policy_document.ssm_unlock.json +} + +resource "aws_iam_role" "lockdown" { + name = "${local.project}-lockdown" + 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" "lockdown_basic" { + role = aws_iam_role.lockdown.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "lockdown" { + name = "lockdown" + role = aws_iam_role.lockdown.id + policy = data.aws_iam_policy_document.ssm_elements_and_auth.json +} + +resource "aws_iam_role" "authorizer" { + name = "${local.project}-authorizer" + 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" "authorizer_basic" { + role = aws_iam_role.authorizer.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "authorizer" { + name = "authorizer" + role = aws_iam_role.authorizer.id + policy = data.aws_iam_policy_document.ssm_auth_only.json +} + +resource "aws_iam_role" "poller" { + name = "${local.project}-lockdown-poller" + 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" "poller_basic" { + role = aws_iam_role.poller.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "poller" { + name = "lockdown-poller" + role = aws_iam_role.poller.id + policy = data.aws_iam_policy_document.ssm_elements_only.json +} + +resource "aws_iam_role" "blf_sync" { + name = "${local.project}-blf-sync" + 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" "blf_sync_basic" { + role = aws_iam_role.blf_sync.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "blf_sync" { + name = "blf-sync" + role = aws_iam_role.blf_sync.id + policy = data.aws_iam_policy_document.three_cx_secrets.json +} diff --git a/terraform/lambda.tf b/terraform/lambda.tf new file mode 100644 index 0000000..218b5c7 --- /dev/null +++ b/terraform/lambda.tf @@ -0,0 +1,135 @@ +resource "aws_lambda_function" "unlock" { + function_name = local.function_packages.unlock.function_name + role = aws_iam_role.unlock.arn + handler = local.function_packages.unlock.handler + runtime = "nodejs24.x" + architectures = ["arm64"] + memory_size = local.function_packages.unlock.memory + timeout = local.function_packages.unlock.timeout + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.function["unlock"].key + source_code_hash = data.archive_file.function["unlock"].output_base64sha256 + + environment { + variables = { + ELEMENTS_API_KEY_PARAM = local.elements_api_key_param + AUTH_TOKEN_PARAM = local.auth_token_param + DOOR_ID_PARAM = local.door_id_param + } + } + + depends_on = [ + aws_cloudwatch_log_group.function, + aws_iam_role_policy.unlock, + aws_iam_role_policy_attachment.unlock_basic, + ] +} + +resource "aws_lambda_function" "lockdown" { + function_name = local.function_packages.lockdown.function_name + role = aws_iam_role.lockdown.arn + handler = local.function_packages.lockdown.handler + runtime = "nodejs24.x" + architectures = ["arm64"] + memory_size = local.function_packages.lockdown.memory + timeout = local.function_packages.lockdown.timeout + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.function["lockdown"].key + source_code_hash = data.archive_file.function["lockdown"].output_base64sha256 + + environment { + variables = { + ELEMENTS_API_KEY_PARAM = local.elements_api_key_param + AUTH_TOKEN_PARAM = local.auth_token_param + } + } + + depends_on = [ + aws_cloudwatch_log_group.function, + aws_iam_role_policy.lockdown, + aws_iam_role_policy_attachment.lockdown_basic, + ] +} + +resource "aws_lambda_function" "authorizer" { + function_name = local.function_packages.authorizer.function_name + role = aws_iam_role.authorizer.arn + handler = local.function_packages.authorizer.handler + runtime = "nodejs24.x" + architectures = ["arm64"] + memory_size = local.function_packages.authorizer.memory + timeout = local.function_packages.authorizer.timeout + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.function["authorizer"].key + source_code_hash = data.archive_file.function["authorizer"].output_base64sha256 + + environment { + variables = { + AUTH_TOKEN_PARAM = local.auth_token_param + } + } + + depends_on = [ + aws_cloudwatch_log_group.function, + aws_iam_role_policy.authorizer, + aws_iam_role_policy_attachment.authorizer_basic, + ] +} + +resource "aws_lambda_function" "poller" { + function_name = local.function_packages.poller.function_name + role = aws_iam_role.poller.arn + handler = local.function_packages.poller.handler + runtime = "nodejs24.x" + architectures = ["arm64"] + memory_size = local.function_packages.poller.memory + timeout = local.function_packages.poller.timeout + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.function["poller"].key + source_code_hash = data.archive_file.function["poller"].output_base64sha256 + + environment { + variables = { + ELEMENTS_API_KEY_PARAM = local.elements_api_key_param + } + } + + depends_on = [ + aws_cloudwatch_log_group.function, + aws_iam_role_policy.poller, + aws_iam_role_policy_attachment.poller_basic, + ] +} + +resource "aws_lambda_function" "blf_sync" { + function_name = local.function_packages.blf_sync.function_name + role = aws_iam_role.blf_sync.arn + handler = local.function_packages.blf_sync.handler + runtime = "nodejs24.x" + architectures = ["arm64"] + memory_size = local.function_packages.blf_sync.memory + timeout = local.function_packages.blf_sync.timeout + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.function["blf_sync"].key + source_code_hash = data.archive_file.function["blf_sync"].output_base64sha256 + + environment { + variables = { + THREE_CX_DOMAIN_SECRET = local.three_cx_domain_secret_name + THREE_CX_CLIENT_ID_SECRET = local.three_cx_client_id_secret_name + THREE_CX_CLIENT_SECRET_SECRET = local.three_cx_client_secret_secret_name + DRY_RUN = "false" + } + } + + depends_on = [ + aws_cloudwatch_log_group.function, + aws_iam_role_policy.blf_sync, + aws_iam_role_policy_attachment.blf_sync_basic, + ] +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..249cc28 --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,65 @@ +locals { + project = "door-unlock-api" + account_id = "011934824531" + + boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-seahaven-door-unlock-api" + + artifacts_bucket_name = "${local.project}-artifacts-${local.account_id}" + + ssm_prefix = "/seahaven/door-unlock" + + elements_api_key_param = "${local.ssm_prefix}/elements-api-key" + auth_token_param = "${local.ssm_prefix}/auth-token" + door_id_param = "${local.ssm_prefix}/door-id" + + elements_api_key_arn = "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.elements_api_key_param}" + auth_token_arn = "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.auth_token_param}" + door_id_arn = "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.door_id_param}" + + three_cx_domain_secret_name = "afterhours-shift-manager/3cx-domain" + three_cx_client_id_secret_name = "afterhours-shift-manager/3cx-client-id" + three_cx_client_secret_secret_name = "afterhours-shift-manager/3cx-client-secret" + + function_packages = { + unlock = { + source = "lambda/unlock/unlock-handler.ts" + outfile = "unlock-handler.js" + handler = "unlock-handler.handler" + function_name = "${local.project}-unlock" + timeout = 20 + memory = 128 + } + lockdown = { + source = "lambda/lockdown/lockdown-handler.ts" + outfile = "lockdown-handler.js" + handler = "lockdown-handler.handler" + function_name = "${local.project}-lockdown" + timeout = 15 + memory = 128 + } + authorizer = { + source = "lambda/authorizer/authorizer-handler.ts" + outfile = "authorizer-handler.js" + handler = "authorizer-handler.handler" + function_name = "${local.project}-authorizer" + timeout = 8 + memory = 128 + } + poller = { + source = "lambda/poller/lockdown-poller.ts" + outfile = "lockdown-poller.js" + handler = "lockdown-poller.handler" + function_name = "${local.project}-lockdown-poller" + timeout = 75 + memory = 128 + } + blf_sync = { + source = "lambda/blf-sync/blf-sync-handler.ts" + outfile = "blf-sync-handler.js" + handler = "blf-sync-handler.handler" + function_name = "${local.project}-blf-sync" + timeout = 60 + memory = 256 + } + } +} diff --git a/terraform/logs.tf b/terraform/logs.tf new file mode 100644 index 0000000..529b52c --- /dev/null +++ b/terraform/logs.tf @@ -0,0 +1,11 @@ +resource "aws_cloudwatch_log_group" "function" { + for_each = local.function_packages + + name = "/aws/lambda/${each.value.function_name}" + retention_in_days = 60 +} + +resource "aws_cloudwatch_log_group" "api_access" { + name = "/aws/apigateway/${local.project}" + retention_in_days = 90 +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..489df4b --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,30 @@ +output "api_endpoint" { + description = "HTTP API execute-api URL for pre-DNS live-path proof." + value = aws_apigatewayv2_api.this.api_endpoint +} + +output "custom_domain_target" { + description = "API Gateway regional domain name. DNS A alias target for doorunlock.seahaven.com at cutover." + value = aws_apigatewayv2_domain_name.this.domain_name_configuration[0].target_domain_name +} + +output "custom_domain_hosted_zone_id" { + description = "API Gateway regional hosted zone id for the Route53 alias." + value = aws_apigatewayv2_domain_name.this.domain_name_configuration[0].hosted_zone_id +} + +output "artifacts_bucket_name" { + description = "S3 bucket holding Lambda deployment packages." + value = aws_s3_bucket.artifacts.id +} + +output "function_arns" { + description = "ARNs of every Lambda function in this configuration." + value = { + unlock = aws_lambda_function.unlock.arn + lockdown = aws_lambda_function.lockdown.arn + authorizer = aws_lambda_function.authorizer.arn + poller = aws_lambda_function.poller.arn + blf_sync = aws_lambda_function.blf_sync.arn + } +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..99f5fce --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "seahaven-door-unlock-api" + ManagedBy = "terraform" + Workspace = "seahaven-door-unlock-api-prod" + } + } +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..c6ecd44 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,17 @@ +variable "aws_region" { + description = "Region every resource in this configuration is created in." + type = string + default = "us-east-1" +} + +variable "domain_name" { + description = "Custom domain for the HTTP API. An ISSUED ACM certificate for this domain must already exist in us-east-1 (see acm.tf)." + type = string + default = "doorunlock.seahaven.com" +} + +variable "enable_schedules" { + description = "When true, EventBridge rules invoke the poller and BLF sync. Keep false until live-path proof and DNS cutover." + type = bool + default = false +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..61d0455 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.58.0" + } + archive = { + source = "hashicorp/archive" + version = "2.8.0" + } + external = { + source = "hashicorp/external" + version = "2.4.1" + } + } + + cloud { + organization = "seahaven" + + workspaces { + name = "seahaven-door-unlock-api-prod" + } + } +}