seahaven-door-unlock-api/lib/door-unlock-stack.ts
Adam Moussa 4265ad90fe Gateway token authorizer + finish CI/CD migration (INFRA-99, INFRA-2) (#32)
* 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.
2026-06-08 18:03:30 -04:00

333 lines
12 KiB
TypeScript

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";
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
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 { Construct } from "constructs";
import * as path from "path";
export class DoorUnlockStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const elementsApiKeyParam = ssm.StringParameter.fromSecureStringParameterAttributes(
this,
"ElementsApiKey",
{ parameterName: "/seahaven/door-unlock/elements-api-key" }
);
const authTokenParam = ssm.StringParameter.fromSecureStringParameterAttributes(
this,
"AuthToken",
{ parameterName: "/seahaven/door-unlock/auth-token" }
);
const doorIdParam = ssm.StringParameter.fromStringParameterName(
this,
"DoorId",
"/seahaven/door-unlock/door-id"
);
const unlockHandler = new lambda.Function(this, "UnlockHandler", {
functionName: "door-unlock-api-unlock",
runtime: lambda.Runtime.NODEJS_22_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,
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/*`
);
return true;
},
},
},
}),
environment: {
ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key",
AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token",
DOOR_ID_PARAM: "/seahaven/door-unlock/door-id",
},
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,
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,
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/*`
);
return true;
},
},
},
}),
environment: {
ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key",
AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token",
},
timeout: cdk.Duration.seconds(15),
memorySize: 128,
logRetention: logs.RetentionDays.TWO_MONTHS,
});
elementsApiKeyParam.grantRead(unlockHandler);
authTokenParam.grantRead(unlockHandler);
doorIdParam.grantRead(unlockHandler);
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",
});
const privateSubnet1 = ec2.Subnet.fromSubnetId(
this, "PrivateSubnet1", "subnet-04e38c507e96f1926"
);
const privateSubnet2 = ec2.Subnet.fromSubnetId(
this, "PrivateSubnet2", "subnet-0a0b4fc6f296dfba5"
);
const pollerSg = new ec2.SecurityGroup(this, "PollerSecurityGroup", {
vpc,
securityGroupName: "door-unlock-api-poller",
description: "Lockdown poller - outbound to Elements API and phone LAN",
allowAllOutbound: false,
});
pollerSg.addEgressRule(
ec2.Peer.anyIpv4(), ec2.Port.tcp(443), "HTTPS to Elements API and SSM via NAT"
);
pollerSg.addEgressRule(
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"
);
const phonePasswordSecret = secretsmanager.Secret.fromSecretNameV2(
this, "PhonePassword", "door-unlock-api/phone-password"
);
const pollerHandler = new lambda.Function(this, "LockdownPoller", {
functionName: "door-unlock-api-lockdown-poller",
runtime: lambda.Runtime.NODEJS_22_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,
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/*`
);
return true;
},
},
},
}),
environment: {
ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key",
PHONE_IPS_PARAM: "/seahaven/door-unlock/phone-ips",
PHONE_PASSWORD_SECRET: "door-unlock-api/phone-password",
},
vpc,
vpcSubnets: { subnets: [privateSubnet1, privateSubnet2] },
securityGroups: [pollerSg],
timeout: cdk.Duration.seconds(75),
memorySize: 128,
logRetention: logs.RetentionDays.TWO_MONTHS,
});
elementsApiKeyParam.grantRead(pollerHandler);
phoneIpsParam.grantRead(pollerHandler);
phonePasswordSecret.grantRead(pollerHandler);
new events.Rule(this, "LockdownPollerSchedule", {
ruleName: "door-unlock-api-lockdown-poller-schedule",
schedule: events.Schedule.rate(cdk.Duration.minutes(1)),
targets: [new targets.LambdaFunction(pollerHandler)],
});
const httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", {
apiName: "door-unlock-api",
});
const defaultStage = httpApi.defaultStage!.node.defaultChild as apigwv2.CfnStage;
defaultStage.addPropertyOverride("DefaultRouteSettings", {
ThrottlingBurstLimit: 5,
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],
integration: new integrations.HttpLambdaIntegration(
"UnlockIntegration",
unlockHandler
),
authorizer: tokenAuthorizer,
});
const lockdownIntegration = new integrations.HttpLambdaIntegration(
"LockdownIntegration",
lockdownHandler
);
httpApi.addRoutes({
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(
this,
"SeaHavenZone",
{
hostedZoneId: "Z06652411XKH89KTZD3XA",
zoneName: "seahaven.com",
}
);
const certificate = acm.Certificate.fromCertificateArn(
this,
"WildcardCert",
"arn:aws:acm:us-east-1:328440206208:certificate/a66c0994-90d4-410a-a1d9-5595c2a3fae3"
);
const domainName = new apigwv2.DomainName(this, "DoorUnlockDomain", {
domainName: "doorunlock.seahaven.com",
certificate,
});
new apigwv2.ApiMapping(this, "DoorUnlockMapping", {
api: httpApi,
domainName,
});
new route53.ARecord(this, "DoorUnlockARecord", {
zone: hostedZone,
recordName: "doorunlock",
target: route53.RecordTarget.fromAlias(
new route53Targets.ApiGatewayv2DomainProperties(
domainName.regionalDomainName,
domainName.regionalHostedZoneId
)
),
});
new cdk.CfnOutput(this, "ApiUrl", {
value: `https://doorunlock.seahaven.com/unlock`,
});
new cdk.CfnOutput(this, "LockdownApiUrl", {
value: `https://doorunlock.seahaven.com/lockdown`,
});
}
}