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. 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 ## Architecture
- **API Gateway (HTTP API)** — `GET /unlock`, `GET /lockdown`, `GET /lockdown/status` with throttling (5 burst / 2 sustained req/sec) - **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 - **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 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) - **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 ## 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 | - **`.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`.
| Pipeline | `seahaven-door-unlock-api-pipeline` |
| CodeBuild project | `seahaven-door-unlock-api-build` |
| Artifact bucket | `seahaven-door-unlock-api-pipeline-artifacts` |
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 ## 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 lambda from "aws-cdk-lib/aws-lambda";
import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2"; import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2";
import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations"; 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 ssm from "aws-cdk-lib/aws-ssm";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as ec2 from "aws-cdk-lib/aws-ec2";
@ -100,6 +101,57 @@ export class DoorUnlockStack extends cdk.Stack {
elementsApiKeyParam.grantRead(lockdownHandler); elementsApiKeyParam.grantRead(lockdownHandler);
authTokenParam.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", { const vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", {
vpcId: "vpc-0d3d4b67bd0cf8a68", vpcId: "vpc-0d3d4b67bd0cf8a68",
}); });
@ -212,6 +264,7 @@ export class DoorUnlockStack extends cdk.Stack {
"UnlockIntegration", "UnlockIntegration",
unlockHandler unlockHandler
), ),
authorizer: tokenAuthorizer,
}); });
const lockdownIntegration = new integrations.HttpLambdaIntegration( const lockdownIntegration = new integrations.HttpLambdaIntegration(
@ -223,12 +276,14 @@ export class DoorUnlockStack extends cdk.Stack {
path: "/lockdown", path: "/lockdown",
methods: [apigwv2.HttpMethod.GET], methods: [apigwv2.HttpMethod.GET],
integration: lockdownIntegration, integration: lockdownIntegration,
authorizer: tokenAuthorizer,
}); });
httpApi.addRoutes({ httpApi.addRoutes({
path: "/lockdown/status", path: "/lockdown/status",
methods: [apigwv2.HttpMethod.GET], methods: [apigwv2.HttpMethod.GET],
integration: lockdownIntegration, integration: lockdownIntegration,
authorizer: tokenAuthorizer,
}); });
const hostedZone = route53.HostedZone.fromHostedZoneAttributes( const hostedZone = route53.HostedZone.fromHostedZoneAttributes(