Skip to content

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/8

The 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

HeaderPurposeUsed by
Remote-UserStable user IDRunSimulation (audit), GetMe, access log user field
Remote-EmailEmailGetMe
Remote-GroupsComma-separated group listGetMe (display only)
Remote-NameDisplay nameGetMe

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:

  1. Drop the Authelia container.
  2. Replace the forward_auth block in Caddyfile with whatever your new provider expects (Authentik, oauth2-proxy, Pomerium, …).
  3. Make sure the new provider sets Remote-User, Remote-Email, Remote-Groups, Remote-Name.
  4. Keep TRUSTED_PROXIES accurate.

That is the entire integration surface.