mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 08:13:13 +00:00
Compare commits
90 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4589ffd8e0 | ||
|
|
e15ff6824c | ||
|
|
5d53280ddc | ||
|
|
3f91831666 | ||
|
|
5c2f9627d0 | ||
|
|
e51c5241c1 | ||
|
|
90b94ad091 | ||
|
|
50564bdffb | ||
|
|
44681ca2c8 | ||
|
|
9063d0f438 | ||
|
|
e4083558e1 | ||
|
|
e12d671e1f | ||
|
|
c23f990c6f | ||
|
|
b43335b4a7 | ||
|
|
ad74f02ec4 | ||
|
|
57dc807618 | ||
|
|
61f13bddfe | ||
|
|
cdd810001d | ||
|
|
c43bee214c | ||
|
|
bbc113497a | ||
|
|
39414156ef | ||
|
|
6481f0c23a | ||
|
|
39013e8a65 | ||
|
|
fe89bc6649 | ||
|
|
fd459b12b6 | ||
|
|
a00730675a | ||
|
|
0929d2fb6c | ||
|
|
5d5439acf7 | ||
|
|
fea155279a | ||
|
|
4e84f52379 | ||
|
|
d650b4b945 | ||
|
|
67a577bf90 | ||
| 1e0ed68a11 | |||
|
|
ab7dd977ac | ||
|
|
3787a82eb1 | ||
|
|
8e43dc3805 | ||
|
|
211a00a013 | ||
|
|
c662688ae3 | ||
|
|
bf51bf7aa2 | ||
|
|
f2bc576dfd | ||
|
|
07247d4044 | ||
|
|
c2c45562e4 | ||
|
|
0d40c471b0 | ||
|
|
dec88eac5d | ||
|
|
97402aac9b | ||
|
|
097b8a3fbf | ||
|
|
3283e62ff2 | ||
|
|
a86465d0e2 | ||
|
|
2a20b68584 | ||
|
|
3e869ff373 | ||
|
|
21eaa2b5ab | ||
|
|
00786aaae4 | ||
|
|
769c712bcb | ||
|
|
5f3b963d00 | ||
|
|
fae171f907 | ||
|
|
1edf1c8b93 | ||
|
|
8d314db257 | ||
|
|
86f078de72 | ||
|
|
4265ad90fe | ||
|
|
fe04a199de | ||
|
|
0f27b2553a | ||
|
|
565d4af4fd | ||
|
|
1a3eb51628 | ||
|
|
912a860462 | ||
|
|
118ea41e87 | ||
|
|
09f301f482 | ||
|
|
e18b085af3 | ||
|
|
b0b2444799 | ||
|
|
1aef1ca16c | ||
|
|
c4567a213c | ||
|
|
8b2a1d001d | ||
|
|
b5e75f8306 | ||
|
|
c3d1c399f2 | ||
|
|
1849d3db50 | ||
|
|
222e6fd49d | ||
|
|
dc992880cc | ||
|
|
0e9943fadc | ||
|
|
1b778197e2 | ||
|
|
07f81be535 | ||
|
|
ae8060f5c9 | ||
|
|
23c860187f | ||
|
|
0c7f154c17 | ||
|
|
d0fd4a5e8b | ||
|
|
3f1060a104 | ||
|
|
b1f5c54829 | ||
|
|
97d47bd37b | ||
|
|
e7b4ae272b | ||
|
|
4068a4c34e | ||
|
|
49639436f7 | ||
|
|
ccbc98962b |
51 changed files with 26486 additions and 412 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
|
||||
15
.github/workflows/ci.yaml
vendored
Normal file
15
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||
with:
|
||||
run-cdk-synth: false
|
||||
run-tests: true
|
||||
10
.github/workflows/dependency-review.yml
vendored
Normal file
10
.github/workflows/dependency-review.yml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
name: Dependency Review
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
review:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||
20
.github/workflows/deploy.yaml.frozen
vendored
Normal file
20
.github/workflows/deploy.yaml.frozen
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# 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:
|
||||
workflow_dispatch: # CD frozen for PLAT-76; do not restore push-to-main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||
secrets:
|
||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||
13
.github/workflows/labeler.yml
vendored
Normal file
13
.github/workflows/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
name: Labeler
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -3,3 +3,10 @@ cdk.out/
|
|||
*.js
|
||||
*.d.ts
|
||||
*.js.map
|
||||
|
||||
.env
|
||||
.env.*
|
||||
|
||||
terraform/build/
|
||||
.terraform/
|
||||
*.tfvars
|
||||
|
|
|
|||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
allow-remote=all
|
||||
40
.security-review/suppressions.json
Normal file
40
.security-review/suppressions.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"suppressions": [
|
||||
{
|
||||
"id": "semgrep-detect-child-process-61",
|
||||
"justification": "False positive. CDK local-bundling tryBundle(outputDir) in lib/door-unlock-stack.ts. execSync runs esbuild at cdk-synth time; outputDir is supplied by the CDK framework (staging temp dir) and the other path segments are repo-relative constants. No untrusted input, build-time only on a trusted host, never runs at request time. Verified proof-or-kill 2026-07-13. INFRA-105. Re-pointed from line 55 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
|
||||
},
|
||||
{
|
||||
"id": "semgrep-detect-child-process-90",
|
||||
"justification": "False positive. Same as semgrep-detect-child-process-55: CDK local-bundling esbuild execSync with framework-supplied synth-time outputDir. Build-time only. INFRA-105. Re-pointed from line 84 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
|
||||
},
|
||||
{
|
||||
"id": "semgrep-detect-child-process-128",
|
||||
"justification": "False positive. Same as semgrep-detect-child-process-55: CDK local-bundling esbuild execSync with framework-supplied synth-time outputDir. Build-time only. INFRA-105. Re-pointed from line 122 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
|
||||
},
|
||||
{
|
||||
"id": "semgrep-detect-child-process-210",
|
||||
"justification": "False positive. Same as semgrep-detect-child-process-55: CDK local-bundling esbuild execSync with framework-supplied synth-time outputDir. Build-time only. INFRA-105. Re-pointed from line 202 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
|
||||
},
|
||||
{
|
||||
"id": "semgrep-detect-child-process-262",
|
||||
"justification": "False positive. Same as the other CDK local-bundling esbuild execSync findings in lib/door-unlock-stack.ts. tryBundle(outputDir) for door-unlock-api-blf-sync; outputDir is supplied by the CDK framework at synth time; remaining path segments are repo-relative constants. No untrusted input, build-time only, never runs at request time. PLAT-116."
|
||||
},
|
||||
{
|
||||
"id": "checkov-CKV_AWS_111-234",
|
||||
"justification": "False positive. CDK-generated LogRetention custom-resource role (cdk.out synth output). logs:PutRetentionPolicy/DeleteRetentionPolicy on Resource:* is inherent to the aws-cdk LogRetention singleton construct (log-group names are not known at synth time). Accepted CDK boilerplate, not hand-written IAM. INFRA-105."
|
||||
},
|
||||
{
|
||||
"id": "gitleaks-generic-api-key-5193",
|
||||
"justification": "False positive. A provisioning-template token placeholder (the literal __DOOR_UNLOCK_TOKEN__) in Yealink T54W templates \u2014 not a secret. The real token was rotated in SSM (INFRA-105, param v3 2026-07-06) and the historical live token was removed by the git-filter-repo history scrub; only the placeholder remains."
|
||||
},
|
||||
{
|
||||
"id": "gitleaks-generic-api-key-900",
|
||||
"justification": "False positive. Historical blob of a Yealink provisioning template. After the INFRA-105 history scrub this line holds the __DOOR_UNLOCK_TOKEN__ placeholder only; the pre-scrub live token was rotated in SSM 2026-07-06 and is invalid."
|
||||
},
|
||||
{
|
||||
"id": "gitleaks-generic-api-key-3097",
|
||||
"justification": "False positive. A __DOOR_UNLOCK_TOKEN__ placeholder in a Yealink provisioning template (unlock linekey) \u2014 not a secret. Live token rotated in SSM 2026-07-06; history scrubbed via git-filter-repo (INFRA-105)."
|
||||
}
|
||||
]
|
||||
}
|
||||
32
AGENTS.md
Normal file
32
AGENTS.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Sea Haven Org Governance
|
||||
|
||||
> Full engineering standards: [engineering-handbook](https://github.com/Sea-Haven-Industries/engineering-handbook).
|
||||
|
||||
## Branching and PRs
|
||||
|
||||
- Branch prefixes: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `release/`
|
||||
- PR titles: `type(scope): description (DEV-123)` — Jira key required (DEV/PLAT/SEC)
|
||||
- PR body sections (exact order): **Summary**, **Validation**, **Tests**, **Notes**
|
||||
- Route work: DEV (product), PLAT (infra/platform), SEC (security)
|
||||
|
||||
## Commits
|
||||
|
||||
- Conventional Commits: `type(scope): description`
|
||||
- Allowed types: `feat fix docs style refactor perf test build ci chore revert release`
|
||||
- No AI-attribution footers
|
||||
|
||||
## Secrets and Security
|
||||
|
||||
- Secrets in AWS Secrets Manager only — never in code, env vars, logs, or commits
|
||||
- Non-secret config in SSM Parameter Store
|
||||
|
||||
## CI and SHA Pins
|
||||
|
||||
Pin every GitHub Actions ref to a full commit SHA with an inline version comment:
|
||||
|
||||
```yaml
|
||||
uses: actions/checkout@abc123def456 # v4.1.0
|
||||
```
|
||||
|
||||
The deterministic global pre-push security hook must not be bypassed (`--no-verify` requires
|
||||
explicit approval). Linting stays in CI; do not gate on it locally.
|
||||
86
README.md
86
README.md
|
|
@ -1,20 +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`. The workspace working directory is `terraform/`. VCS file triggers use `trigger-patterns = [terraform/**/*, lambda/**/*]` because `terraform/build_packages.sh` bundles handlers from `lambda/`. A `lambda/`-only merge must still queue a run.
|
||||
|
||||
```
|
||||
Yealink T54W/T58W → HTTPS GET → API Gateway → Lambda → LenelS2 Elements API
|
||||
Yealink T54W/T57W/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. 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
|
||||
- **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. The API Gateway domain mapping is attached at DNS cutover (`attach_custom_domain`). Route 53 stays in mgmt. Until then, proof uses the execute-api URL.
|
||||
- **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 (HCP Terraform)
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
terraform/ # HCP Terraform (working directory)
|
||||
lambda/
|
||||
├── unlock/unlock-handler.ts
|
||||
├── lockdown/lockdown-handler.ts
|
||||
├── poller/lockdown-poller.ts
|
||||
├── authorizer/authorizer-handler.ts
|
||||
└── blf-sync/blf-sync-handler.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.
|
||||
|
||||
EventBridge schedules are created **disabled** (`enable_schedules = false`) until live-path proof and DNS cutover.
|
||||
|
||||
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.
|
||||
|
||||
- **[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
|
||||
|
||||
|
|
@ -36,24 +76,28 @@ 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 |
|
||||
|
||||
## Deployment
|
||||
## CI/CD
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx cdk deploy
|
||||
```
|
||||
- **`.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`. Working directory `terraform/`. `trigger-patterns = [terraform/**/*, lambda/**/*]`. Manual apply. Auto-apply stays off until the stack is sealed.
|
||||
|
||||
Do not run `cdk deploy` against prod. The GitHub CDK deploy workflow is frozen.
|
||||
|
||||
## Phone Configuration
|
||||
|
||||
Configure DSS keys on the Yealink T54W/T58W (via phone web UI or 3CX):
|
||||
Configure DSS keys on the Yealink T54W/T57W/T58W (via phone web UI or 3CX):
|
||||
|
||||
- **Key 2 — Unlock Door**
|
||||
- Type: URL
|
||||
|
|
@ -70,5 +114,17 @@ Custom 3CX templates are included with door unlock and lockdown URLs hardcoded.
|
|||
| Template | Model | Key 2 | Keys 3-4 | Display |
|
||||
|----------|-------|-------|----------|---------|
|
||||
| `yealinkT54W-door-unlock.ph.xml` | T54W | Unlock Door | Managed by 3CX BLF | Dim after 5 min, never sleep |
|
||||
| `yealinkT54W-door-unlock-with-sp.ph.xml` | T54W | Unlock Door | Shared Parking SP1-3 | Dim after 5 min, never sleep |
|
||||
| `yealinkT54W-door-unlock-with-sp.ph.xml` | T54W | Unlock Door | SP1-3 via BLF sync | Dim after 5 min, never sleep |
|
||||
| `yealinkT57W-door-unlock-with-sp.ph.xml` | T57W | Unlock Door | SP1-3 via BLF sync | Dim after 5 min, never sleep |
|
||||
| `yealinkT58W-door-unlock.ph.xml` | T58W | Unlock Door | Lockdown Toggle (Bohemia/Ronkonkoma) | Default T58W display settings |
|
||||
|
||||
Department colleague BLFs are not encoded in these templates. A scheduled Lambda (`door-unlock-api-blf-sync`) writes each Yealink user's 3CX BLF list from that user's first non-DEFAULT 3CX department, excluding the phone's own extension. Extension 100 is always included, even when the XAPI Users list omits it. Unlock and lockdown URL keys stay hardcoded in the template. Shared parking on the T54W+SP and T57W+SP templates is written by the sync job as 3CX SharedParking BLFs.
|
||||
|
||||
| Template | Reserved (never write) | Sync-owned parking | Own line | Managed department BLFs | Personal |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `yealinkT54W-door-unlock.ph.xml` | `blf2` | none | `blf1` | `blf3`–`blf12` | `blf13+` |
|
||||
| `yealinkT54W-door-unlock-with-sp.ph.xml` | `blf2` | `blf3`–`blf5` (SP1–SP3) | `blf1` | `blf6`–`blf15` | `blf16+` |
|
||||
| `yealinkT57W-door-unlock-with-sp.ph.xml` | `blf2` | `blf3`–`blf5` (SP1–SP3) | `blf1` | `blf6`–`blf15` | `blf16+` |
|
||||
| `yealinkT58W-door-unlock.ph.xml` | `blf2`–`blf4` | none | `blf1` | `blf5`–`blf14` | `blf15+` |
|
||||
|
||||
The job authenticates to 3CX XAPI with the existing `afterhours-shift-manager/3cx-*` Secrets Manager values. Invoke `door-unlock-api-blf-sync` with `DRY_RUN=true` for a proposed-XML log and no writes. Set `SMOKE_EXTENSION` to PATCH a single extension. The daily EventBridge rule runs at `09:00 UTC` (05:00 ET during EDT).
|
||||
|
|
|
|||
113
RUNBOOK-token-rotation.md
Normal file
113
RUNBOOK-token-rotation.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Door-Unlock Token Rotation & History Scrub — INFRA-105
|
||||
|
||||
> **Status: EXECUTED 2026-07-06.** Token rotated (SSM `/seahaven/door-unlock/auth-token` v3,
|
||||
> 16:23 ET), all phones re-provisioned, git history scrubbed with `git filter-repo` and
|
||||
> force-pushed (main + this branch + all tags), and the three token-validating Lambdas
|
||||
> force-recycled to flush the never-expiring in-memory token cache (see step 1 note). The
|
||||
> previously committed token is invalid and no longer present on any origin ref.
|
||||
>
|
||||
> **Residual (accepted):** GitHub-controlled read-only `refs/pull/*` refs still reference
|
||||
> pre-scrub commits (only GitHub Support can purge); harmless since the token is rotated.
|
||||
|
||||
## Background
|
||||
|
||||
The Yealink Push-XML provisioning templates hard-coded the live door-system auth token in
|
||||
plaintext, in both the `/unlock` and `/lockdown` query strings, across:
|
||||
|
||||
- `yealinkT58W-door-unlock.ph.xml`
|
||||
- `yealinkT54W-door-unlock.ph.xml`
|
||||
- `yealinkT54W-door-unlock-with-sp.ph.xml`
|
||||
- `yealinkT57W-door-unlock-with-sp.ph.xml`
|
||||
|
||||
The token is the exact secret the API Gateway authorizer (`lambda/authorizer/authorizer-handler.ts`)
|
||||
validates against SSM SecureString `/seahaven/door-unlock/auth-token` via `timingSafeEqual`.
|
||||
It is present in git history since the first template commit `6d5f665`. Anyone with repo read
|
||||
access (or history) could unlock the door or trigger building lockdown over the internet.
|
||||
|
||||
This branch replaces the token with the placeholder `__DOOR_UNLOCK_TOKEN__`. The real value is
|
||||
injected at provisioning time and must never be committed again.
|
||||
|
||||
## Cutover procedure (run in a maintenance window)
|
||||
|
||||
### 1. Rotate the token
|
||||
|
||||
```sh
|
||||
NEW_TOKEN=$(openssl rand -hex 32)
|
||||
aws ssm put-parameter \
|
||||
--name /seahaven/door-unlock/auth-token \
|
||||
--type SecureString --overwrite \
|
||||
--value "$NEW_TOKEN" --region us-east-1
|
||||
|
||||
# CRITICAL: the SSM overwrite ALONE does NOT invalidate the old token.
|
||||
# The authorizer + unlock + lockdown handlers cache the token in module scope with
|
||||
# NO TTL (authorizer-handler.ts getAuthToken, unlock/lockdown loadSecrets), so any warm
|
||||
# Lambda container keeps honoring the OLD token until AWS recycles it — unbounded, up to
|
||||
# hours on a low-traffic API. You MUST force a cold start to guarantee invalidation:
|
||||
for fn in door-unlock-api-authorizer door-unlock-api-unlock door-unlock-api-lockdown; do
|
||||
aws lambda update-function-configuration --region us-east-1 \
|
||||
--function-name "$fn" --description "token-rotation $(date -u +%Y-%m-%dT%H%M%SZ)"
|
||||
done
|
||||
# (The API Gateway authorizer result cache is a separate 5-min TTL keyed on the presented
|
||||
# token string; stale ALLOW entries for the old token expire within 5 min on their own.)
|
||||
```
|
||||
|
||||
> **Design debt (INFRA-105 follow-up):** give the module-scope token cache a short TTL (or
|
||||
> read SSM per-invocation) so future rotations are self-healing and this manual cold-start
|
||||
> step is unnecessary. Tracked as a confirmed HIGH from the 2026-07-06 `/sh-security-review`.
|
||||
|
||||
### 2. Re-provision all Yealink phones
|
||||
|
||||
Render each template with the new token (do NOT commit the rendered output):
|
||||
|
||||
```sh
|
||||
for f in yealink*.ph.xml; do
|
||||
sed "s/__DOOR_UNLOCK_TOKEN__/$NEW_TOKEN/g" "$f" > "/tmp/rendered-$f"
|
||||
done
|
||||
```
|
||||
|
||||
Push `/tmp/rendered-*` to the phones via the 3CX/Yealink provisioning path. Verify one phone's
|
||||
`/unlock` key works against the new token before doing the rest. Delete the rendered files after.
|
||||
|
||||
> The token must physically live on the phones — type-17 URL keys can't send headers — so the
|
||||
> rendered XML always contains the secret. Keep it out of source control and off shared storage.
|
||||
|
||||
### 3. Scrub git history
|
||||
|
||||
```sh
|
||||
# git-filter-repo (preferred)
|
||||
# Put the old 64-hex token value in a gitignored/temp file, never inline in this doc:
|
||||
git filter-repo --replace-text <(printf '%s==>__DOOR_UNLOCK_TOKEN__\n' "$OLD_TOKEN")
|
||||
# then force-push every ref; coordinate with anyone holding clones (they must re-clone)
|
||||
git push --force --all && git push --force --tags
|
||||
```
|
||||
|
||||
> **Do it in a mirror clone** (`git clone --mirror`), scrub there, and force-push, rather than
|
||||
> filter-repo'ing your working clone. Then re-sync your local clone: delete stale local tags and
|
||||
> `git fetch --tags --force`, and delete any local branches whose tips predate the scrub (they
|
||||
> retain the old blob even after the scrub). Verify: `for r in $(git for-each-ref
|
||||
> --format='%(refname)'); do git grep -q "$OLD_TOKEN" "$r" && echo "HIT $r"; done`.
|
||||
|
||||
After force-push, the old token is gone from history but is **already compromised** — rotation
|
||||
(step 1, including the forced Lambda cold start) is what actually invalidates it, not the scrub.
|
||||
Note `refs/pull/*` on GitHub are read-only and cannot be force-updated — they retain the old
|
||||
blob until GitHub Support purges them; acceptable once the token is rotated.
|
||||
|
||||
## Gates before merge/deploy
|
||||
|
||||
- **/sh-security-review** — auth surface, required. Run 2026-07-06: gate = BLOCK on 5 confirmed
|
||||
pre-existing auth-design HIGHs (never-expiring token cache ×2, no replay/IP restriction,
|
||||
lockdown toggle de-escalation, unlock-during-lockdown). None are introduced by this branch,
|
||||
which adds only this runbook. The residual-secret concern (SC-01) was REFUTED post-scrub.
|
||||
Tracked for follow-up; see the project memory. Merge decision is Adam's given the block is on
|
||||
legacy design, not this diff.
|
||||
- **GPT-4.1 cross-review** — only if a handler/IAM change accompanies this (placeholdering alone does not).
|
||||
- Deploy-then-merge per handbook; the pre-push scanner hook passes with machine-level suppressions
|
||||
for the placeholder/CDK-bundling/LogRetention false-positives (see
|
||||
`~/.config/sea-haven/security-review/seahaven-door-unlock-api/suppressions.json`).
|
||||
|
||||
## Follow-up design fix
|
||||
|
||||
Treat the provisioning XML as a generated/secret artifact: keep only the placeholdered template
|
||||
in git, render with the SSM value at provisioning time. Consider a small provisioning script in
|
||||
this repo that pulls the token from SSM and renders, so the secret is never written to disk longer
|
||||
than the push requires.
|
||||
|
|
@ -5,6 +5,6 @@ import { DoorUnlockStack } from "../lib/door-unlock-stack";
|
|||
|
||||
const app = new cdk.App();
|
||||
new DoorUnlockStack(app, "door-unlock-api", {
|
||||
stackName: "SeaHavenDoorUnlockStack",
|
||||
stackName: "seahaven-door-unlock-api",
|
||||
env: { account: "328440206208", region: "us-east-1" },
|
||||
});
|
||||
|
|
|
|||
2
cdk.json
2
cdk.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"app": "npx ts-node bin/app.ts",
|
||||
"app": "npx tsx bin/app.ts",
|
||||
"watch": {
|
||||
"include": ["**"],
|
||||
"exclude": [
|
||||
|
|
|
|||
BIN
firmware/T5XW-96.87.0.22.rom
Normal file
BIN
firmware/T5XW-96.87.0.22.rom
Normal file
Binary file not shown.
62
lambda/authorizer/authorizer-handler.ts
Normal file
62
lambda/authorizer/authorizer-handler.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
SSMClient,
|
||||
GetParameterCommand,
|
||||
} from "@aws-sdk/client-ssm";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
const ssm = new SSMClient({});
|
||||
|
||||
function tokensMatch(provided: string, expected: string): boolean {
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
// timingSafeEqual throws on unequal-length buffers; check length first.
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
let cachedAuthToken: string | undefined;
|
||||
|
||||
async function getAuthToken(): Promise<string> {
|
||||
if (cachedAuthToken) return cachedAuthToken;
|
||||
const res = await ssm.send(
|
||||
new GetParameterCommand({
|
||||
Name: process.env.AUTH_TOKEN_PARAM!,
|
||||
WithDecryption: true,
|
||||
})
|
||||
);
|
||||
cachedAuthToken = res.Parameter!.Value!;
|
||||
return cachedAuthToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Gateway HTTP API (payload v2.0) REQUEST Lambda authorizer.
|
||||
*
|
||||
* Validates the SAME `?token=` query-string parameter the Yealink XML Browser
|
||||
* keys already send (type-17 keys issue a plain GET and cannot send headers or
|
||||
* a POST body), so this authorizer is transparent to the phones. An
|
||||
* unauthenticated or wrong-token request is now rejected at the gateway with
|
||||
* 401/403 before any handler Lambda is invoked.
|
||||
*
|
||||
* Returns the simple-response shape ({ isAuthorized }) which the routes are
|
||||
* configured for (enableSimpleResponses: true).
|
||||
*/
|
||||
export async function handler(event: {
|
||||
queryStringParameters?: Record<string, string>;
|
||||
}): Promise<{ isAuthorized: boolean }> {
|
||||
const token = event.queryStringParameters?.token;
|
||||
if (!token) {
|
||||
return { isAuthorized: false };
|
||||
}
|
||||
|
||||
let expected: string;
|
||||
try {
|
||||
expected = await getAuthToken();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
JSON.stringify({ action: "authorize", status: "error", reason: "ssm_failure", error: String(err) })
|
||||
);
|
||||
// Fail closed: deny if the token cannot be loaded.
|
||||
return { isAuthorized: false };
|
||||
}
|
||||
|
||||
return { isAuthorized: tokensMatch(token, expected) };
|
||||
}
|
||||
192
lambda/blf-sync/blf-sync-handler.ts
Normal file
192
lambda/blf-sync/blf-sync-handler.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import {
|
||||
SecretsManagerClient,
|
||||
GetSecretValueCommand,
|
||||
} from "@aws-sdk/client-secrets-manager";
|
||||
import {
|
||||
ALWAYS_INCLUDE_EXTENSIONS,
|
||||
addAlwaysIncludedColleagues,
|
||||
groupUsersByDepartment,
|
||||
isEligibleColleague,
|
||||
primaryDepartmentName,
|
||||
splitPeerName,
|
||||
type ColleagueRef,
|
||||
type SyncUser,
|
||||
} from "./department";
|
||||
import { mergeDepartmentBlfs } from "./merge";
|
||||
import { resolveTemplateId, SLOT_CONTRACT } from "./slot-contract";
|
||||
import { ThreeCxClient } from "./three-cx-client";
|
||||
|
||||
const secrets = new SecretsManagerClient({});
|
||||
|
||||
export interface BlfSyncEvent {
|
||||
dryRun?: boolean;
|
||||
smokeExtension?: string;
|
||||
}
|
||||
|
||||
function parseSecretString(raw: string | undefined): string {
|
||||
if (!raw) {
|
||||
throw new Error("empty secret");
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return typeof parsed === "string" ? parsed : raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function readSecret(name: string): Promise<string> {
|
||||
const res = await secrets.send(new GetSecretValueCommand({ SecretId: name }));
|
||||
return parseSecretString(res.SecretString);
|
||||
}
|
||||
|
||||
function resolveTemplate(user: SyncUser) {
|
||||
const phones = user.Phones ?? [];
|
||||
for (const phone of phones) {
|
||||
const id = resolveTemplateId(phone.TemplateName, phone.Name);
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handler(event: BlfSyncEvent = {}) {
|
||||
const dryRun = event.dryRun ?? process.env.DRY_RUN !== "false";
|
||||
const smokeExtension = (event.smokeExtension ?? process.env.SMOKE_EXTENSION ?? "").trim();
|
||||
|
||||
const domain = await readSecret(process.env.THREE_CX_DOMAIN_SECRET!);
|
||||
const clientId = await readSecret(process.env.THREE_CX_CLIENT_ID_SECRET!);
|
||||
const clientSecret = await readSecret(process.env.THREE_CX_CLIENT_SECRET_SECRET!);
|
||||
const client = new ThreeCxClient({ domain, clientId, clientSecret });
|
||||
|
||||
const users = await client.listUsers<SyncUser>();
|
||||
const byDept = groupUsersByDepartment(users);
|
||||
const extraColleagues: ColleagueRef[] = [];
|
||||
for (const number of ALWAYS_INCLUDE_EXTENSIONS) {
|
||||
const already = users.find((u) => u.Number === number);
|
||||
if (already && isEligibleColleague(already)) {
|
||||
extraColleagues.push({
|
||||
id: already.Id,
|
||||
number: already.Number,
|
||||
firstName: already.FirstName ?? "",
|
||||
lastName: already.LastName ?? "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const peer = await client.getPeerByNumber(number);
|
||||
if (!peer) {
|
||||
console.log(JSON.stringify({ action: "blf_sync_skip_extra", reason: "peer_not_found", extension: number }));
|
||||
continue;
|
||||
}
|
||||
const names = splitPeerName(peer.Name);
|
||||
extraColleagues.push({
|
||||
id: peer.Id,
|
||||
number: peer.Number,
|
||||
firstName: names.firstName,
|
||||
lastName: names.lastName,
|
||||
});
|
||||
}
|
||||
|
||||
let patched = 0;
|
||||
let unchanged = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const user of users) {
|
||||
const templateId = resolveTemplate(user);
|
||||
if (!templateId) {
|
||||
skipped += 1;
|
||||
console.log(JSON.stringify({
|
||||
action: "blf_sync_skip",
|
||||
reason: "unknown_template",
|
||||
extension: user.Number,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const dept = primaryDepartmentName(user);
|
||||
if (!dept) {
|
||||
skipped += 1;
|
||||
console.log(JSON.stringify({
|
||||
action: "blf_sync_skip",
|
||||
reason: "no_department",
|
||||
extension: user.Number,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const colleagues = addAlwaysIncludedColleagues(
|
||||
(byDept.get(dept) ?? [])
|
||||
.filter(isEligibleColleague)
|
||||
.map((u) => ({
|
||||
id: u.Id,
|
||||
number: u.Number,
|
||||
firstName: u.FirstName ?? "",
|
||||
lastName: u.LastName ?? "",
|
||||
})),
|
||||
extraColleagues
|
||||
);
|
||||
|
||||
const result = mergeDepartmentBlfs({
|
||||
currentXml: user.Blfs,
|
||||
selfExtension: user.Number,
|
||||
colleagues,
|
||||
contract: SLOT_CONTRACT[templateId],
|
||||
});
|
||||
|
||||
const logBase = {
|
||||
extension: user.Number,
|
||||
department: dept,
|
||||
templateId,
|
||||
placed: result.placed,
|
||||
overflow: result.overflow,
|
||||
changed: result.changed,
|
||||
};
|
||||
|
||||
if (!result.changed) {
|
||||
unchanged += 1;
|
||||
console.log(JSON.stringify({ action: "blf_sync_unchanged", ...logBase }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dryRun || (smokeExtension && user.Number !== smokeExtension)) {
|
||||
console.log(JSON.stringify({
|
||||
action: dryRun ? "blf_sync_dry_run" : "blf_sync_skipped_not_smoke",
|
||||
...logBase,
|
||||
proposedBlfs: result.xml,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const write = await client.patchUserBlfs(user.Id, result.xml);
|
||||
if (write.status >= 200 && write.status < 300) {
|
||||
patched += 1;
|
||||
console.log(JSON.stringify({ action: "blf_sync_patched", ...logBase, status: write.status }));
|
||||
} else {
|
||||
failed += 1;
|
||||
console.error(JSON.stringify({
|
||||
action: "blf_sync_patch_failed",
|
||||
...logBase,
|
||||
status: write.status,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
action: "blf_sync_complete",
|
||||
dryRun,
|
||||
smokeExtension: smokeExtension || undefined,
|
||||
visibleUsers: users.length,
|
||||
departments: [...byDept.keys()],
|
||||
patched,
|
||||
unchanged,
|
||||
skipped,
|
||||
failed,
|
||||
};
|
||||
console.log(JSON.stringify(summary));
|
||||
if (failed > 0) {
|
||||
throw new Error(`blf sync patch failed for ${failed} user(s)`);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
222
lambda/blf-sync/blf-sync.test.ts
Normal file
222
lambda/blf-sync/blf-sync.test.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { blfsEqual, parseBlfs, serializeBlfs } from "./blf-xml";
|
||||
import { addAlwaysIncludedColleagues, isEligibleColleague, primaryDepartmentName, splitPeerName } from "./department";
|
||||
import { mergeDepartmentBlfs } from "./merge";
|
||||
import { resolveTemplateId, SLOT_CONTRACT } from "./slot-contract";
|
||||
|
||||
const colleagues = [
|
||||
{ id: 29, number: "100", firstName: "Adam", lastName: "Moussa" },
|
||||
{ id: 33, number: "111", firstName: "Alyssa", lastName: "Ficarra" },
|
||||
{ id: 38, number: "114", firstName: "Ashley", lastName: "Fedner" },
|
||||
{ id: 66, number: "115", firstName: "Derrick", lastName: "Smith" },
|
||||
{ id: 68, number: "113", firstName: "Sarah", lastName: "May" },
|
||||
{ id: 69, number: "116", firstName: "Cindy", lastName: "Vallecillo" },
|
||||
];
|
||||
|
||||
test("resolveTemplateId matches longest Sea Haven template first", () => {
|
||||
assert.equal(
|
||||
resolveTemplateId("yealinkT54W-door-unlock-with-sp.ph.xml"),
|
||||
"t54w-door-unlock-with-sp"
|
||||
);
|
||||
assert.equal(
|
||||
resolveTemplateId("yealinkT57W-door-unlock-with-sp.ph.xml"),
|
||||
"t57w-door-unlock-with-sp"
|
||||
);
|
||||
assert.equal(resolveTemplateId("yealinkT54W-door-unlock.ph.xml"), "t54w-door-unlock");
|
||||
assert.equal(resolveTemplateId("yealinkT58W-door-unlock.ph.xml"), "t58w-door-unlock");
|
||||
assert.equal(resolveTemplateId("yealinkT54W.ph.xml"), undefined);
|
||||
assert.equal(resolveTemplateId("yealinkT57W.ph.xml"), undefined);
|
||||
});
|
||||
|
||||
test("T57W+SP slot contract matches T54W+SP", () => {
|
||||
assert.deepEqual(
|
||||
SLOT_CONTRACT["t57w-door-unlock-with-sp"],
|
||||
SLOT_CONTRACT["t54w-door-unlock-with-sp"]
|
||||
);
|
||||
});
|
||||
|
||||
test("parseBlfs reads Line self-close and extension BLFs", () => {
|
||||
const xml =
|
||||
'<PhoneDevice><BLFS><BLF ID="-1" BLFNo="1" BLFType="Line" BLFTypeID="6" /><BLF ID="91" BLFNo="6" BLFType="BLF" BLFTypeID="0">112</BLF></BLFS></PhoneDevice>';
|
||||
assert.deepEqual(parseBlfs(xml), [
|
||||
{ id: "-1", blfNo: 1, blfType: "Line", blfTypeId: "6", value: "" },
|
||||
{ id: "91", blfNo: 6, blfType: "BLF", blfTypeId: "0", value: "112" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("merge skips self and sorts by extension", () => {
|
||||
const result = mergeDepartmentBlfs({
|
||||
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||
selfExtension: "114",
|
||||
colleagues,
|
||||
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||
});
|
||||
const managed = parseBlfs(result.xml).filter((e) => e.blfNo >= 6);
|
||||
assert.deepEqual(
|
||||
managed.map((e) => e.value),
|
||||
["100", "111", "113", "115", "116"]
|
||||
);
|
||||
assert.equal(result.overflow, 0);
|
||||
assert.equal(result.placed, 5);
|
||||
assert.equal(managed.some((e) => e.value === "114"), false);
|
||||
});
|
||||
|
||||
test("merge never writes reserved T54W+SP unlock key 2", () => {
|
||||
const current =
|
||||
'<PhoneDevice><BLFS><BLF ID="33" BLFNo="1" BLFType="BLF" BLFTypeID="0">111</BLF><BLF ID="91" BLFNo="2" BLFType="BLF" BLFTypeID="0">112</BLF><BLF ID="38" BLFNo="3" BLFType="BLF" BLFTypeID="0">114</BLF></BLFS></PhoneDevice>';
|
||||
const result = mergeDepartmentBlfs({
|
||||
currentXml: current,
|
||||
selfExtension: "113",
|
||||
colleagues,
|
||||
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||
});
|
||||
const entries = parseBlfs(result.xml);
|
||||
assert.equal(entries.some((e) => e.blfNo === 2), false);
|
||||
assert.equal(entries.find((e) => e.blfNo === 1)?.blfType, "Line");
|
||||
});
|
||||
|
||||
test("merge writes SP1-SP3 on T54W+SP keys 3-5", () => {
|
||||
const result = mergeDepartmentBlfs({
|
||||
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||
selfExtension: "111",
|
||||
colleagues,
|
||||
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||
});
|
||||
const parking = parseBlfs(result.xml).filter((e) => e.blfNo >= 3 && e.blfNo <= 5);
|
||||
assert.deepEqual(
|
||||
parking.map((e) => ({ blfNo: e.blfNo, type: e.blfType, value: e.value })),
|
||||
[
|
||||
{ blfNo: 3, type: "SharedParking", value: "SP1" },
|
||||
{ blfNo: 4, type: "SharedParking", value: "SP2" },
|
||||
{ blfNo: 5, type: "SharedParking", value: "SP3" },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("merge preserves personal BLFs outside the managed range", () => {
|
||||
const current =
|
||||
'<PhoneDevice><BLFS><BLF ID="-1" BLFNo="1" BLFType="Line" BLFTypeID="6" /><BLF ID="9" BLFNo="16" BLFType="BLF" BLFTypeID="0">215</BLF></BLFS></PhoneDevice>';
|
||||
const result = mergeDepartmentBlfs({
|
||||
currentXml: current,
|
||||
selfExtension: "116",
|
||||
colleagues,
|
||||
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||
});
|
||||
const personal = parseBlfs(result.xml).find((e) => e.blfNo === 16);
|
||||
assert.equal(personal?.value, "215");
|
||||
assert.equal(personal?.id, "9");
|
||||
});
|
||||
|
||||
test("merge caps overflow at the managed slot count", () => {
|
||||
const many = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: 200 + i,
|
||||
number: String(300 + i),
|
||||
firstName: "User",
|
||||
lastName: `Z${String(i).padStart(2, "0")}`,
|
||||
}));
|
||||
const result = mergeDepartmentBlfs({
|
||||
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||
selfExtension: "111",
|
||||
colleagues: many,
|
||||
contract: SLOT_CONTRACT["t54w-door-unlock"],
|
||||
});
|
||||
assert.equal(result.placed, 10);
|
||||
assert.equal(result.overflow, 2);
|
||||
const managed = parseBlfs(result.xml).filter((e) => e.blfNo >= 3 && e.blfNo <= 12);
|
||||
assert.equal(managed.length, 10);
|
||||
assert.equal(managed[0]?.blfNo, 3);
|
||||
assert.equal(managed[9]?.blfNo, 12);
|
||||
});
|
||||
|
||||
test("merge is a no-op when XML already matches", () => {
|
||||
const first = mergeDepartmentBlfs({
|
||||
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||
selfExtension: "116",
|
||||
colleagues,
|
||||
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||
});
|
||||
const second = mergeDepartmentBlfs({
|
||||
currentXml: first.xml,
|
||||
selfExtension: "116",
|
||||
colleagues,
|
||||
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||
});
|
||||
assert.equal(first.changed, true);
|
||||
assert.equal(second.changed, false);
|
||||
assert.equal(blfsEqual(first.xml, second.xml), true);
|
||||
});
|
||||
|
||||
test("merge keeps assigned shared-parking IDs so later runs are a no-op", () => {
|
||||
const first = mergeDepartmentBlfs({
|
||||
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||
selfExtension: "116",
|
||||
colleagues,
|
||||
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||
});
|
||||
const assigned = serializeBlfs(
|
||||
parseBlfs(first.xml).map((entry) =>
|
||||
entry.blfType === "SharedParking" || entry.blfType === "Line"
|
||||
? { ...entry, id: String(900 + entry.blfNo) }
|
||||
: entry
|
||||
)
|
||||
);
|
||||
const second = mergeDepartmentBlfs({
|
||||
currentXml: assigned,
|
||||
selfExtension: "116",
|
||||
colleagues,
|
||||
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||
});
|
||||
assert.equal(second.changed, false);
|
||||
const parking = parseBlfs(second.xml).filter((e) => e.blfNo >= 3 && e.blfNo <= 5);
|
||||
assert.deepEqual(
|
||||
parking.map((e) => e.id),
|
||||
["903", "904", "905"]
|
||||
);
|
||||
});
|
||||
|
||||
test("serializeBlfs normalizes empty lists", () => {
|
||||
assert.equal(serializeBlfs([]), "<PhoneDevice><BLFS></BLFS></PhoneDevice>");
|
||||
assert.equal(blfsEqual("<PhoneDevice><BLFS/></PhoneDevice>", serializeBlfs([])), true);
|
||||
});
|
||||
|
||||
test("primaryDepartmentName prefers Office over DEFAULT", () => {
|
||||
assert.equal(
|
||||
primaryDepartmentName({
|
||||
Id: 1,
|
||||
Number: "111",
|
||||
Groups: [{ Id: 28, Name: "DEFAULT" }, { Id: 142, Name: "Office" }],
|
||||
}),
|
||||
"Office"
|
||||
);
|
||||
assert.equal(
|
||||
primaryDepartmentName({
|
||||
Id: 2,
|
||||
Number: "201",
|
||||
Groups: [{ Id: 28, Name: "DEFAULT" }],
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("addAlwaysIncludedColleagues injects ext 100 when missing", () => {
|
||||
const with100 = addAlwaysIncludedColleagues(colleagues, [
|
||||
{ id: 29, number: "100", firstName: "Adam", lastName: "Moussa" },
|
||||
]);
|
||||
assert.equal(with100.some((c) => c.number === "100"), true);
|
||||
const again = addAlwaysIncludedColleagues(with100, [
|
||||
{ id: 29, number: "100", firstName: "Adam", lastName: "Moussa" },
|
||||
]);
|
||||
assert.equal(again.filter((c) => c.number === "100").length, 1);
|
||||
});
|
||||
|
||||
test("splitPeerName uses last token as last name", () => {
|
||||
assert.deepEqual(splitPeerName("Adam Moussa"), { firstName: "Adam", lastName: "Moussa" });
|
||||
});
|
||||
|
||||
test("isEligibleColleague skips queues and voicemail", () => {
|
||||
assert.equal(isEligibleColleague({ Id: 1, Number: "111", FirstName: "A", LastName: "B" }), true);
|
||||
assert.equal(isEligibleColleague({ Id: 2, Number: "801", FirstName: "After", LastName: "Hours" }), false);
|
||||
assert.equal(isEligibleColleague({ Id: 3, Number: "201", FirstName: "Voicemail" }), false);
|
||||
assert.equal(isEligibleColleague({ Id: 4, Number: "scheduler" }), false);
|
||||
});
|
||||
63
lambda/blf-sync/blf-xml.ts
Normal file
63
lambda/blf-sync/blf-xml.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
export interface BlfEntry {
|
||||
id: string;
|
||||
blfNo: number;
|
||||
blfType: string;
|
||||
blfTypeId: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const BLF_TAG =
|
||||
/<BLF\b([^>/]*)(?:\s*\/>|>([\s\S]*?)<\/BLF>)/gi;
|
||||
|
||||
function attr(attrs: string, name: string): string {
|
||||
const match = attrs.match(new RegExp(`\\b${name}="([^"]*)"`, "i"));
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function parseBlfs(xml: string | undefined | null): BlfEntry[] {
|
||||
if (!xml) {
|
||||
return [];
|
||||
}
|
||||
const entries: BlfEntry[] = [];
|
||||
for (const match of xml.matchAll(BLF_TAG)) {
|
||||
const attrs = match[1] ?? "";
|
||||
const blfNo = Number.parseInt(attr(attrs, "BLFNo"), 10);
|
||||
if (!Number.isFinite(blfNo)) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
id: attr(attrs, "ID") || "-1",
|
||||
blfNo,
|
||||
blfType: attr(attrs, "BLFType") || "BLF",
|
||||
blfTypeId: attr(attrs, "BLFTypeID") || "0",
|
||||
value: (match[2] ?? "").trim(),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function serializeBlfs(entries: BlfEntry[]): string {
|
||||
const sorted = [...entries].sort((a, b) => a.blfNo - b.blfNo);
|
||||
const inner = sorted
|
||||
.map((entry) => {
|
||||
const attrs = `ID="${escapeXml(entry.id)}" BLFNo="${entry.blfNo}" BLFType="${escapeXml(entry.blfType)}" BLFTypeID="${escapeXml(entry.blfTypeId)}"`;
|
||||
if (entry.value === "") {
|
||||
return `<BLF ${attrs} />`;
|
||||
}
|
||||
return `<BLF ${attrs}>${escapeXml(entry.value)}</BLF>`;
|
||||
})
|
||||
.join("");
|
||||
return `<PhoneDevice><BLFS>${inner}</BLFS></PhoneDevice>`;
|
||||
}
|
||||
|
||||
export function blfsEqual(a: string | undefined | null, b: string): boolean {
|
||||
return serializeBlfs(parseBlfs(a)) === serializeBlfs(parseBlfs(b));
|
||||
}
|
||||
95
lambda/blf-sync/department.ts
Normal file
95
lambda/blf-sync/department.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
export interface UserGroup {
|
||||
Id?: number;
|
||||
Name?: string;
|
||||
}
|
||||
|
||||
export interface SyncUser {
|
||||
Id: number;
|
||||
Number: string;
|
||||
FirstName?: string;
|
||||
LastName?: string;
|
||||
PrimaryGroupId?: number;
|
||||
Groups?: UserGroup[];
|
||||
Blfs?: string;
|
||||
Phones?: Array<{
|
||||
TemplateName?: string;
|
||||
Name?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const QUEUE_EXTENSION_MIN = 800;
|
||||
|
||||
export const ALWAYS_INCLUDE_EXTENSIONS = ["100"] as const;
|
||||
|
||||
export interface ColleagueRef {
|
||||
id: number;
|
||||
number: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export function splitPeerName(name: string | undefined): { firstName: string; lastName: string } {
|
||||
const parts = (name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return { firstName: "", lastName: "" };
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return { firstName: parts[0], lastName: "" };
|
||||
}
|
||||
return { firstName: parts.slice(0, -1).join(" "), lastName: parts[parts.length - 1] };
|
||||
}
|
||||
|
||||
export function addAlwaysIncludedColleagues(
|
||||
colleagues: ColleagueRef[],
|
||||
extras: ColleagueRef[]
|
||||
): ColleagueRef[] {
|
||||
const next = [...colleagues];
|
||||
for (const extra of extras) {
|
||||
if (!next.some((c) => c.number === extra.number)) {
|
||||
next.push(extra);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isEligibleColleague(user: SyncUser): boolean {
|
||||
if (!/^\d+$/.test(user.Number)) {
|
||||
return false;
|
||||
}
|
||||
if (Number.parseInt(user.Number, 10) >= QUEUE_EXTENSION_MIN) {
|
||||
return false;
|
||||
}
|
||||
const first = (user.FirstName ?? "").trim();
|
||||
const last = (user.LastName ?? "").trim();
|
||||
if (!first && !last) {
|
||||
return false;
|
||||
}
|
||||
const full = `${first} ${last}`.trim().toLowerCase();
|
||||
if (full === "voicemail") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function primaryDepartmentName(user: SyncUser): string | undefined {
|
||||
const groups = user.Groups ?? [];
|
||||
const named = groups.find((g) => (g.Name ?? "").toUpperCase() !== "DEFAULT" && (g.Name ?? "").trim() !== "");
|
||||
if (named?.Name) {
|
||||
return named.Name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function groupUsersByDepartment(users: SyncUser[]): Map<string, SyncUser[]> {
|
||||
const byDept = new Map<string, SyncUser[]>();
|
||||
for (const user of users) {
|
||||
const dept = primaryDepartmentName(user);
|
||||
if (!dept) {
|
||||
continue;
|
||||
}
|
||||
const list = byDept.get(dept) ?? [];
|
||||
list.push(user);
|
||||
byDept.set(dept, list);
|
||||
}
|
||||
return byDept;
|
||||
}
|
||||
102
lambda/blf-sync/merge.ts
Normal file
102
lambda/blf-sync/merge.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import {
|
||||
type BlfEntry,
|
||||
parseBlfs,
|
||||
serializeBlfs,
|
||||
} from "./blf-xml";
|
||||
import {
|
||||
type SlotContract,
|
||||
isManagedSlot,
|
||||
isReservedSlot,
|
||||
isSharedParkingSlot,
|
||||
managedSlotCount,
|
||||
} from "./slot-contract";
|
||||
|
||||
export interface Colleague {
|
||||
id: number;
|
||||
number: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
xml: string;
|
||||
changed: boolean;
|
||||
overflow: number;
|
||||
placed: number;
|
||||
}
|
||||
|
||||
export function sortColleagues(colleagues: Colleague[]): Colleague[] {
|
||||
return [...colleagues].sort((a, b) => a.number.localeCompare(b.number, "en", { numeric: true }));
|
||||
}
|
||||
|
||||
export function mergeDepartmentBlfs(input: {
|
||||
currentXml: string | undefined | null;
|
||||
selfExtension: string;
|
||||
colleagues: Colleague[];
|
||||
contract: SlotContract;
|
||||
}): MergeResult {
|
||||
const current = parseBlfs(input.currentXml);
|
||||
const others = sortColleagues(
|
||||
input.colleagues.filter((c) => c.number !== input.selfExtension)
|
||||
);
|
||||
const slotCount = managedSlotCount(input.contract);
|
||||
const placedColleagues = others.slice(0, slotCount);
|
||||
const overflow = Math.max(0, others.length - slotCount);
|
||||
|
||||
const preserved = current.filter((entry) => {
|
||||
if (entry.blfNo === input.contract.ownLine) {
|
||||
return false;
|
||||
}
|
||||
if (isReservedSlot(input.contract, entry.blfNo)) {
|
||||
return false;
|
||||
}
|
||||
if (isSharedParkingSlot(input.contract, entry.blfNo)) {
|
||||
return false;
|
||||
}
|
||||
if (isManagedSlot(input.contract, entry.blfNo)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const existingOwnLine = current.find((entry) => entry.blfNo === input.contract.ownLine);
|
||||
const ownLine: BlfEntry = {
|
||||
id: existingOwnLine?.blfType === "Line" ? existingOwnLine.id : "-1",
|
||||
blfNo: input.contract.ownLine,
|
||||
blfType: "Line",
|
||||
blfTypeId: "6",
|
||||
value: "",
|
||||
};
|
||||
|
||||
const parking: BlfEntry[] = input.contract.sharedParking.map((slot) => {
|
||||
const existing = current.find(
|
||||
(entry) =>
|
||||
entry.blfNo === slot.blfNo &&
|
||||
entry.blfType === "SharedParking" &&
|
||||
entry.value === slot.value
|
||||
);
|
||||
return {
|
||||
id: existing?.id ?? "-1",
|
||||
blfNo: slot.blfNo,
|
||||
blfType: "SharedParking",
|
||||
blfTypeId: existing?.blfTypeId ?? "3",
|
||||
value: slot.value,
|
||||
};
|
||||
});
|
||||
|
||||
const managed: BlfEntry[] = placedColleagues.map((colleague, index) => ({
|
||||
id: String(colleague.id),
|
||||
blfNo: input.contract.managedStart + index,
|
||||
blfType: "BLF",
|
||||
blfTypeId: "0",
|
||||
value: colleague.number,
|
||||
}));
|
||||
|
||||
const nextXml = serializeBlfs([...preserved, ownLine, ...parking, ...managed]);
|
||||
return {
|
||||
xml: nextXml,
|
||||
changed: serializeBlfs(current) !== nextXml,
|
||||
overflow,
|
||||
placed: placedColleagues.length,
|
||||
};
|
||||
}
|
||||
93
lambda/blf-sync/slot-contract.ts
Normal file
93
lambda/blf-sync/slot-contract.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
export type TemplateId =
|
||||
| "t54w-door-unlock"
|
||||
| "t54w-door-unlock-with-sp"
|
||||
| "t57w-door-unlock-with-sp"
|
||||
| "t58w-door-unlock";
|
||||
|
||||
export interface SharedParkingSlot {
|
||||
readonly blfNo: number;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export interface SlotContract {
|
||||
readonly reserved: readonly number[];
|
||||
readonly sharedParking: readonly SharedParkingSlot[];
|
||||
readonly ownLine: number;
|
||||
readonly managedStart: number;
|
||||
readonly managedEnd: number;
|
||||
}
|
||||
|
||||
export const SLOT_CONTRACT: Record<TemplateId, SlotContract> = {
|
||||
"t54w-door-unlock": {
|
||||
reserved: [2],
|
||||
sharedParking: [],
|
||||
ownLine: 1,
|
||||
managedStart: 3,
|
||||
managedEnd: 12,
|
||||
},
|
||||
"t54w-door-unlock-with-sp": {
|
||||
reserved: [2],
|
||||
sharedParking: [
|
||||
{ blfNo: 3, value: "SP1" },
|
||||
{ blfNo: 4, value: "SP2" },
|
||||
{ blfNo: 5, value: "SP3" },
|
||||
],
|
||||
ownLine: 1,
|
||||
managedStart: 6,
|
||||
managedEnd: 15,
|
||||
},
|
||||
"t57w-door-unlock-with-sp": {
|
||||
reserved: [2],
|
||||
sharedParking: [
|
||||
{ blfNo: 3, value: "SP1" },
|
||||
{ blfNo: 4, value: "SP2" },
|
||||
{ blfNo: 5, value: "SP3" },
|
||||
],
|
||||
ownLine: 1,
|
||||
managedStart: 6,
|
||||
managedEnd: 15,
|
||||
},
|
||||
"t58w-door-unlock": {
|
||||
reserved: [2, 3, 4],
|
||||
sharedParking: [],
|
||||
ownLine: 1,
|
||||
managedStart: 5,
|
||||
managedEnd: 14,
|
||||
},
|
||||
};
|
||||
|
||||
const TEMPLATE_MATCHERS: { id: TemplateId; needle: string }[] = [
|
||||
{ id: "t57w-door-unlock-with-sp", needle: "yealinkT57W-door-unlock-with-sp" },
|
||||
{ id: "t54w-door-unlock-with-sp", needle: "yealinkT54W-door-unlock-with-sp" },
|
||||
{ id: "t54w-door-unlock", needle: "yealinkT54W-door-unlock" },
|
||||
{ id: "t58w-door-unlock", needle: "yealinkT58W-door-unlock" },
|
||||
];
|
||||
|
||||
export function resolveTemplateId(...candidates: Array<string | undefined | null>): TemplateId | undefined {
|
||||
const haystack = candidates.filter(Boolean).join(" ");
|
||||
if (!haystack) {
|
||||
return undefined;
|
||||
}
|
||||
for (const matcher of TEMPLATE_MATCHERS) {
|
||||
if (haystack.includes(matcher.needle)) {
|
||||
return matcher.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isManagedSlot(contract: SlotContract, blfNo: number): boolean {
|
||||
return blfNo >= contract.managedStart && blfNo <= contract.managedEnd;
|
||||
}
|
||||
|
||||
export function isReservedSlot(contract: SlotContract, blfNo: number): boolean {
|
||||
return contract.reserved.includes(blfNo);
|
||||
}
|
||||
|
||||
export function isSharedParkingSlot(contract: SlotContract, blfNo: number): boolean {
|
||||
return contract.sharedParking.some((slot) => slot.blfNo === blfNo);
|
||||
}
|
||||
|
||||
export function managedSlotCount(contract: SlotContract): number {
|
||||
return contract.managedEnd - contract.managedStart + 1;
|
||||
}
|
||||
107
lambda/blf-sync/three-cx-client.ts
Normal file
107
lambda/blf-sync/three-cx-client.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
export interface ThreeCxConfig {
|
||||
domain: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class ThreeCxClient {
|
||||
private accessToken: string | undefined;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly config: ThreeCxConfig) {
|
||||
this.baseUrl = `https://${config.domain.replace(/^https?:\/\//, "")}`;
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken) {
|
||||
return this.accessToken;
|
||||
}
|
||||
const res = await fetch(`${this.baseUrl}/connect/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`3CX token request failed: ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { access_token?: string };
|
||||
if (!body.access_token) {
|
||||
throw new Error("3CX token response missing access_token");
|
||||
}
|
||||
this.accessToken = body.access_token;
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
async getJson<T>(path: string): Promise<{ status: number; body: T }> {
|
||||
const token = await this.getAccessToken();
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
||||
});
|
||||
const text = await res.text();
|
||||
const body = (text ? JSON.parse(text) : {}) as T;
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async patchJson<T>(path: string, payload: unknown): Promise<{ status: number; body: T }> {
|
||||
const token = await this.getAccessToken();
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await res.text();
|
||||
const body = (text ? JSON.parse(text) : {}) as T;
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async listUsers<T>(): Promise<T[]> {
|
||||
const users: T[] = [];
|
||||
let path =
|
||||
"/xapi/v1/Users?$top=100&$select=Id,Number,FirstName,LastName,Blfs,PrimaryGroupId&$expand=Groups($select=Id,Name),Phones";
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const page = await this.getJson<{ value?: T[]; "@odata.nextLink"?: string }>(path);
|
||||
if (page.status !== 200) {
|
||||
throw new Error(`3CX Users list failed: ${page.status}`);
|
||||
}
|
||||
users.push(...(page.body.value ?? []));
|
||||
const next = page.body["@odata.nextLink"];
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
path = next.replace(this.baseUrl, "");
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
async getPeerByNumber(number: string): Promise<{ Id: number; Number: string; Name?: string } | undefined> {
|
||||
const encoded = number.replace(/'/g, "''");
|
||||
const page = await this.getJson<{ value?: Array<{ Id: number; Number: string; Name?: string }> }>(
|
||||
`/xapi/v1/Peers?$filter=Number eq '${encoded}'&$top=1`
|
||||
);
|
||||
if (page.status !== 200) {
|
||||
return undefined;
|
||||
}
|
||||
return page.body.value?.[0];
|
||||
}
|
||||
|
||||
async patchUserBlfs(userId: number, blfs: string): Promise<{ status: number }> {
|
||||
const path = `/xapi/v1/Users(${userId})`;
|
||||
const current = await this.getJson<Record<string, unknown>>(path);
|
||||
if (current.status !== 200) {
|
||||
return { status: current.status };
|
||||
}
|
||||
const payload = { ...current.body };
|
||||
delete payload["@odata.context"];
|
||||
payload.Blfs = blfs;
|
||||
const patched = await this.patchJson<Record<string, unknown>>(path, payload);
|
||||
return { status: patched.status };
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,17 @@ import {
|
|||
SSMClient,
|
||||
GetParameterCommand,
|
||||
} from "@aws-sdk/client-ssm";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
const ssm = new SSMClient({});
|
||||
|
||||
function tokensMatch(provided: string, expected: string): boolean {
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
// timingSafeEqual throws on unequal-length buffers; check length first.
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
let cachedAuthToken: string | undefined;
|
||||
let cachedApiKey: string | undefined;
|
||||
|
||||
|
|
@ -105,7 +113,7 @@ export async function handler(event: {
|
|||
return xmlResponse(500, textScreenXml("Error", "Internal error"));
|
||||
}
|
||||
|
||||
if (token !== secrets.authToken) {
|
||||
if (!tokensMatch(token, secrets.authToken)) {
|
||||
console.log(JSON.stringify({ action: "lockdown", status: "rejected", reason: "invalid_token", sourceIp }));
|
||||
return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,17 @@ import {
|
|||
SSMClient,
|
||||
GetParameterCommand,
|
||||
} from "@aws-sdk/client-ssm";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
const ssm = new SSMClient({});
|
||||
|
||||
function tokensMatch(provided: string, expected: string): boolean {
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
// timingSafeEqual throws on unequal-length buffers; check length first.
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
let cachedAuthToken: string | undefined;
|
||||
let cachedApiKey: string | undefined;
|
||||
let cachedDoorId: string | undefined;
|
||||
|
|
@ -51,7 +59,7 @@ export async function handler(event: {
|
|||
return { statusCode: 500, body: JSON.stringify({ error: "Internal error" }) };
|
||||
}
|
||||
|
||||
if (token !== secrets.authToken) {
|
||||
if (!tokensMatch(token, secrets.authToken)) {
|
||||
console.log(JSON.stringify({ action: "unlock_attempt", status: "rejected", reason: "invalid_token", sourceIp }));
|
||||
return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as cdk from "aws-cdk-lib";
|
|||
import * as lambda from "aws-cdk-lib/aws-lambda";
|
||||
import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2";
|
||||
import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations";
|
||||
import * as authorizers from "aws-cdk-lib/aws-apigatewayv2-authorizers";
|
||||
import * as ssm from "aws-cdk-lib/aws-ssm";
|
||||
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
|
||||
import * as ec2 from "aws-cdk-lib/aws-ec2";
|
||||
|
|
@ -11,6 +12,9 @@ import * as route53 from "aws-cdk-lib/aws-route53";
|
|||
import * as route53Targets from "aws-cdk-lib/aws-route53-targets";
|
||||
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||
import * as logs from "aws-cdk-lib/aws-logs";
|
||||
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
|
||||
import * as cwactions from "aws-cdk-lib/aws-cloudwatch-actions";
|
||||
import * as sns from "aws-cdk-lib/aws-sns";
|
||||
import { Construct } from "constructs";
|
||||
import * as path from "path";
|
||||
|
||||
|
|
@ -30,25 +34,31 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
{ parameterName: "/seahaven/door-unlock/auth-token" }
|
||||
);
|
||||
|
||||
const doorIdParam = ssm.StringParameter.fromStringParameterName(
|
||||
// forceDynamicReference: the stack only needs the parameter for grantRead
|
||||
// (ARN, built from the name); without it, fromStringParameterName injects
|
||||
// an unreferenced AWS::SSM::Parameter::Value CloudFormation parameter.
|
||||
const doorIdParam = ssm.StringParameter.fromStringParameterAttributes(
|
||||
this,
|
||||
"DoorId",
|
||||
"/seahaven/door-unlock/door-id"
|
||||
{
|
||||
parameterName: "/seahaven/door-unlock/door-id",
|
||||
forceDynamicReference: true,
|
||||
}
|
||||
);
|
||||
|
||||
const unlockHandler = new lambda.Function(this, "UnlockHandler", {
|
||||
functionName: "door-unlock-api-unlock",
|
||||
runtime: lambda.Runtime.NODEJS_22_X,
|
||||
runtime: lambda.Runtime.NODEJS_24_X,
|
||||
architecture: lambda.Architecture.ARM_64,
|
||||
handler: "unlock-handler.handler",
|
||||
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/unlock"), {
|
||||
bundling: {
|
||||
image: lambda.Runtime.NODEJS_22_X.bundlingImage,
|
||||
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
|
||||
local: {
|
||||
tryBundle(outputDir: string) {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`esbuild ${path.join(__dirname, "../lambda/unlock/unlock-handler.ts")} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, "unlock-handler.js")} --external:@aws-sdk/*`
|
||||
`esbuild ${path.join(__dirname, "../lambda/unlock/unlock-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "unlock-handler.js")} --external:@aws-sdk/*`
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
|
@ -60,24 +70,24 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token",
|
||||
DOOR_ID_PARAM: "/seahaven/door-unlock/door-id",
|
||||
},
|
||||
timeout: cdk.Duration.seconds(10),
|
||||
timeout: cdk.Duration.seconds(20),
|
||||
memorySize: 128,
|
||||
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||
});
|
||||
|
||||
const lockdownHandler = new lambda.Function(this, "LockdownHandler", {
|
||||
functionName: "door-unlock-api-lockdown",
|
||||
runtime: lambda.Runtime.NODEJS_22_X,
|
||||
runtime: lambda.Runtime.NODEJS_24_X,
|
||||
architecture: lambda.Architecture.ARM_64,
|
||||
handler: "lockdown-handler.handler",
|
||||
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/lockdown"), {
|
||||
bundling: {
|
||||
image: lambda.Runtime.NODEJS_22_X.bundlingImage,
|
||||
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
|
||||
local: {
|
||||
tryBundle(outputDir: string) {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`esbuild ${path.join(__dirname, "../lambda/lockdown/lockdown-handler.ts")} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, "lockdown-handler.js")} --external:@aws-sdk/*`
|
||||
`esbuild ${path.join(__dirname, "../lambda/lockdown/lockdown-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "lockdown-handler.js")} --external:@aws-sdk/*`
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
|
@ -100,6 +110,57 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
elementsApiKeyParam.grantRead(lockdownHandler);
|
||||
authTokenParam.grantRead(lockdownHandler);
|
||||
|
||||
// Gateway authorizer (INFRA-99): validates the same `?token=` query-string
|
||||
// value the Yealink XML Browser keys already send, so it is transparent to
|
||||
// the phones while rejecting unauthenticated callers at the gateway.
|
||||
const authorizerHandler = new lambda.Function(this, "AuthorizerHandler", {
|
||||
functionName: "door-unlock-api-authorizer",
|
||||
runtime: lambda.Runtime.NODEJS_24_X,
|
||||
architecture: lambda.Architecture.ARM_64,
|
||||
handler: "authorizer-handler.handler",
|
||||
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/authorizer"), {
|
||||
bundling: {
|
||||
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
|
||||
local: {
|
||||
tryBundle(outputDir: string) {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`esbuild ${path.join(__dirname, "../lambda/authorizer/authorizer-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "authorizer-handler.js")} --external:@aws-sdk/*`
|
||||
);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
environment: {
|
||||
AUTH_TOKEN_PARAM: authTokenParam.parameterName,
|
||||
},
|
||||
// APIGW HTTP API authorizers have a hard 10s limit; keep margin so the
|
||||
// gateway returns its own 500 rather than racing the Lambda timeout. The
|
||||
// token is cached in module scope, so warm invocations never hit SSM.
|
||||
timeout: cdk.Duration.seconds(8),
|
||||
memorySize: 128,
|
||||
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||
});
|
||||
|
||||
authTokenParam.grantRead(authorizerHandler);
|
||||
|
||||
const tokenAuthorizer = new authorizers.HttpLambdaAuthorizer(
|
||||
"DoorUnlockTokenAuthorizer",
|
||||
authorizerHandler,
|
||||
{
|
||||
authorizerName: "door-unlock-api-token-authorizer",
|
||||
// The Yealink phones send the token in the query string; scope the
|
||||
// identity source there. A request with no `token` query param is
|
||||
// rejected by the gateway before the authorizer Lambda is invoked.
|
||||
identitySource: ["$request.querystring.token"],
|
||||
responseTypes: [authorizers.HttpLambdaResponseType.SIMPLE],
|
||||
// Authorizer result caching keyed on the identity source (the token).
|
||||
// 5 min keeps repeated phone presses fast without a long stale window.
|
||||
resultsCacheTtl: cdk.Duration.minutes(5),
|
||||
}
|
||||
);
|
||||
|
||||
const vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", {
|
||||
vpcId: "vpc-0d3d4b67bd0cf8a68",
|
||||
});
|
||||
|
|
@ -124,8 +185,10 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
ec2.Peer.ipv4("10.10.0.0/16"), ec2.Port.tcp(443), "HTTPS to phone LAN via VPN"
|
||||
);
|
||||
|
||||
const phoneIpsParam = ssm.StringParameter.fromStringParameterName(
|
||||
this, "PhoneIps", "/seahaven/door-unlock/phone-ips"
|
||||
// forceDynamicReference for the same reason as DoorId above.
|
||||
const phoneIpsParam = ssm.StringParameter.fromStringParameterAttributes(
|
||||
this, "PhoneIps",
|
||||
{ parameterName: "/seahaven/door-unlock/phone-ips", forceDynamicReference: true }
|
||||
);
|
||||
|
||||
const phonePasswordSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||
|
|
@ -134,17 +197,17 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
|
||||
const pollerHandler = new lambda.Function(this, "LockdownPoller", {
|
||||
functionName: "door-unlock-api-lockdown-poller",
|
||||
runtime: lambda.Runtime.NODEJS_22_X,
|
||||
runtime: lambda.Runtime.NODEJS_24_X,
|
||||
architecture: lambda.Architecture.ARM_64,
|
||||
handler: "lockdown-poller.handler",
|
||||
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/poller"), {
|
||||
bundling: {
|
||||
image: lambda.Runtime.NODEJS_22_X.bundlingImage,
|
||||
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
|
||||
local: {
|
||||
tryBundle(outputDir: string) {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`esbuild ${path.join(__dirname, "../lambda/poller/lockdown-poller.ts")} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, "lockdown-poller.js")} --external:@aws-sdk/*`
|
||||
`esbuild ${path.join(__dirname, "../lambda/poller/lockdown-poller.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "lockdown-poller.js")} --external:@aws-sdk/*`
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
|
@ -174,6 +237,58 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
targets: [new targets.LambdaFunction(pollerHandler)],
|
||||
});
|
||||
|
||||
const threeCxDomainSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||
this, "ThreeCxDomain", "afterhours-shift-manager/3cx-domain"
|
||||
);
|
||||
const threeCxClientIdSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||
this, "ThreeCxClientId", "afterhours-shift-manager/3cx-client-id"
|
||||
);
|
||||
const threeCxClientSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||
this, "ThreeCxClientSecret", "afterhours-shift-manager/3cx-client-secret"
|
||||
);
|
||||
|
||||
const blfSyncHandler = new lambda.Function(this, "BlfSyncHandler", {
|
||||
functionName: "door-unlock-api-blf-sync",
|
||||
runtime: lambda.Runtime.NODEJS_24_X,
|
||||
architecture: lambda.Architecture.ARM_64,
|
||||
handler: "blf-sync-handler.handler",
|
||||
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/blf-sync"), {
|
||||
bundling: {
|
||||
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
|
||||
local: {
|
||||
tryBundle(outputDir: string) {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`esbuild ${path.join(__dirname, "../lambda/blf-sync/blf-sync-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "blf-sync-handler.js")} --external:@aws-sdk/*`
|
||||
);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
environment: {
|
||||
THREE_CX_DOMAIN_SECRET: "afterhours-shift-manager/3cx-domain",
|
||||
THREE_CX_CLIENT_ID_SECRET: "afterhours-shift-manager/3cx-client-id",
|
||||
THREE_CX_CLIENT_SECRET_SECRET: "afterhours-shift-manager/3cx-client-secret",
|
||||
DRY_RUN: "false",
|
||||
},
|
||||
timeout: cdk.Duration.seconds(60),
|
||||
memorySize: 256,
|
||||
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||
});
|
||||
|
||||
threeCxDomainSecret.grantRead(blfSyncHandler);
|
||||
threeCxClientIdSecret.grantRead(blfSyncHandler);
|
||||
threeCxClientSecret.grantRead(blfSyncHandler);
|
||||
|
||||
// 05:00 ET during EDT (09:00 UTC).
|
||||
new events.Rule(this, "BlfSyncSchedule", {
|
||||
ruleName: "door-unlock-api-blf-sync-schedule",
|
||||
schedule: events.Schedule.cron({ minute: "0", hour: "9" }),
|
||||
enabled: true,
|
||||
targets: [new targets.LambdaFunction(blfSyncHandler)],
|
||||
});
|
||||
|
||||
const httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", {
|
||||
apiName: "door-unlock-api",
|
||||
});
|
||||
|
|
@ -184,6 +299,27 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
ThrottlingRateLimit: 2,
|
||||
});
|
||||
|
||||
// Access logging (audit M-18). Throttling above was already present.
|
||||
const apiAccessLogGroup = new logs.LogGroup(this, "ApiAccessLogGroup", {
|
||||
logGroupName: "/aws/apigateway/door-unlock-api",
|
||||
retention: logs.RetentionDays.THREE_MONTHS,
|
||||
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
||||
});
|
||||
defaultStage.addPropertyOverride("AccessLogSettings", {
|
||||
DestinationArn: apiAccessLogGroup.logGroupArn,
|
||||
Format: JSON.stringify({
|
||||
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",
|
||||
}),
|
||||
});
|
||||
|
||||
httpApi.addRoutes({
|
||||
path: "/unlock",
|
||||
methods: [apigwv2.HttpMethod.GET],
|
||||
|
|
@ -191,6 +327,7 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
"UnlockIntegration",
|
||||
unlockHandler
|
||||
),
|
||||
authorizer: tokenAuthorizer,
|
||||
});
|
||||
|
||||
const lockdownIntegration = new integrations.HttpLambdaIntegration(
|
||||
|
|
@ -202,12 +339,14 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
path: "/lockdown",
|
||||
methods: [apigwv2.HttpMethod.GET],
|
||||
integration: lockdownIntegration,
|
||||
authorizer: tokenAuthorizer,
|
||||
});
|
||||
|
||||
httpApi.addRoutes({
|
||||
path: "/lockdown/status",
|
||||
methods: [apigwv2.HttpMethod.GET],
|
||||
integration: lockdownIntegration,
|
||||
authorizer: tokenAuthorizer,
|
||||
});
|
||||
|
||||
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(
|
||||
|
|
@ -253,5 +392,74 @@ export class DoorUnlockStack extends cdk.Stack {
|
|||
new cdk.CfnOutput(this, "LockdownApiUrl", {
|
||||
value: `https://doorunlock.seahaven.com/lockdown`,
|
||||
});
|
||||
|
||||
// ── CloudWatch error alarms (INFRA-101) ──────────────────────────────────
|
||||
// Import the cross-stack site-alerts SNS topic. This topic is managed by
|
||||
// seahaven-account-baseline and encrypted with alias/seahaven-alarm-topics
|
||||
// (CMK). Never use alias/aws/sns here — CloudWatch cannot publish to topics
|
||||
// using the AWS-managed SNS key (silent publish failure).
|
||||
// ALARM state only; no OK/recovery actions per Sea Haven preference.
|
||||
const siteAlerts = sns.Topic.fromTopicArn(
|
||||
this,
|
||||
"SiteAlerts",
|
||||
"arn:aws:sns:us-east-1:328440206208:site-alerts"
|
||||
);
|
||||
|
||||
interface AlarmSpec {
|
||||
readonly id: string;
|
||||
readonly fn: lambda.Function;
|
||||
readonly alarmName: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
const alarmSpecs: AlarmSpec[] = [
|
||||
{
|
||||
id: "UnlockErrors",
|
||||
fn: unlockHandler,
|
||||
alarmName: "door-unlock-api-unlock-errors",
|
||||
description: "door-unlock-api-unlock Lambda is erroring — physical door unlock calls may be failing",
|
||||
},
|
||||
{
|
||||
id: "LockdownErrors",
|
||||
fn: lockdownHandler,
|
||||
alarmName: "door-unlock-api-lockdown-errors",
|
||||
description: "door-unlock-api-lockdown Lambda is erroring — lockdown commands may not be executing",
|
||||
},
|
||||
{
|
||||
id: "AuthorizerErrors",
|
||||
fn: authorizerHandler,
|
||||
alarmName: "door-unlock-api-authorizer-errors",
|
||||
description: "door-unlock-api-authorizer Lambda is erroring — all API requests will be rejected",
|
||||
},
|
||||
{
|
||||
id: "PollerErrors",
|
||||
fn: pollerHandler,
|
||||
alarmName: "door-unlock-api-lockdown-poller-errors",
|
||||
description: "door-unlock-api-lockdown-poller Lambda is erroring — lockdown state polling may be broken",
|
||||
},
|
||||
{
|
||||
id: "BlfSyncErrors",
|
||||
fn: blfSyncHandler,
|
||||
alarmName: "door-unlock-api-blf-sync-errors",
|
||||
description: "door-unlock-api-blf-sync Lambda is erroring — department BLF updates may not be applying",
|
||||
},
|
||||
];
|
||||
|
||||
for (const spec of alarmSpecs) {
|
||||
const alarm = new cloudwatch.Alarm(this, spec.id, {
|
||||
alarmName: spec.alarmName,
|
||||
alarmDescription: spec.description,
|
||||
metric: spec.fn.metricErrors({
|
||||
period: cdk.Duration.minutes(5),
|
||||
statistic: "Sum",
|
||||
}),
|
||||
threshold: 0,
|
||||
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
||||
evaluationPeriods: 1,
|
||||
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||
});
|
||||
// ALARM-only: addAlarmAction only (no addOkAction / addInsufficientDataAction)
|
||||
alarm.addAlarmAction(new cwactions.SnsAction(siteAlerts));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1383
package-lock.json
generated
1383
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -6,19 +6,25 @@
|
|||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "tsx --test lambda/blf-sync/*.test.ts",
|
||||
"cdk": "cdk",
|
||||
"synth": "cdk synth",
|
||||
"deploy": "cdk deploy",
|
||||
"diff": "cdk diff"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.178.0",
|
||||
"typescript": "~5.7.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"esbuild": "^0.25.0"
|
||||
"@aws-sdk/client-secrets-manager": "^3.1140.0",
|
||||
"@aws-sdk/client-ssm": "^3.1140.0",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"aws-cdk": "^2.1143.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "~7.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "^2.178.0",
|
||||
"constructs": "^10.0.0"
|
||||
"aws-cdk-lib": "2.270.0",
|
||||
"constructs": "^10.8.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
scripts/blf-sync-local.ts
Normal file
21
scripts/blf-sync-local.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env node
|
||||
import { handler, type BlfSyncEvent } from "../lambda/blf-sync/blf-sync-handler";
|
||||
|
||||
process.env.THREE_CX_DOMAIN_SECRET ??= "afterhours-shift-manager/3cx-domain";
|
||||
process.env.THREE_CX_CLIENT_ID_SECRET ??= "afterhours-shift-manager/3cx-client-id";
|
||||
process.env.THREE_CX_CLIENT_SECRET_SECRET ??= "afterhours-shift-manager/3cx-client-secret";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const event: BlfSyncEvent = {
|
||||
dryRun: !args.includes("--write"),
|
||||
smokeExtension: args.find((a) => a.startsWith("--smoke="))?.slice("--smoke=".length),
|
||||
};
|
||||
|
||||
if (event.dryRun === false) {
|
||||
process.env.DRY_RUN = "false";
|
||||
}
|
||||
|
||||
handler(event).catch((err) => {
|
||||
console.error(String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
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.1"
|
||||
constraints = "2.8.1"
|
||||
hashes = [
|
||||
"h1:KHd+q58PrZfAHh6hThm5b9z8uZ3Fy/g7F1XQTAH4WfA=",
|
||||
"h1:KfIRyazppfpvRKIW5URe4sIVRPPdcbtt4ddkYd1Bw3Y=",
|
||||
"h1:Lc8kS9DBvvKbl7klWyHTI406NU4mFHJcEgKN0wUaroM=",
|
||||
"h1:TKUVBhC4A1uEerXrssAgudziMw3ybiecKswVsH3aDAA=",
|
||||
"h1:W+SKtC8w0d/RNYlYc0Pz7IHotwlVXXiccFQ3Vs/Ykm8=",
|
||||
"h1:Z4YQ0fn73Qt5AoFKeBcKYNDuWpnIRM0rVtDzV9pNhWk=",
|
||||
"h1:aLNmq6dc3cDcqZc8s/8eKtn0I+UQXyJMGrmo4rRFtNw=",
|
||||
"h1:bQBSVj62u4hNd6xqDLKvuQ8IbZHHmWv2d9YQrSG5QPc=",
|
||||
"h1:eehhIUcuegkswQDKArYBAhVV9wQmRVMhyYGaD7kHIj0=",
|
||||
"h1:qy2edUBBRcDC9frArT5EVbuShLx+EuFpb7/O7ZeRoTM=",
|
||||
"h1:sVkac3fUlYGsTEO4F3x55D9zRm6xW+okSRpxi72cR/M=",
|
||||
"h1:xVTMBOmMFXyLhO4yhr9an1CaRKcuiA6Wi0P8DAzUVcg=",
|
||||
"zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec",
|
||||
"zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058",
|
||||
"zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59",
|
||||
"zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4",
|
||||
"zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35",
|
||||
"zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6",
|
||||
"zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad",
|
||||
"zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9",
|
||||
"zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831",
|
||||
"zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249",
|
||||
"zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "6.65.0"
|
||||
constraints = "6.65.0"
|
||||
hashes = [
|
||||
"h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=",
|
||||
"h1:1QFvuV0+K3GieeXSlb7qi9Q334XrsnMP8dCRwpM7fkc=",
|
||||
"h1:36ZBGQTzM6dW8dLQ145loI9yw6/fSbRV+fvLGCeEubc=",
|
||||
"h1:4uHlr+eDGOjf71giwLidIfGsMP6g5x2wkSGP5xq9EpM=",
|
||||
"h1:9fSxZKfaAGrNSzXIz53IP2EmC1LYdO/fGYl7T87Y36Q=",
|
||||
"h1:GJCK46UtrJMGSyByH4SiDytbwX11bg79jvTGZ27BGWU=",
|
||||
"h1:H0qzEAMrqydb8eTZdebso8nS/b8w1BNHysP8nmM4pLk=",
|
||||
"h1:RjeO6m/SvlhGUCDrwTdj99kJhKl/rC1zE9B5mi3YhVw=",
|
||||
"h1:Yx8Kv/T/BHVE8S1WEyHsFdu4HcsAQIl5e/831DeoQ5k=",
|
||||
"h1:ZFDxAUFzk1A2BPbLgu1LhAJ3erD4BbqZsF25yQobN4o=",
|
||||
"h1:anUZ356aBWvbHzQalF036qNKO+RISPmq6ycIL4OdXxk=",
|
||||
"h1:fhsSsZmfNFf4wErbcsmu1/ek1/TVUy7NCuMYeOpA1aE=",
|
||||
"h1:l+w5eqL9UqpiVZ2ll0r+YvWAPjIL/WtqV8xqfH1+apM=",
|
||||
"h1:nt0kyMKN9kDNXKHOejaAo7LQIemP3tyntYjxHY32P0M=",
|
||||
"h1:o2tj5YHQU1QNgANW2YAbjj0opl0BRJZl8W9trjYsqdA=",
|
||||
"zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be",
|
||||
"zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3",
|
||||
"zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917",
|
||||
"zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8",
|
||||
"zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09",
|
||||
"zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8",
|
||||
"zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7",
|
||||
"zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5",
|
||||
"zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294",
|
||||
"zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f",
|
||||
"zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab",
|
||||
"zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af",
|
||||
"zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87",
|
||||
"zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/external" {
|
||||
version = "2.4.2"
|
||||
constraints = "2.4.2"
|
||||
hashes = [
|
||||
"h1:4UInMFuK4GNw4uf2vkUwwDtc0CajvJ88BkAE6xLKOa4=",
|
||||
"h1:8KPRXwNezVs9S/xEpGcKkPNMez+Kv5bKJNfLWivGekY=",
|
||||
"h1:B8SUNH8ToFJjQwz10rFU8k9soEhnj2OzzTwKrcH9cFI=",
|
||||
"h1:ERhVaFFS4/WRonirXUJV1DWN+cSTyEKEfs2ZnoP1BgY=",
|
||||
"h1:Ev3S467Z+WcjiY/MroSWX492k017jYU1eGtZLhXr9x8=",
|
||||
"h1:O6uC9yKr7swQtc/kHVyaV9yETT20A39oUi5X+k/b290=",
|
||||
"h1:QP724n6VWM/iitpILCwO079UOlzx6I0Ylh7g8Gwv1Y0=",
|
||||
"h1:famcgOUn8RzdgcZe+GUptA59vdgh4pXzIu/y9GMX8SU=",
|
||||
"h1:h5n+iCc1zwT5mKIATjIYS8hcjJxoFAPSNxPsZbZLc3Q=",
|
||||
"h1:l0Z5YlRsbjRUU0+u4U8BPIDGv7PAszOrNLO9ZIxQrG4=",
|
||||
"h1:rwlUbh50HZYdRQWKW12nG4+3Giu2rp+mQXjqF0NzmVg=",
|
||||
"h1:tP4PPkaGoG60uUYEH3bUnYBSbhUmlk2vsh9uJbBmjUU=",
|
||||
"zh:0b51793be4f66934a3666339e44c01fd56e1c6a56256dfc66d1cb391584b4c2f",
|
||||
"zh:31cdd9b30e4ec63d130befc89471757ef3937b99a8f6cc006769d215365c5ba8",
|
||||
"zh:61f86de4a3166cfa5da6800eeba8e6a2e4ab6403fc3d7b396508260b45d3de7d",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:817e8d5946aed6ca692e0bb2f6463c28774ef8fb2fdd3922543a495a249229ea",
|
||||
"zh:b35f1bd1be09ed1a1620b43ab8cb43fe93407cf419586d53fe965691cc1b4a8e",
|
||||
"zh:bcb170063ec8b5728bc2a4568bc48f539a1991cdaaf31667aa35568aebc34725",
|
||||
"zh:c0d2c824cc7c047f26ce793bb0cbb6746f9745d1fc6e630d34a0afb5b92850d1",
|
||||
"zh:cde68f51089b02db50e2c5a17f6e132dc1ead2fc08d3dd267b8dd54ec58133fa",
|
||||
"zh:d1f3c497aa41f17e8d61067122f6d94e51ef942ab08be5465489864d900854ab",
|
||||
"zh:e62568bfc0934b63e14f3547c823b01ed60d7475ff16bf12c0e55d5ac8d98ba7",
|
||||
"zh:ed8890c29dba2b0ac27afcefa75e7395e27253b7025aaaa4258345fc59dad23e",
|
||||
"zh:f220c56c7e487f01fd158126066f6525178bbd82805b27205354bc523cc7c413",
|
||||
]
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
135
terraform/apigateway.tf
Normal file
135
terraform/apigateway.tf
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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" {
|
||||
count = var.attach_custom_domain ? 1 : 0
|
||||
|
||||
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" {
|
||||
count = var.attach_custom_domain ? 1 : 0
|
||||
|
||||
api_id = aws_apigatewayv2_api.this.id
|
||||
domain_name = aws_apigatewayv2_domain_name.this[0].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
|
||||
}
|
||||
64
terraform/build_packages.sh
Executable file
64
terraform/build_packages.sh
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
#!/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}"
|
||||
# HCP Terraform Linux workers do not ship xz. Use the gzip tarball.
|
||||
curl -fsSL "https://nodejs.org/dist/v${version}/${tarball}.tar.gz" \
|
||||
| tar -xz -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
|
||||
}
|
||||
686
terraform/hcp_iam.tf
Normal file
686
terraform/hcp_iam.tf
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
# HCP plan/apply roles imported from seahaven-terraform-substrate (PLAT-146).
|
||||
# Import, do not recreate. Role names stay hcptf-seahaven-door-unlock-api / hcptf-seahaven-door-unlock-api-plan.
|
||||
#
|
||||
# Live seahaven-hcptf-iam-management DenySelfMutation blocks DetachRolePolicy
|
||||
# and PutRolePolicy on hcptf-* (including this role). Import apply sequence:
|
||||
# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh
|
||||
# --account prod --allow-workspace seahaven-door-unlock-api-prod
|
||||
# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap /
|
||||
# hcptf-bootstrap-plan (workspace vars, never a project set).
|
||||
# 3. One Manual apply (import + detach seahaven-hcptf-iam-management +
|
||||
# put scoped inline).
|
||||
# 4. Point TFC_AWS_* back at hcptf-seahaven-door-unlock-api / hcptf-seahaven-door-unlock-api-plan.
|
||||
# 5. Re-run the script without --allow-workspace to pin trust back to
|
||||
# iam-bootstrap-prod only.
|
||||
# seahaven-lambda-execution-boundary remains seahaven-lambda-execution-boundary-seahaven-door-unlock-api.
|
||||
|
||||
import {
|
||||
to = aws_iam_role.hcptf_apply
|
||||
id = "hcptf-seahaven-door-unlock-api"
|
||||
}
|
||||
|
||||
import {
|
||||
to = aws_iam_role.hcptf_plan
|
||||
id = "hcptf-seahaven-door-unlock-api-plan"
|
||||
}
|
||||
|
||||
import {
|
||||
to = aws_iam_role_policy.hcptf_apply_services
|
||||
id = "hcptf-seahaven-door-unlock-api:seahaven-door-unlock-api-services"
|
||||
}
|
||||
|
||||
import {
|
||||
to = aws_iam_role_policy.hcptf_plan_refresh
|
||||
id = "hcptf-seahaven-door-unlock-api-plan:seahaven-door-unlock-api-plan-refresh"
|
||||
}
|
||||
|
||||
import {
|
||||
to = aws_iam_role_policy_attachments_exclusive.hcptf_apply
|
||||
id = "hcptf-seahaven-door-unlock-api"
|
||||
}
|
||||
|
||||
import {
|
||||
to = aws_iam_role_policy_attachment.hcptf_plan_viewonly
|
||||
id = "hcptf-seahaven-door-unlock-api-plan/arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
|
||||
}
|
||||
|
||||
import {
|
||||
to = aws_iam_role_policy_attachments_exclusive.hcptf_plan
|
||||
id = "hcptf-seahaven-door-unlock-api-plan"
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "hcptf_apply_trust" {
|
||||
statement {
|
||||
sid = "HcpApply"
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRoleWithWebIdentity"]
|
||||
|
||||
principals {
|
||||
type = "Federated"
|
||||
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
|
||||
}
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "app.terraform.io:aud"
|
||||
values = ["aws.workload.identity"]
|
||||
}
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "app.terraform.io:sub"
|
||||
values = [
|
||||
"organization:seahaven:project:seahaven-prod:workspace:seahaven-door-unlock-api-prod:run_phase:apply",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "hcptf_plan_trust" {
|
||||
statement {
|
||||
sid = "HcpPlan"
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRoleWithWebIdentity"]
|
||||
|
||||
principals {
|
||||
type = "Federated"
|
||||
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
|
||||
}
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "app.terraform.io:aud"
|
||||
values = ["aws.workload.identity"]
|
||||
}
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "app.terraform.io:sub"
|
||||
values = [
|
||||
"organization:seahaven:project:seahaven-prod:workspace:seahaven-door-unlock-api-prod:run_phase:plan",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "hcptf_scoped_iam" {
|
||||
statement {
|
||||
sid = "DenyCreatePolicy"
|
||||
effect = "Deny"
|
||||
actions = ["iam:CreatePolicy", "iam:CreatePolicyVersion"]
|
||||
resources = ["*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "CreateExecRoleWithBoundary"
|
||||
effect = "Allow"
|
||||
actions = ["iam:CreateRole"]
|
||||
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*"]
|
||||
|
||||
condition {
|
||||
test = "StringLike"
|
||||
variable = "iam:PermissionsBoundary"
|
||||
values = [
|
||||
"arn:aws:iam::${local.account_id}:policy/tf-managed/door-unlock-api-*",
|
||||
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-seahaven-door-unlock-api"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "MutateExecRoleWithBoundary"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"iam:AttachRolePolicy",
|
||||
"iam:PutRolePolicy",
|
||||
"iam:PutRolePermissionsBoundary",
|
||||
]
|
||||
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*"]
|
||||
|
||||
condition {
|
||||
test = "StringLike"
|
||||
variable = "iam:PermissionsBoundary"
|
||||
values = [
|
||||
"arn:aws:iam::${local.account_id}:policy/tf-managed/door-unlock-api-*",
|
||||
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-seahaven-door-unlock-api"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "WriteExecRoles"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"iam:DeleteRole",
|
||||
"iam:DeleteRolePolicy",
|
||||
"iam:DetachRolePolicy",
|
||||
"iam:TagRole",
|
||||
"iam:UntagRole",
|
||||
"iam:UpdateAssumeRolePolicy",
|
||||
"iam:UpdateRole",
|
||||
"iam:UpdateRoleDescription",
|
||||
]
|
||||
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "PassExecRolesToLambda"
|
||||
effect = "Allow"
|
||||
actions = ["iam:PassRole"]
|
||||
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*"]
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "iam:PassedToService"
|
||||
values = ["lambda.amazonaws.com"]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "IamReadOnly"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"iam:GetPolicy",
|
||||
"iam:GetPolicyVersion",
|
||||
"iam:GetRole",
|
||||
"iam:GetRolePolicy",
|
||||
"iam:ListAttachedRolePolicies",
|
||||
"iam:ListInstanceProfilesForRole",
|
||||
"iam:ListPolicies",
|
||||
"iam:ListPolicyVersions",
|
||||
"iam:ListRolePolicies",
|
||||
"iam:ListRoles",
|
||||
"iam:ListRoleTags",
|
||||
]
|
||||
resources = ["*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "DenySelfMutation"
|
||||
effect = "Deny"
|
||||
actions = [
|
||||
"iam:AttachRolePolicy",
|
||||
"iam:DeleteRole",
|
||||
"iam:DeleteRolePolicy",
|
||||
"iam:DeleteRolePermissionsBoundary",
|
||||
"iam:DetachRolePolicy",
|
||||
"iam:PutRolePolicy",
|
||||
"iam:PutRolePermissionsBoundary",
|
||||
"iam:UpdateAssumeRolePolicy",
|
||||
"iam:UpdateRole",
|
||||
"iam:UpdateRoleDescription",
|
||||
]
|
||||
resources = [
|
||||
"arn:aws:iam::${local.account_id}:role/hcptf-*",
|
||||
"arn:aws:iam::${local.account_id}:role/github-cfn-execution-role",
|
||||
"arn:aws:iam::${local.account_id}:role/githubdeploy-*",
|
||||
"arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*",
|
||||
"arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole",
|
||||
"arn:aws:iam::${local.account_id}:role/seahaven-*",
|
||||
]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "DenyBoundaryTampering"
|
||||
effect = "Deny"
|
||||
actions = [
|
||||
"iam:DeleteRolePermissionsBoundary",
|
||||
"iam:DeleteUserPermissionsBoundary",
|
||||
]
|
||||
resources = [
|
||||
"arn:aws:iam::${local.account_id}:role/*",
|
||||
"arn:aws:iam::${local.account_id}:user/*",
|
||||
]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "DenyBoundaryPolicyEdit"
|
||||
effect = "Deny"
|
||||
actions = [
|
||||
"iam:CreatePolicyVersion",
|
||||
"iam:DeletePolicy",
|
||||
"iam:DeletePolicyVersion",
|
||||
"iam:SetDefaultPolicyVersion",
|
||||
]
|
||||
resources = ["arn:aws:iam::${local.account_id}:policy/seahaven-*"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "hcptf_apply_services" {
|
||||
name = "seahaven-door-unlock-api-services"
|
||||
role = aws_iam_role.hcptf_apply.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Action = [
|
||||
"lambda:*",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:lambda:us-east-1:${local.account_id}:function:door-unlock-api-*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "LambdaAll"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"lambda:ListFunctions",
|
||||
"lambda:GetAccountSettings",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "LambdaList"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"events:*",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:events:us-east-1:${local.account_id}:rule/door-unlock-api-*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "EventBridgeRules"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"logs:CreateLogGroup",
|
||||
"logs:DeleteLogGroup",
|
||||
"logs:PutRetentionPolicy",
|
||||
"logs:DeleteRetentionPolicy",
|
||||
"logs:TagResource",
|
||||
"logs:UntagResource",
|
||||
"logs:ListTagsForResource",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:logs:us-east-1:${local.account_id}:log-group:/aws/lambda/door-unlock-api-*",
|
||||
"arn:aws:logs:us-east-1:${local.account_id}:log-group:/aws/apigateway/door-unlock-api*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "CloudWatchLogs"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"logs:DescribeLogGroups",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "CloudWatchLogsDescribe"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"logs:CreateLogDelivery",
|
||||
"logs:GetLogDelivery",
|
||||
"logs:UpdateLogDelivery",
|
||||
"logs:DeleteLogDelivery",
|
||||
"logs:ListLogDeliveries",
|
||||
"logs:DescribeResourcePolicies",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "DoorUnlockApiGwAccessLogDelivery"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"s3:*",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:s3:::door-unlock-api-artifacts-${local.account_id}",
|
||||
"arn:aws:s3:::door-unlock-api-artifacts-${local.account_id}/*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "StackBuckets"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"apigateway:*",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:apigateway:us-east-1::/apis",
|
||||
"arn:aws:apigateway:us-east-1::/apis/*",
|
||||
"arn:aws:apigateway:us-east-1::/tags/*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "HttpApiManage"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"apigateway:*",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:apigateway:us-east-1::/domainnames/doorunlock.seahaven.com",
|
||||
"arn:aws:apigateway:us-east-1::/domainnames/doorunlock.seahaven.com/*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "HttpApiDomain"
|
||||
},
|
||||
{
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"aws:RequestTag/Project" = "seahaven-door-unlock-api"
|
||||
}
|
||||
}
|
||||
Action = [
|
||||
"acm:RequestCertificate",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "AcmCreate"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"acm:ListCertificates",
|
||||
"acm:ListTagsForCertificate",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "AcmList"
|
||||
},
|
||||
{
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"aws:ResourceTag/Project" = "seahaven-door-unlock-api"
|
||||
}
|
||||
}
|
||||
Action = [
|
||||
"acm:DescribeCertificate",
|
||||
"acm:GetCertificate",
|
||||
"acm:DeleteCertificate",
|
||||
"acm:AddTagsToCertificate",
|
||||
"acm:RemoveTagsFromCertificate",
|
||||
"acm:RenewCertificate",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "AcmManageTagged"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"ssm:GetParameter",
|
||||
"ssm:GetParameters",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:ssm:us-east-1:${local.account_id}:parameter/seahaven/door-unlock/door-id",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "DoorUnlockSsm"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"ssm:ListTagsForResource",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:ssm:us-east-1:${local.account_id}:parameter/seahaven/door-unlock/*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "DoorUnlockSsmTags"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"ssm:DescribeParameters",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "DoorUnlockSsmDescribeParameters"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"secretsmanager:DescribeSecret",
|
||||
"secretsmanager:GetResourcePolicy",
|
||||
"secretsmanager:ListSecretVersionIds",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-domain-TPwqWP",
|
||||
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-client-id-jzyQXb",
|
||||
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-client-secret-jpO476",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "DescribeThreeCxSecrets"
|
||||
},
|
||||
{
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"iam:PassedToService" = "apigateway.amazonaws.com"
|
||||
}
|
||||
}
|
||||
Action = [
|
||||
"iam:PassRole",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "DoorUnlockPassRoleApiGateway"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"cloudwatch:PutMetricAlarm",
|
||||
"cloudwatch:DeleteAlarms",
|
||||
"cloudwatch:DescribeAlarms",
|
||||
"cloudwatch:TagResource",
|
||||
"cloudwatch:UntagResource",
|
||||
"cloudwatch:ListTagsForResource",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:cloudwatch:us-east-1:${local.account_id}:alarm:door-unlock-api-*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "CloudWatchAlarms"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"sns:Publish",
|
||||
"sns:GetTopicAttributes",
|
||||
"sns:ListTagsForResource",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:sns:us-east-1:${local.account_id}:site-alerts",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "SnsPublishSiteAlerts"
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "hcptf_plan_refresh" {
|
||||
name = "seahaven-door-unlock-api-plan-refresh"
|
||||
role = aws_iam_role.hcptf_plan.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Action = [
|
||||
"iam:GetRole",
|
||||
"iam:GetRolePolicy",
|
||||
"iam:ListRolePolicies",
|
||||
"iam:ListAttachedRolePolicies",
|
||||
"iam:ListRoleTags",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*",
|
||||
"arn:aws:iam::${local.account_id}:role/hcptf-seahaven-door-unlock-api",
|
||||
"arn:aws:iam::${local.account_id}:role/hcptf-seahaven-door-unlock-api-plan",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshIamRoles"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"iam:GetPolicy",
|
||||
"iam:GetPolicyVersion",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshManagedPolicies"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"events:DescribeRule",
|
||||
"events:ListTargetsByRule",
|
||||
"events:ListTagsForResource",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:events:us-east-1:${local.account_id}:rule/door-unlock-api-*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshEventBridge"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"lambda:Get*",
|
||||
"lambda:List*",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:lambda:us-east-1:${local.account_id}:function:door-unlock-api-*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshLambda"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"s3:Get*",
|
||||
"s3:ListBucket",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:s3:::door-unlock-api-artifacts-${local.account_id}",
|
||||
"arn:aws:s3:::door-unlock-api-artifacts-${local.account_id}/*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshBuckets"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"logs:DescribeLogGroups",
|
||||
"logs:ListTagsForResource",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshLogs"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"acm:DescribeCertificate",
|
||||
"acm:ListCertificates",
|
||||
"acm:ListTagsForCertificate",
|
||||
"acm:GetCertificate",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshAcm"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"ssm:GetParameter",
|
||||
"ssm:GetParameters",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:ssm:us-east-1:${local.account_id}:parameter/seahaven/door-unlock/door-id",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshDoorUnlockSsm"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"ssm:ListTagsForResource",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:ssm:us-east-1:${local.account_id}:parameter/seahaven/door-unlock/*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshDoorUnlockSsmTags"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"ssm:DescribeParameters",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshSsmDescribeParameters"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"apigateway:GET",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:apigateway:us-east-1::/apis/*",
|
||||
"arn:aws:apigateway:us-east-1::/domainnames/doorunlock.seahaven.com",
|
||||
"arn:aws:apigateway:us-east-1::/domainnames/doorunlock.seahaven.com/*",
|
||||
"arn:aws:apigateway:us-east-1::/tags/*",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshHttpApi"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"cloudwatch:DescribeAlarms",
|
||||
"cloudwatch:ListTagsForResource",
|
||||
]
|
||||
Resource = "*"
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshAlarms"
|
||||
},
|
||||
{
|
||||
Action = [
|
||||
"secretsmanager:DescribeSecret",
|
||||
"secretsmanager:GetResourcePolicy",
|
||||
"secretsmanager:ListSecretVersionIds",
|
||||
]
|
||||
Resource = [
|
||||
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-domain-TPwqWP",
|
||||
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-client-id-jzyQXb",
|
||||
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-client-secret-jpO476",
|
||||
]
|
||||
Effect = "Allow"
|
||||
Sid = "RefreshThreeCxSecrets"
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "hcptf_apply" {
|
||||
name = "hcptf-seahaven-door-unlock-api"
|
||||
assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json
|
||||
max_session_duration = 3600
|
||||
|
||||
tags = {
|
||||
Project = "seahaven-door-unlock-api"
|
||||
Owner = "adam@seahavenind.com"
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
# Empty exclusive set keeps seahaven-hcptf-iam-management detached.
|
||||
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_apply" {
|
||||
role_name = aws_iam_role.hcptf_apply.name
|
||||
policy_arns = []
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "hcptf_plan" {
|
||||
name = "hcptf-seahaven-door-unlock-api-plan"
|
||||
assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json
|
||||
max_session_duration = 3600
|
||||
|
||||
tags = {
|
||||
Project = "seahaven-door-unlock-api"
|
||||
Owner = "adam@seahavenind.com"
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "hcptf_plan_viewonly" {
|
||||
role = aws_iam_role.hcptf_plan.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_plan" {
|
||||
role_name = aws_iam_role.hcptf_plan.name
|
||||
policy_arns = [
|
||||
aws_iam_role_policy_attachment.hcptf_plan_viewonly.policy_arn,
|
||||
]
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "hcptf_scoped_iam" {
|
||||
name = "scoped-iam-management"
|
||||
role = aws_iam_role.hcptf_apply.id
|
||||
policy = data.aws_iam_policy_document.hcptf_scoped_iam.json
|
||||
}
|
||||
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. Null until attach_custom_domain is true."
|
||||
value = var.attach_custom_domain ? aws_apigatewayv2_domain_name.this[0].domain_name_configuration[0].target_domain_name : null
|
||||
}
|
||||
|
||||
output "custom_domain_hosted_zone_id" {
|
||||
description = "API Gateway regional hosted zone id for the Route53 alias. Null until attach_custom_domain is true."
|
||||
value = var.attach_custom_domain ? aws_apigatewayv2_domain_name.this[0].domain_name_configuration[0].hosted_zone_id : null
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
terraform/variables.tf
Normal file
23
terraform/variables.tf
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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
|
||||
}
|
||||
|
||||
variable "attach_custom_domain" {
|
||||
description = "When true, create the API Gateway custom domain and mapping. Keep false until mgmt releases doorunlock.seahaven.com at DNS cutover; the hostname is unique per region across accounts."
|
||||
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.65.0"
|
||||
}
|
||||
archive = {
|
||||
source = "hashicorp/archive"
|
||||
version = "2.8.1"
|
||||
}
|
||||
external = {
|
||||
source = "hashicorp/external"
|
||||
version = "2.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
cloud {
|
||||
organization = "seahaven"
|
||||
|
||||
workspaces {
|
||||
name = "seahaven-door-unlock-api-prod"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
|
|
@ -20,5 +21,5 @@
|
|||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"exclude": ["node_modules", "cdk.out"]
|
||||
"exclude": ["node_modules", "cdk.out", "**/*.test.ts", "scripts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5189,33 +5189,49 @@ linekey.1.extension = %%PickupValue%%
|
|||
linekey.1.type = 0
|
||||
{ENDIF}
|
||||
#Configure Line Key2 - Door Unlock (hardcoded)
|
||||
# do not assign 3CX BLF index 2 — reserved for this URL key
|
||||
linekey.2.line = 1
|
||||
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
|
||||
linekey.2.pickup_value = %NULL%
|
||||
linekey.2.type = 17
|
||||
linekey.2.label = Unlock Door
|
||||
linekey.2.extension = %NULL%
|
||||
#Configure Line Key3 - Shared Parking SP1 (hardcoded)
|
||||
linekey.3.line = 1
|
||||
linekey.3.value = SP1
|
||||
linekey.3.pickup_value = %NULL%
|
||||
linekey.3.type = 10
|
||||
linekey.3.label = SP 1
|
||||
linekey.3.extension = %NULL%
|
||||
#Configure Line Key4 - Shared Parking SP2 (hardcoded)
|
||||
linekey.4.line = 1
|
||||
linekey.4.value = SP2
|
||||
linekey.4.pickup_value = %NULL%
|
||||
linekey.4.type = 10
|
||||
linekey.4.label = SP 2
|
||||
linekey.4.extension = %NULL%
|
||||
#Configure Line Key5 - Shared Parking SP3 (hardcoded)
|
||||
linekey.5.line = 1
|
||||
linekey.5.value = SP3
|
||||
linekey.5.pickup_value = %NULL%
|
||||
linekey.5.type = 10
|
||||
linekey.5.label = SP 3
|
||||
linekey.5.extension = %NULL%
|
||||
#Configure Line Key3
|
||||
# SP1 is written by door-unlock-api-blf-sync as 3CX BLF index 3
|
||||
{IF blf3}
|
||||
linekey.3.line = %%Line%%
|
||||
linekey.3.value = %%type%%
|
||||
linekey.3.pickup_value = %%PickupValue%%
|
||||
linekey.3.type = %%DKtype%%
|
||||
linekey.3.label = %%label%%
|
||||
linekey.3.extension = %%PickupValue%%
|
||||
{ELSE}
|
||||
linekey.3.type = 0
|
||||
{ENDIF}
|
||||
#Configure Line Key4
|
||||
# SP2 is written by door-unlock-api-blf-sync as 3CX BLF index 4
|
||||
{IF blf4}
|
||||
linekey.4.line = %%Line%%
|
||||
linekey.4.value = %%type%%
|
||||
linekey.4.pickup_value = %%PickupValue%%
|
||||
linekey.4.type = %%DKtype%%
|
||||
linekey.4.label = %%label%%
|
||||
linekey.4.extension = %%PickupValue%%
|
||||
{ELSE}
|
||||
linekey.4.type = 0
|
||||
{ENDIF}
|
||||
#Configure Line Key5
|
||||
# SP3 is written by door-unlock-api-blf-sync as 3CX BLF index 5
|
||||
{IF blf5}
|
||||
linekey.5.line = %%Line%%
|
||||
linekey.5.value = %%type%%
|
||||
linekey.5.pickup_value = %%PickupValue%%
|
||||
linekey.5.type = %%DKtype%%
|
||||
linekey.5.label = %%label%%
|
||||
linekey.5.extension = %%PickupValue%%
|
||||
{ELSE}
|
||||
linekey.5.type = 0
|
||||
{ENDIF}
|
||||
#Configure Line Key6
|
||||
{IF blf6}
|
||||
linekey.6.line = %%Line%%
|
||||
|
|
|
|||
|
|
@ -5189,6 +5189,7 @@ linekey.1.extension = %%PickupValue%%
|
|||
linekey.1.type = 0
|
||||
{ENDIF}
|
||||
#Configure Line Key2 - Door Unlock (hardcoded)
|
||||
# do not assign 3CX BLF index 2 — reserved for this URL key
|
||||
linekey.2.line = 1
|
||||
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
|
||||
linekey.2.pickup_value = %NULL%
|
||||
|
|
|
|||
22131
yealinkT57W-door-unlock-with-sp.ph.xml
Normal file
22131
yealinkT57W-door-unlock-with-sp.ph.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3094,6 +3094,7 @@ linekey.1.extension = %%PickupValue%%
|
|||
linekey.1.type = 0
|
||||
{ENDIF}
|
||||
#Configure Line Key2 - Door Unlock (hardcoded)
|
||||
# do not assign 3CX BLF index 2 — reserved for this URL key
|
||||
linekey.2.line = 1
|
||||
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
|
||||
linekey.2.pickup_value = %NULL%
|
||||
|
|
@ -3101,6 +3102,7 @@ linekey.2.type = 17
|
|||
linekey.2.label = Unlock Door
|
||||
linekey.2.extension = %NULL%
|
||||
#Configure Line Key3 - Lockdown: Bohemia (hardcoded)
|
||||
# do not assign 3CX BLF index 3 — reserved for this URL key
|
||||
linekey.3.line = 1
|
||||
linekey.3.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=bohemia
|
||||
linekey.3.pickup_value = %NULL%
|
||||
|
|
@ -3108,6 +3110,7 @@ linekey.3.type = 17
|
|||
linekey.3.label = Lockdown BOH
|
||||
linekey.3.extension = %NULL%
|
||||
#Configure Line Key4 - Lockdown: Ronkonkoma (hardcoded)
|
||||
# do not assign 3CX BLF index 4 — reserved for this URL key
|
||||
linekey.4.line = 1
|
||||
linekey.4.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=ronkonkoma
|
||||
linekey.4.pickup_value = %NULL%
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue