Gateway token authorizer + finish CI/CD migration (INFRA-99, INFRA-2) (#32)
Some checks failed
Deploy / deploy (push) Has been cancelled

* feat: add gateway token authorizer to door-unlock API (INFRA-99)

All three routes (GET /unlock, /lockdown, /lockdown/status) were
AuthorizationType NONE — auth relied solely on each handler checking
the ?token= query param. Add a REQUEST-type HTTP API Lambda authorizer
(door-unlock-api-authorizer) that validates the SAME ?token= value the
Yealink XML Browser keys already send, against the existing
/seahaven/door-unlock/auth-token SSM SecureString, and attach it to all
three routes.

Transparent to the phones: identity source is $request.querystring.token
(exactly what the type-17 XML Browser keys send via GET), simple response
{isAuthorized}, fail-closed, 5-min results cache. Token is cached in
module scope so warm invocations skip SSM.

GET is kept (not switched to POST): the Yealink type-17 XML Browser keys
are GET-only and render the returned Yealink XML — they cannot issue a
POST body or custom headers. POST is therefore deferred to avoid bricking
the door keys.

Handlers retain their own token check as defense-in-depth. Purely
additive change set; no existing Lambda or integration is modified.

* chore: complete CI/CD migration to GitHub Actions (INFRA-2)

GitHub Actions (ci.yaml + deploy.yaml via the Sea Haven reusable
workflows) is the proven deploy path. Remove the now-orphaned
buildspec.yml and update the README CI/CD and architecture sections.

The legacy CodePipeline was already deleted (2026-06-05); the leftover
CodeBuild project seahaven-door-unlock-api-build and its IAM role
seahaven-door-unlock-api-codebuild have now also been decommissioned.
This commit is contained in:
Adam Moussa 2026-06-08 18:03:30 -04:00 • committed by GitHub
parent dbae72810a
commit b1369bf162
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 123 additions and 19 deletions

View file

@ -3,12 +3,13 @@
AWS Lambda middleware that allows Yealink desk phones to unlock the front door and manage lockdown profiles via LenelS2 Elements.
```
Yealink T54W/T58W → HTTPS GET → API Gateway → Lambda → LenelS2 Elements API
Yealink T54W/T58W → HTTPS GET (?token=) → API Gateway (token authorizer) → Lambda → LenelS2 Elements API
```
## 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.
- **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)
@ -46,15 +47,12 @@ Pressing the line key toggles the lockdown on/off and displays the current statu
## CI/CD
Pushes to `main` trigger an AWS CodePipeline (V2) that runs `cdk deploy` via CodeBuild.
GitHub Actions, using the Sea Haven reusable workflows:
| Resource | Name |
|----------|------|
| Pipeline | `seahaven-door-unlock-api-pipeline` |
| CodeBuild project | `seahaven-door-unlock-api-build` |
| Artifact bucket | `seahaven-door-unlock-api-pipeline-artifacts` |
- **`.github/workflows/ci.yaml`** — on pull requests to `main`, runs the `ci-typescript-cdk` reusable workflow (build, lint, synth).
- **`.github/workflows/deploy.yaml`** — on push to `main`, runs the `cd-cdk` reusable workflow which assumes the `githubdeploy-seahaven-door-unlock-api` OIDC role (`AWS_DEPLOY_ROLE_ARN` repo secret) and runs `cdk deploy`.
The CodeBuild role assumes CDK bootstrap roles for deployment — no separate CloudFormation stage.
The legacy CodePipeline/CodeBuild deploy path has been fully decommissioned.
## Manual Deployment

View file

@ -1,11 +0,0 @@
version: 0.2
phases:
install:
runtime-versions:
nodejs: 22
commands:
- npm ci
build:
commands:
- npx cdk deploy --require-approval never

View 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) };
}

View file

@ -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";
@ -100,6 +101,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_22_X,
architecture: lambda.Architecture.ARM_64,
handler: "authorizer-handler.handler",
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/authorizer"), {
bundling: {
image: lambda.Runtime.NODEJS_22_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=node22 --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",
});
@ -212,6 +264,7 @@ export class DoorUnlockStack extends cdk.Stack {
"UnlockIntegration",
unlockHandler
),
authorizer: tokenAuthorizer,
});
const lockdownIntegration = new integrations.HttpLambdaIntegration(
@ -223,12 +276,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(