- Status: Accepted (2026-08-23)
- Implements: roadmap M3/M4 (
web_apiin the architecture diagram); closes T1's open half
ADR-0022 gave the product a Cognito user pool that issues tokens; ADR-0023 gave Store a real
DynamoDB adapter. Nothing yet verified a token at a request boundary and turned it into a
TenantContext — docs/THREAT-MODEL.md's T1 disposition named exactly that gap: "the
API-layer JWT-verifying middleware does not [exist yet]." This ADR builds the first route,
GET /me, to close it — and to prove the whole chain (Cognito → verified JWT → TenantContext
→ DynamoStore → an existing, already-tested service) actually works together, not just on
paper.
- HTTP API (
apigatewayv2), not REST API, withHttpUserPoolAuthorizer. HTTP API is cheaper, lower-latency, and its Cognito authorizer construct verifies the JWT before the Lambda runs — no custom Lambda authorizer needed for the common case, and no hand-rolled JWT verification code to get wrong. The Lambda handler trustsrequestContext.authorizer.jwt. claimscompletely and reads nothing else as identity; API Gateway is the only place a token is verified. GET /meis deliberately not a feature. It idempotently provisions the caller's workspace and returns plan/status — every call it makes (ProvisioningService.ensure_workspace,EntitlementsService.get_or_provision) is already covered bytests/test_control_plane.py. This route exists to prove the chain, not to add new business logic;src/openjobradar/lambda_handlers/stays a thin translation layer by design (its own package docstring says so) — a decision, not an oversight, so the next handler doesn't quietly become the place decisions live.- No Docker bundling.
Code.fromAssetzipssrc/verbatim (minus__pycache__/*.pyc); nopip installbundling step. This works because the handler only importsopenjobradar.tenancy/openjobradar.control, neither of which importsPyYAMLorjsonschema(verified by grep before writing the handler, not assumed) —boto3is the only third-party import anywhere in that path, and it ships pre-installed in every Lambda Python runtime. This is now a constraint, not a footnote: a handler that needsopenjobradar.config(JSON-Schema validation) oropenjobradar.scoringwould break this assumption and require a real bundling step (Docker or a Lambda layer) — cross that bridge when a handler actually needs it, not preemptively. - IAM is table-scoped, not yet entity-scoped.
tenantTable.grantReadWriteData(meFunction)grants read/write across the wholeTenantTable— every entity, not justusers/entitlements, which is what this one handler actually touches. Finer per-entity scoping (an IAM condition on theskprefix) is a documented follow-up (infra/test/api-stack.test.tsasserts a DynamoDB grant exists and says so explicitly in a comment), not silently assumed done. - CORS origins are wide open only in
dev(AllowOrigins: ['*']);stage/prodsynthesize with an empty allow-list until a real web origin exists to name — fail-closed by omission rather than a placeholder domain that could get deployed and forgotten.
- T1's threat-model disposition changes from "the middleware doesn't exist" to "the middleware
exists and is API-Gateway-native, not custom code" — update
docs/THREAT-MODEL.mdalongside this ADR. - Every future route follows the same shape: a thin
lambda_handlers/<name>.py, anHttpLambdaIntegrationinApiStack, a route behind the same (or a route-specific) authorizer. - The
stage/prodempty CORS allow-list means those environments cannot actually be called from a browser yet — expected and correct until a real web origin exists (roadmap M5); tracked here rather than silently deferred.