Skip to content

Latest commit

 

History

History
59 lines (52 loc) · 3.95 KB

File metadata and controls

59 lines (52 loc) · 3.95 KB

ADR 0024: First API route — HTTP API + Cognito JWT authorizer + one Lambda

  • Status: Accepted (2026-08-23)
  • Implements: roadmap M3/M4 (web_api in the architecture diagram); closes T1's open half

Context

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 TenantContextdocs/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 → TenantContextDynamoStore → an existing, already-tested service) actually works together, not just on paper.

Decision

  1. HTTP API (apigatewayv2), not REST API, with HttpUserPoolAuthorizer. 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 trusts requestContext.authorizer.jwt. claims completely and reads nothing else as identity; API Gateway is the only place a token is verified.
  2. GET /me is 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 by tests/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.
  3. No Docker bundling. Code.fromAsset zips src/ verbatim (minus __pycache__/*.pyc); no pip install bundling step. This works because the handler only imports openjobradar.tenancy/openjobradar.control, neither of which imports PyYAML or jsonschema (verified by grep before writing the handler, not assumed) — boto3 is 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 needs openjobradar.config (JSON-Schema validation) or openjobradar.scoring would 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.
  4. IAM is table-scoped, not yet entity-scoped. tenantTable.grantReadWriteData(meFunction) grants read/write across the whole TenantTable — every entity, not just users/ entitlements, which is what this one handler actually touches. Finer per-entity scoping (an IAM condition on the sk prefix) is a documented follow-up (infra/test/api-stack.test.ts asserts a DynamoDB grant exists and says so explicitly in a comment), not silently assumed done.
  5. CORS origins are wide open only in dev (AllowOrigins: ['*']); stage/prod synthesize 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.

Consequences

  • 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.md alongside this ADR.
  • Every future route follows the same shape: a thin lambda_handlers/<name>.py, an HttpLambdaIntegration in ApiStack, a route behind the same (or a route-specific) authorizer.
  • The stage/prod empty 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.