feat(terraform): add hcp terraform for prod door-unlock-api (PLAT-76) (#81)

* feat(terraform): add hcp terraform for prod door-unlock-api

Move deploy off frozen CDK CD onto an HCP workspace that recreates the HTTP API, five Lambdas, disabled EventBridge rules, and alarms in seahaven-prod without a poller VPC.

* docs(readme): link the door unlock api ops page

* fix(terraform): invoke package build via bash

HCP launches the external data source with bash, so the inner build script should not depend on the git executable bit.
This commit is contained in:
Adam Moussa 2026-08-27 22:03:12 +00:00 • committed by GitHub
parent bbc113497a
commit c43bee214c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1096 additions and 63 deletions

32
.github/workflows/ci-terraform.yaml vendored Normal file
View file

@ -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

View file

@ -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

View file

@ -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

4
.gitignore vendored
View file

@ -6,3 +6,7 @@ cdk.out/
.env
.env.*
terraform/build/
.terraform/
*.tfvars

View file

@ -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

104
terraform/.terraform.lock.hcl generated Normal file
View file

@ -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",
]
}

13
terraform/acm.tf Normal file
View file

@ -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
}

44
terraform/alarms.tf Normal file
View file

@ -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
}
}

131
terraform/apigateway.tf Normal file
View file

@ -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
}

104
terraform/artifacts.tf Normal file
View file

@ -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
}

63
terraform/build_packages.sh Executable file
View file

@ -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

View file

@ -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}"

39
terraform/data.tf Normal file
View file

@ -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
}

43
terraform/events.tf Normal file
View file

@ -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
}

157
terraform/iam.tf Normal file
View file

@ -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
}

135
terraform/lambda.tf Normal file
View file

@ -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,
]
}

65
terraform/locals.tf Normal file
View file

@ -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
}
}
}

11
terraform/logs.tf Normal file
View file

@ -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
}

30
terraform/outputs.tf Normal file
View file

@ -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
}
}

11
terraform/providers.tf Normal file
View file

@ -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"
}
}
}

17
terraform/variables.tf Normal file
View file

@ -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
}

26
terraform/versions.tf Normal file
View file

@ -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"
}
}
}