Authorizationenforcementcomplete mediationmiddlewarerepositorygateway

Where Authorization Must Live

Not in the frontend, not only at the gateway, and not as an optional call in each handler — but at the point where the resource is loaded, structured so it cannot be skipped.

▶ Run the labFollow the failure

Frame the problem

Security starts with a concrete asset, attacker capability and trust crossing.

Asset
The guarantee that every access path to a resource is checked.
Attacker & capability
Anyone who finds the path that was not checked: a new endpoint, a batch job, a GraphQL resolver, an export.
Trust boundary
The point of enforcement — which must sit on every path to the data.
AssetThreatAttack SurfaceTrust BoundaryVulnerabilityExploit PathImpactMitigationDefense in DepthResidual Risk

Complete mediation

The principle: every access to every resource goes through the check. The practical consequence: the check must live *below* the layer where paths multiply. HTTP handlers multiply (REST, GraphQL, websockets, admin routes, batch jobs); a data-access layer is singular. Put the principal requirement there.

The frontend is not an enforcement point — it runs on the attacker's machine. The gateway is a coarse enforcement point — it knows the principal, not the record. Handlers are enforcement points that are easy to forget. The repository is the enforcement point that is hard to bypass.

Paths multiply above the data layer
RESTGraphQLWebSocketBatch jobRepository(principal) — the one checkDatabase (+ RLS as depth)
UserLLMAgentToolDataDecisionHumanGuardrail

Make skipping it a compile error

Batch jobs and admin tools that legitimately cross tenants get a distinct, audited entry point with a distinct principal type — so the exception is visible in code review rather than hidden in a nullable parameter.

1class Invoices {
2 // No public byId(id). The only entry point takes a principal.
3 forPrincipal(p: Principal) {
4 return {
5 byId: (id: InvoiceId) => this.db.one('SELECT * FROM invoices WHERE id=$1 AND tenant_id=$2', [id, p.tenantId]),
6 list: (q: Query) => this.db.many('SELECT * FROM invoices WHERE tenant_id=$1 AND ...', [p.tenantId]),
7 }
8 }
9 // Admin/support access is a DIFFERENT, audited method with a different principal type.
10 forSupport(p: SupportPrincipal, reason: string) { audit.log('support.access', { p, reason }); /* ... */ }
11}

Key points

  • Enforce below the layer where paths multiply.
  • Frontend checks are UX; gateway checks are coarse; repository checks are complete.
  • Make unscoped access unrepresentable.
  • Give cross-tenant tooling a separate, audited entry point.

Boundary control exercise

This lesson uses the shared boundary-control exercise.

Boundary control check
Untrusted input / identity
Trust boundary
Privileged asset
Prevention may fail silently.

Follow the attack

Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.

  1. 1
    Attacker → the path without the check: GraphQL resolver, export, websocket, new route.
Blast radius
  • One unchecked path exposes the resource type regardless of how many checked paths exist.

Defend, detect, recover

One prevention is a single point of security failure. Layer it and make failure observable.

Prevent
  • • Principal-required data access.
  • • RLS as depth.
  • • Route policy declared or the app fails to boot.
Detect
  • • Count data-layer calls without a principal (should be zero).
  • • Requester/owner mismatch alerts.
Respond & recover
  • • Find every caller of the unscoped path; scope from logs.
Residual risk
  • • Raw SQL and ORMs offer escape hatches.

Misconceptions

Claim
“The gateway handles authorization.”
Reality
The gateway knows who; it cannot know whether invoice 101 is theirs.