mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 02:33:11 +00:00
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:
parent
bbc113497a
commit
c43bee214c
22 changed files with 1096 additions and 63 deletions
32
.github/workflows/ci-terraform.yaml
vendored
Normal file
32
.github/workflows/ci-terraform.yaml
vendored
Normal 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
|
||||
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
.gitignore
vendored
|
|
@ -6,3 +6,7 @@ cdk.out/
|
|||
|
||||
.env
|
||||
.env.*
|
||||
|
||||
terraform/build/
|
||||
.terraform/
|
||||
*.tfvars
|
||||
|
|
|
|||
96
README.md
96
README.md
|
|
@ -1,81 +1,60 @@
|
|||
# Sea Haven Door Unlock API
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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
104
terraform/.terraform.lock.hcl
generated
Normal 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
13
terraform/acm.tf
Normal 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
44
terraform/alarms.tf
Normal 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
131
terraform/apigateway.tf
Normal 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
104
terraform/artifacts.tf
Normal 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
63
terraform/build_packages.sh
Executable 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
|
||||
25
terraform/build_packages_external.sh
Executable file
25
terraform/build_packages_external.sh
Executable 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
39
terraform/data.tf
Normal 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
43
terraform/events.tf
Normal 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
157
terraform/iam.tf
Normal 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
135
terraform/lambda.tf
Normal 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
65
terraform/locals.tf
Normal 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
11
terraform/logs.tf
Normal 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
30
terraform/outputs.tf
Normal 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
11
terraform/providers.tf
Normal 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
17
terraform/variables.tf
Normal 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
26
terraform/versions.tf
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue