mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 07:03:12 +00:00
Gateway token authorizer + finish CI/CD migration (INFRA-99, INFRA-2) (#32)
Some checks failed
Deploy / deploy (push) Has been cancelled
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:
parent
dbae72810a
commit
b1369bf162
4 changed files with 123 additions and 19 deletions
14
README.md
14
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
version: 0.2
|
||||
|
||||
phases:
|
||||
install:
|
||||
runtime-versions:
|
||||
nodejs: 22
|
||||
commands:
|
||||
- npm ci
|
||||
build:
|
||||
commands:
|
||||
- npx cdk deploy --require-approval never
|
||||
62
lambda/authorizer/authorizer-handler.ts
Normal file
62
lambda/authorizer/authorizer-handler.ts
Normal 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) };
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue