mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 10:33:13 +00:00
Some checks failed
Deploy / deploy (push) Has been cancelled
* ci: add least-privilege permissions blocks to workflow callers Resolves code scanning alerts #3 and #4 (actions/missing-workflow-permissions). Both callable workflows only need contents: read; the dependency-review callable already declares it internally, this caps the caller token to match. Signed-off-by: Adam Moussa <adam@seahavenind.com> * build(deps): bump aws-cdk-lib to 2.262.0 to patch brace-expansion aws-cdk-lib 2.261.0 bundles brace-expansion 5.0.6, which is vulnerable to CVE-2026-13149 (GHSA-3jxr-9vmj-r5cp), an exponential-time DoS in expand(). This was the only path pulling the vulnerable package into the tree. 2.262.0 vendors the patched 5.0.7, resolving Dependabot alert 7. Because brace-expansion arrives bundled inside the aws-cdk-lib tarball rather than resolved by npm, the aws-cdk-lib bump is the only way to move it. Signed-off-by: Adam Moussa <adam@seahavenind.com> * 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> --------- Signed-off-by: Adam Moussa <adam@seahavenind.com>
407 lines
15 KiB
TypeScript
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));
|
|
}
|
|
}
|
|
}
|