Gateway Auth
Every route on every API is JWT-verified in the gateway itself
(gateway/njs/, policy in gateway/njs/policy.js) — not by per-backend
Python auth code. This page covers the org/site/user/role model,
auth_api.py's login flow, how policy.js decides who can hit what, and
the (separate, easy to conflate) backend defense-in-depth header. See
Architecture for where the gateway sits in
the request path, and the Auth API reference for the full
endpoint list.
Org / site / user / role model
Four Postgres tables (created by migration 0001,
src/baler/migrations/v0001_baseline.py; see
Schema migrations):
orgs— the top-level tenant. Everything else hangs off anorg_id.sites— a building/location within an org. Optional scoping unit: a user or API key can be restricted to specificsite_ids, or left unscoped (NULL/empty = whole org).users—email+ PBKDF2-HMAC-SHA256 password hash (600k iterations — OWASP's 2023+ recommendation, stdlibhashlibonly, no new dependency) + arole.api_keys— long-lived, non-interactive credentials. Same role vocabulary as users plus one extra:service. Each key has its ownjti, optionalexpires_at, and can carrysite_idsnarrower than its org.
Roles are ordinal, not a permission set you assemble per-route:
viewer < operator < admin, plus service (an API-key-only class
ranked equal to operator — ROLE_RANK in policy.js is
{ viewer: 1, operator: 2, admin: 3, service: 2 }). A route's policy
entry names one minRole; anything ranked at or above it passes. There's
deliberately no separate role→resource permission matrix layered on top
— each policy row already states exactly who can call it, and a second
table encoding the same fact a different way is the first thing that
goes stale when a route is added.
Bootstrapping: POST /auth/orgs
There's no seed data and no superuser bootstrap script — the very first
account is created through the API itself. POST /auth/orgs is public
(no token required — gateway/njs/policy.js marks it
resource: null, minRole: null, same as /auth/login) and does two
things in one call: creates the org row and its first admin user,
via auth.create_org():
Code
This is the only way an org's first user gets created — every
subsequent user comes from an admin calling POST /auth/users (itself
minRole: admin in policy.js) with an already-valid token. Making org
creation carry its own first-admin payload solves the obvious chicken-
and-egg problem: an admin-only "create a user" endpoint has no answer for
"who creates the very first admin."
Both /auth/orgs and /auth/login sit behind their own per-source-IP
limit_req_zone in plain nginx (gateway/nginx.conf, 5 requests/minute
each, limit_req_status 429) — njs can't gate these two routes on a
token (that's the whole point of them being public), so brute-forcing
either is throttled at the nginx layer instead.
Login flow: POST /auth/login → Bearer token
Code
auth_api.py's _login handler (src/baler/auth_api.py) checks
the password with auth.verify_password, looks up the user's
site_ids via auth.user_site_ids, and signs a JWT with
auth.issue_session_token. A session token's claims:
Code
Every route after login expects that token as Authorization: Bearer <token>. There's no cookie, no server-side session store on the gateway
side — the JWT itself, signed with BALER_JWT_SECRET, is the
whole session. POST /auth/api-keys (admin-only) issues the
non-interactive equivalent — same claims shape, but kind: "api_key",
no exp unless expires_in_seconds was given, and revocable (see
below), where a session token just expires after 12 hours
(auth.SESSION_TTL_SECONDS) and can't be individually revoked before
then.
/auth/login itself has no js_access gate at all (a dedicated
exact-match nginx location, not a policy.js row) — you can't require
a valid token to go fetch one.
gateway/njs/policy.js — the route table
gateway/njs/main.js's authorize(r) (wired as js_access main.authorize; per protected location) is the actual gate; the table
it reads lives entirely in gateway/njs/policy.js, one flat array so the
whole access policy is reviewable in one file:
Code
Paths are matched as seen at the gateway, service prefix included
(/fault/rules, not rules stripped of its proxy prefix) — one row is
the complete story for a route, no separate "which service is this"
lookup needed to read it. resolveRoute() does a linear first-match scan
(fine at this table's size); main.js treats no match as deny, not
fail-open — an unmapped route under a proxied prefix returns 403 no policy defined for this route, since a policy gap should be visible as
a hard failure, not silently-permitted traffic.
authorize()'s actual sequence per request:
- No matching route →
403. minRole === null(public route) → allow, no token check, no rate limit.- No
Authorization: Bearer <token>→401. - Token doesn't verify against
BALER_JWT_SECRET(gateway/njs/jwt.js) →401. claims.roledoesn't rank ≥ the route'sminRole(policy.roleSatisfies) →403.- Only for
kind === "api_key"tokens: isclaims.jtiinngx.shared.revoked(refreshed fromauth_api'sGET /internal/revoked-jtisevery 30s by ajs_periodichandler) →401if so. Session tokens skip this check entirely — their own short expiry is treated as the guard, so there's no revocation list to check against. - Per-token rate limit (
gateway/njs/ratelimit.js,viewer: 1200, operator: 3000, admin: 6000, service: 12000requests/minute, keyed byclaims.sub) →429if exceeded.
Only after all six checks pass does the request reach a backend at all.
Two different secrets, two different jobs
It's easy to conflate these because both live in .env and both get
checked on (almost) every request, but they protect against different
things:
BALER_JWT_SECRET— signs and verifies the user-facing session/API-key tokens (Authorization: Bearer <token>). This is the one end users and API-key holders present. Verified in the gateway (gateway/njs/jwt.js) and independently re-verified insideauth_api.pyitself (auth.decode_own_token) for handlers that need to know who's calling — that's not "trust the gateway already checked this," sinceauth_api.pyis the identity service, not just another backend behind it.BALER_GATEWAY_SECRET— a shared secret the gateway attaches asX-Gateway-Secreton every request it forwards (r.variables.gateway_secretinmain.js, set unconditionally on every invocation). Each backend checks it withgateway_auth.request_came_through_gateway()(src/baler/gateway_auth.py) — a singlehmac.compare_digestagainst the expected value, fail-closed if the env var isn't set at all. This is not a second authorization layer re-deciding who can do what; it only answers "did this request actually come through the gateway'sjs_accesschecks, or did it hit this container's port directly from a sibling on the same Docker network." All the role/site/rate-limit logic already ran in the gateway by the time this header is checked — this is defense in depth against bypassing the gateway entirely, not a duplicate policy engine.
Concretely: a request with a valid JWT but no route through the gateway
(hitting fault_api:8002 directly from another container) fails the
X-Gateway-Secret check and gets a 401 from the backend itself, even
though the JWT would have been fine. A request through the gateway with
an invalid or missing JWT never gets that far — it's rejected by
authorize() before X-Gateway-Secret is even relevant. Both docker compose up refuses to start the gateway service without both
secrets set (docker-compose.yml's ${BALER_JWT_SECRET:?...} /
${BALER_GATEWAY_SECRET:?...}) — an unset JWT secret means the
gateway can't verify anything, and an unset gateway secret would mean
every backend's request_came_through_gateway() fails closed on every
single request.
What's not covered here
Site-scoping (site_ids on a user or API key) exists in the schema and
is carried in every token's claims, but nothing currently reads it back
out. gateway/njs/main.js's authorize() only checks role, not site;
grepping the backends (ingest_api.py, fault_api.py,
derivation_api.py, validate_api.py) turns up no site_id filtering
either. A user or API key restricted to one site can, today, still see
and touch every site in its org through any route its role permits —
site_ids is plumbed through end-to-end but not yet enforced anywhere.
Worth knowing before relying on it as an isolation boundary.

