Auth and identity
The Docker stack ships with Caddy + Authelia for authentication out of the
box. The backend is auth-agnostic - it reads identity from Remote-*
headers, but only when the peer is in TRUSTED_PROXIES.
Flow
browser -> https://painscaler.lan -> caddy (TLS termination + forward_auth to authelia) -> authelia (login, MFA, session check) -> caddy receives Remote-User, Remote-Email, Remote-Groups, Remote-Name -> upstream painscaler-web (nginx) [forwards Remote-* unchanged] -> upstream painscaler-api [reads Remote-* if peer is trusted]If the user has a valid session, Authelia returns 200 and Caddy attaches
the Remote-* headers. If not, Caddy redirects to auth.lan for login.
Backend trust model
The backend has one rule for Remote-* headers: trust them only if the
direct peer is in TRUSTED_PROXIES.
TRUSTED_PROXIES=172.16.0.0/12,10.0.0.0/8The default covers Docker bridge networks (172.16/12) and standard private ranges (10/8). Anyone not matching the CIDR list gets all four headers deleted in middleware before any handler runs.
Implementation: internal/server/server.go -
stripUntrustedAuthHeaders chains in front of RequestID and AccessLog,
so even the access log sees the post-strip identity.
If you change the docker network range, or sit behind a different proxy,
update TRUSTED_PROXIES accordingly. Comma-separated. Either bare IPs
(auto-promoted to /32 or /128) or CIDRs.
Header contract
| Header | Purpose | Used by |
|---|---|---|
Remote-User | Stable user ID | RunSimulation (audit), GetMe, access log user field |
Remote-Email | GetMe | |
Remote-Groups | Comma-separated group list | GetMe (display only) |
Remote-Name | Display name | GetMe |
Authelia configures these via its forward-auth response headers. If you swap Authelia for Authentik, oauth2-proxy, or Pomerium, set the same headers and PainScaler does not care.
Per-handler binding
apigen has a //api:header directive. Handlers that need identity
declare it explicitly:
//api:route POST /api/v1/simulation/run//api:header Remote-User={user}func (s *Server) RunSimulation(user string, simCtx simulator.SimContext) (*simulator.DecisionResult, error) { // user is empty string when no Remote-User header}The TS client never sees user as a parameter - apigen strips
header-source params from the generated frontend code, since the proxy
sets them.
What “audit” actually means
When RunSimulation succeeds, the resulting row in simulation_runs
gets created_by = Remote-User. So the simulation history (visible in
the UI’s Scans tab) has attribution. Remote-User is empty when running
natively without a proxy - those rows show no author.
There is no per-route ACL today. Authelia decides “may this user reach PainScaler at all”; once they are in, every endpoint is open to every authenticated user. If you need per-feature gating, the cleanest path is extending Authelia’s policy to per-path rules and pointing more forward-auth blocks at it.
Replacing Authelia
The backend cares about four headers, nothing else. To swap Authelia:
- Drop the Authelia container.
- Replace the
forward_authblock inCaddyfilewith whatever your new provider expects (Authentik, oauth2-proxy, Pomerium, …). - Make sure the new provider sets
Remote-User,Remote-Email,Remote-Groups,Remote-Name. - Keep
TRUSTED_PROXIESaccurate.
That is the entire integration surface.