seahaven-door-unlock-api/lib/door-unlock-stack.ts
Adam Moussa b810de1743
refactor(cdk): drop unreferenced ssm value parameters
fromStringParameterName injects an AWS::SSM::Parameter::Value
CloudFormation parameter to carry the parameter's value, but DoorId
and PhoneIps are only used for grantRead, which builds the ARN from
the name string — so DoorIdParameter and PhoneIpsParameter sat
unreferenced in the template (flagged W2001 by the CloudFormation
validator newly bundled in aws-cdk-lib 2.262.0).

Switching to fromStringParameterAttributes with forceDynamicReference
resolves the value lazily via an SSM dynamic reference, emitting
nothing when unused. Verified the synthesized template is identical
apart from the removed Parameters entries and the CDKMetadata
analytics hash — no IAM or resource changes.

Also re-points the four line-keyed semgrep detect-child-process
suppressions (adjudicated FPs, INFRA-105) to the shifted line
numbers; the findings themselves are unchanged.

Signed-off-by: Adam Moussa <adam@seahavenind.com>
2026-07-23 16:18:46 -04:00

407 lines
15 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 * 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";
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" }
);
// 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",
{
parameterName: "/seahaven/door-unlock/door-id",
forceDynamicReference: true,
}
);
const unlockHandler = new lambda.Function(this, "UnlockHandler", {
functionName: "door-unlock-api-unlock",
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_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=node24 --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_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_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=node24 --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_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",
});
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"
);
// 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(
this, "PhonePassword", "door-unlock-api/phone-password"
);
const pollerHandler = new lambda.Function(this, "LockdownPoller", {
functionName: "door-unlock-api-lockdown-poller",
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_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=node24 --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`,
});
// ── 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",
},
];
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));
}
}
}