Skip to content

Langfuse Sysadmin Gate

Langfuse logins are restricted to users with the AIHubSysAdmin realm role through custom authentication flows in the aihub realm. The enforcement happens entirely inside Keycloak (no oauth2-proxy) and is defined in infra/deployment/templates/configs/keycloak-realm.json.j2 — see Keycloak Configuration for the realm overview. The decision rationale is recorded in the ADR docs/arc42/decisions/2026_06_11_langfuse_access_restricted_to_sysadmins.md.

How the gate works

A mapper-less marker client scope langfuse-sysadmin-gate is attached as a default scope to the langfuse client only. A conditional sub-flow (the "gate") evaluates:

  1. Condition - client scope — is the requesting client carrying the marker scope?
  2. Condition - user roleAIHubSysAdmin, negated (true when the user lacks the role)
  3. Deny access — fails the login

Conditions inside a conditional sub-flow are ANDed, so the deny only fires for Langfuse logins by non-sysadmins. For every other client the first condition is false and the gate is skipped entirely. This is what makes a realm-wide flow safe: the per-client scoping lives in the marker scope, not in per-client flow bindings. The marker is a default scope, so the condition triggers regardless of the scope parameter the application sends — Langfuse itself needs no configuration for this.

The gate exists twice, because Keycloak has two non-overlapping login paths:

Gate sub-flowParent flowCovers
langfuse-gate-browserbrowser-aihub (custom browser flow)Existing SSO-cookie sessions and direct Keycloak logins
langfuse-gate-post-brokerPost Broker Login - AIHubAccess CheckFresh logins brokered through the external IdP (e.g. Azure AD)

Fresh brokered logins do not resume the browser flow after the external redirect — the post-broker flow is the only hook on that path, which is why both gates are required for full coverage. Sub-flows cannot be shared between parent flows and authenticator-config aliases are realm-unique, hence two structurally identical copies with -browser- / -post-broker- prefixed config aliases.

Why the browser flow is replicated (browser-aihub* flows)

Keycloak's built-in browser flow is immutable (builtIn: true), and flows have no inheritance or include mechanism — to add anything, the whole flow tree must be recreated and bound as the realm browser flow (realm key "browserFlow": "browser-aihub"). That is what the browser-aihub* aliases are:

FlowRole
browser-aihubTop level: [REQUIRED authenticate] → [CONDITIONAL langfuse-gate-browser]
browser-aihub-authenticateThe built-in alternatives: cookie SSO, IdP redirector, forms
browser-aihub-formsUsername/password form (replica of built-in forms)
browser-aihub-conditional-2faConditional OTP (replica of built-in Browser - Conditional 2FA)

The aliases carry a browser-aihub- prefix because flow aliases are realm-unique and the built-in names (forms, …) are already taken. This mirrors what the admin console's Copy flow action would produce — the realm JSON just declares the copy explicitly.

Structural rules when changing these flows

  1. Never place a CONDITIONAL sub-flow at the same level as ALTERNATIVE executions. Keycloak then ignores the alternatives and login breaks for all clients. This is why the authentication alternatives are nested inside the REQUIRED browser-aihub-authenticate wrapper and the gate sits next to the wrapper, not next to the alternatives.
  2. Flow description fields are limited to 255 characters (database column limit) — longer values abort the whole realm import on a fresh start.
  3. Replicated flows receive no built-in-flow migrations. Keycloak version upgrades will not add new executions (e.g. passkeys) to browser-aihub; review the flow on major Keycloak bumps.

How the gate reaches running instances

The realm JSON is only imported on the first Keycloak start, and Keycloak's partialImport API does not support authentication flows. infra/deployment/templates/configs/keycloak-entrypoint.sh.j2 therefore reconciles the gate idempotently via kcadm on every container start: create the marker scope → attach it to the langfuse client → build the flows → bind the realm browser flow. Each step is existence-checked, so fresh imports no-op and already-initialized databases converge on the next container restart with no manual steps.

Built with ❤️ in Switzerland 🇨🇭