Centralized API Router

Centralized API Router

Companion to index.md. Revises the per-service authorizer model into a single API router so we don’t duplicate auth, AVP, and error shaping across N services.

Why the per-service model breaks down

The original design put a shared authz-lib inside each service. That works for 3 same-language services in a monorepo, but degrades fast:

  • Duplication. Every service re-wires JWT verification, AVP calls, and Smithy error shaping. Easy to drift between services.
  • Language lock-in. A Go or Python service has to re-implement the library.
  • Cross-cutting features get re-solved each time. Rate limiting, throttling, request validation, partner API keys, structured access logs — every service team owns these or nobody does.
  • SDK error contract is fragile. If one service emits an UnauthorizedException with a slightly different shape, generated SDK clients see schema drift.
  • ALB rule sprawl. Each new service needs its own /api/<svc>/* rule, plus auth-posture decisions baked into the rule.

The fix is to lift auth, authz, and error shaping into one router and let services do only business logic.

Topology

API Gateway HTTP API behind the ALB, owning everything under /api/*.

flowchart LR
  user([Browser / SDK consumer])

  subgraph vpc[VPC  us-gov-west-1]
    alb[ALB<br/>OIDC on / only<br/>pass-through on /api/*]

    apigw[API Gateway<br/>HTTP API<br/>private endpoint]

    authz[Authorizer Lambda<br/>JWT verify + AVP IsAuthorized]
    avp[(AVP Policy Store)]

    spa[SPA Fargate]
    um[User Mgmt]
    cs[Commercial]
    fs[FE Support]
  end

  user -- /* --> alb --> spa
  user -- /api/* --> alb --> apigw
  apigw -. authorizes via .-> authz
  authz --> avp
  apigw --> um
  apigw --> cs
  apigw --> fs
  • / keeps ALB OIDC for browser auth. Cookie gates SPA assets — unchanged.
  • /api/* on the ALB pass-through forwards to API Gateway via a VPC Link (private integration). No ALB auth action on this rule.
  • API Gateway HTTP API owns API routing, authorization, validation, throttling.
  • Services receive already-authorized requests with principal context in headers. They enforce business rules only.

Authorizer Lambda

One Lambda. One AVP call site. One JWT verifier.

sequenceDiagram
    autonumber
    participant Client
    participant APIGW as API Gateway
    participant AuthZ as Authorizer Lambda
    participant AVP
    participant Svc as Service

    Client->>APIGW: GET /api/commercial/orders/123<br/>Authorization: Bearer ... OR<br/>x-amzn-oidc-accesstoken: ... (from ALB)
    APIGW->>AuthZ: token + route + method
    AuthZ->>AuthZ: pick token source<br/>verify signature via JWKS<br/>extract sub, groups, tenant_id
    AuthZ->>AVP: IsAuthorized(<br/>  principal: User::sub,<br/>  action: commercial:GetOrder,<br/>  resource: Order::123,<br/>  context: {tenant_id, groups}<br/>)
    AVP-->>AuthZ: ALLOW | DENY
    alt ALLOW
        AuthZ-->>APIGW: { isAuthorized: true, context: { principal, tenant_id } }
        APIGW->>Svc: forward + inject principal headers
        Svc-->>APIGW: 200 + body
        APIGW-->>Client: 200 + body
    else DENY (no token / bad sig / AVP deny)
        AuthZ-->>APIGW: { isAuthorized: false }
        APIGW-->>Client: 401 or 403 with Smithy-shaped JSON<br/>(via Gateway Response template)
    end

Token source resolution. The authorizer reads whichever is present:

  • Authorization: Bearer <jwt> — SDK / machine-to-machine / direct API callers
  • x-amzn-oidc-accesstoken — SPA → ALB → API Gateway path (injected by ALB OIDC)

Both flow through the same verify+AVP path. One auth model, two transports.

Action mapping. Map (HTTP method, route) to a Cedar action string. e.g. GET /api/commercial/orders/{id}commercial:GetOrder. Keep this mapping in the authorizer (or generated from the Smithy model — see below).

Caching. API Gateway authorizer result caching keyed on the identity source. Modest TTL (e.g. 60 s) cuts AVP/JWKS load without making revocation feel slow. Disable on sensitive write paths if needed.

Smithy-shaped errors via Gateway Responses

API Gateway lets you override default error responses with custom JSON templates. Define them once to match the Smithy error model:

// UNAUTHORIZED (401)
{
  "__type": "UnauthorizedException",
  "message": "$context.error.message",
  "requestId": "$context.requestId"
}

// ACCESS_DENIED (403)
{
  "__type": "ForbiddenException",
  "message": "Not permitted",
  "requestId": "$context.requestId"
}

// MISSING_AUTHENTICATION_TOKEN (401)
// BAD_REQUEST_BODY (400) → ValidationException, etc.

Generated SDK clients deserialize these into typed exception classes. Callers write:

try {
  await client.getOrder({ id: "123" });
} catch (e) {
  if (e instanceof UnauthorizedException) { /* refresh token, retry */ }
  if (e instanceof ForbiddenException)    { /* show "no access" UI */ }
}

Define the templates once in the API Gateway CDK construct. Every service inherits them.

What services look like now

Stripped-down. The handler receives a pre-authorized request with principal context in headers (e.g. x-principal-sub, x-principal-tenant-id, x-principal-groups). It can:

  • Trust them, because the only network path in is via API Gateway → VPC Link.
  • Use them for tenancy filtering, audit logs, ABAC inside the data layer.
  • Not call AVP. Not verify JWTs. Not shape 401/403 errors.

If a service needs resource-level AVP checks beyond what the route-level authorizer covers (e.g. “this user can call GetOrder, but can they see this specific order?”), it can call AVP itself — but only for that finer-grained decision. The coarse gate stays at the router.

Cross-cutting features you get for free

ConcernHow API Gateway handles it
Rate limitingPer-route throttle settings
Burst protectionBuilt-in token-bucket per route
Request validationSchema validators from OpenAPI (generated from Smithy)
Access logsStructured, configurable, to CloudWatch / S3 / Firehose
TracingNative X-Ray integration
Partner API keysREST API only (see HTTP vs REST below)
WAFAttach a WebACL at the API Gateway stage

HTTP API vs REST API

API Gateway has two flavors. Pick based on whether you need partner API keys.

HTTP APIREST API
Cost~70% cheaperMore expensive
LatencyLowerHigher
Lambda authorizersYesYes
JWT authorizer (built-in, no Lambda)YesNo
Usage plans + API keysNoYes
Request validationLimitedFull
CachingNoYes (per-stage)

Default to HTTP API. Switch to REST API only when you need to issue partner API keys with usage plans, or per-stage response caching. The authorizer/services/Smithy-error patterns are identical either way.

Note: you could use the built-in JWT authorizer on HTTP API to skip the Lambda hop. But we still need a Lambda to call AVP, so the JWT authorizer alone isn’t enough. Stick with a Lambda authorizer that does both.

What this changes vs. index.md

  • Removes the authz-lib shared library as the per-service auth path. Library still exists but only for the rare in-service fine-grained AVP check.
  • Adds API Gateway HTTP API as the API-path front door, sitting behind the ALB.
  • Adds the authorizer Lambda as the one place JWT verification and AVP IsAuthorized live.
  • Adds Gateway Response templates for Smithy-shaped errors.
  • ALB still does browser OIDC for SPA paths and pass-through for /api/*.

Trade-offs

  • One extra hop (ALB → APIG → service). Single-digit ms in-region. Acceptable for almost all APIs; punch through ALB → service directly only for a specifically latency-sensitive endpoint.
  • Two systems to operate (ALB + API Gateway) instead of one. Worth it once you’re at 3+ services or expect language diversity.
  • API Gateway HTTP API in GovCloud is available in both us-gov-west-1 and us-gov-east-1. REST API also available. No GovCloud blockers here.
  • Per-request cost is small but non-zero. At very high RPS, monitor; not a concern at typical SaaS volumes.

Action mapping from Smithy

Worth doing once the model stabilizes: generate the (method, route) → Cedar action table from the Smithy model so the authorizer’s mapping can’t drift from the SDK’s expectations. A small build step that walks the Smithy operations and emits a JSON map the authorizer reads at cold start.

Open questions

  1. VPC Link vs public API Gateway endpoint. Private VPC Link keeps API Gateway off the public internet — strictly better for GovCloud posture. Default to that; only expose publicly if you have a reason (e.g. third-party callers that can’t reach via ALB).
  2. Where does fine-grained AVP go? Coarse: in authorizer. Fine (per-resource): in service. Need a convention to keep teams from re-checking the coarse decision.
  3. Cache TTL on the authorizer. Tighter = more AVP load, faster revocation. Looser = cheaper, staler. Default 60s, revisit per route as needed.