Files
serverAppConfigs/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md
T
Frank a5fe045b58 docs(vault): one fact, one owner — repair the doubled pages, single-source the facts
Structural repair:
- 13 pages were the same document twice (each exactly 2x its HEAD size),
  a second frontmatter block whose closing fence had been glued onto a body
  line by a frontmatter rewrite that re-read its own output. Un-doubled by
  cutting at the second frontmatter and keeping the copy whose body is a
  superset; the orphan census reports 0 lost tokens against HEAD.
- log.md: one entry per date, bare `## YYYY-MM-DD` headings, newest first —
  66 dated headings merged into 34, titles demoted to bold leads, every
  content line verified present. The file had been descending at the head
  and ascending at the tail.

Ownership moves:
- New `LLM/frank-medium.md` is the locator for the retired Dankserver
  endpoint: era windows quoted from each era page's own banner, no numbers
  restated, and the two unresolved date conflicts recorded instead of
  silently resolved. `frank-archon.md` links to it instead of restating the
  pre-rename window.
- The four Pipeline-Parallelism stubs (frontmatter only, no body) are gone;
  `Multi-Node Runtimes.md` owns runtime construction, launch, mixed-vendor
  verdicts and cross-split spec decode, and the four links that pointed at
  the stubs were repointed at it or at the drafter page.
- Authentik's three stub pages fold into `Authentik.md`, with the process
  detail and the outpost reference split out so the parent stays under the
  length hint.

Verified corrections (host census + live probe, 2026-10-19):
- The MXFP4 checkpoint two pages called the "on-disk rollback" is gone:
  /home/dank/models/ holds StillDeadcode, rad and tpl.jinja. Stated once, on
  the era page that owns that payload; the swap page now links to it.
- frank-medium-radiance-mxfp4: the 4.24/7 accept rate belongs to the DFlash2
  arm measured on the FP8 target, not to the MXFP4 era; the 3-family soak run
  census is kept once (155 requests) instead of twice (~120 and 155).
- Fleet shapes re-confirmed against the live endpoints before touching any
  page: 3x200000 fp8 KV, 2x200704, 2x262144.

Index rows are regenerated, not hand-carved. Net -1,849 vault lines.
2026-10-19 05:00:43 -07:00

14 KiB

type, description, tags, timestamp
type description tags timestamp
reference Outpost, proxy-provider, OAuth2, LDAP and flow configuration for the homelab's Authentik SSO.
infrastructure
authentik
2026-10-19

Authentik Outpost Reference

Configuration lookup for the Authentik deployment: the live proxy-provider inventory behind the guarded vhosts, the provisioning rules the embedded outpost enforces, this fork's OAuth2 relying-party quirks, the LDAP provider objects, the self-serve user-settings flow, and the live database shape. What the service is, how it is deployed, wired and operated is Authentik; the three core containers are Authentik Processes.

Live proxy-provider inventory

Live provider inventory (read from the authentik-postgres container with read-only SELECTs, 2026-10-18). Nine proxy providers, all attached to outpost 7e39655b-58d3-4fe6-a80a-513f31051217 ("authentik Embedded Outpost", 9 members in authentik_outposts_outpost_providers), all with internal_host = http://caddy:8017, all carrying the same three flow UUIDs (default-authentication-flow, default-provider-authorization-implicit-consent, default-provider-invalidation-flow), and each backed by an Application (the slug column below):

Provider pk name external_host app slug
1 embedded-outpost https://www.franksdank.cloud caddy
2 search-proxy https://search.franksdank.cloud search
4 prometheus-proxy https://prometheus.franksdank.cloud prometheus
5 romm-proxy https://romm.franksdank.cloud romm
6 files-proxy https://files.franksdank.cloud files
7 loki-proxy https://loki.franksdank.cloud/ loki
8 grafana-proxy https://grafana.franksdank.cloud/ grafana-guard
10 seerr-proxy https://seerr.franksdank.cloud/ seerr-guard
13 marreta-proxy https://marreta.franksdank.cloud marreta

That is exactly the Caddyfile's guarded set (dankclaw/Caddyfile:283-423), so the lockstep rule in Authentik holds today. Two corrections against the 2026-09-02 table: pk 13 (marreta-proxy) was added when Marreta went onto the chain, and pk 3 (hermes-proxy) no longer exists at all — select … where id=3 returns zero rows, so it is deleted, not "dormant"; its vhost survived but is exempt-by-construction (dankclaw/Caddyfile:179-191). OAuth2 relying parties, not proxy providers, so the proxy outpost does not deserialize them: pk 9 grafana (grant_types = {authorization_code}), pk 12 Seerr OIDC (grant_types = {} — empty, i.e. still in the state the token-endpoint rule below rejects), and pk 14 Open WebUI (grant_types = {authorization_code,refresh_token}). Trailing slashes on external_host (the runbook-recipe style) and bare origins both intercept correctly — the outpost matches on hostname.

Provisioning rules for proxy providers

Two provisioning rules, each verified live by an outage:

  1. All three flows are required. A ProxyProvider created via raw API/ORM with any of authentication_flow/authorization_flow/invalidation_flow null wedges the embedded outpost's config fetch: the Rust side rejects the whole config JSON (serde: invalid type: null, expected a formatted UUID string), logs Failed to fetch outpost from API, retrying in 3 seconds forever, and everything behind the outpost dies at the next outpost reload — including hosts whose providers were fine (verified 2026-09-02 when three providers provisioned without any flows took down every guarded vhost after a server restart). Backfill all three flows (copy from pk 1: default-authentication-flow, default-provider-authorization-implicit-consent (the implicit-consent authorization flow, default-invalidation-flow) and the retry loop recovers on its next 3-second cycle. The silent killer: while the outpost process keeps its last good in-memory config, everything works — the outage only surfaces on restart/reload.
  2. Every proxy provider needs an attached Application. The outpost resolves the post-login redirect through the provider's application; a provider with no app completes the login (authentik session established) but bounces to authentik's own user UI (/if/user/#/library) instead of the app. No error is logged anywhere — the SSO "works" and the app never appears. Create the Application with provider_id set (the UI does this automatically; raw API/ORM drives must do it explicitly).

OAuth2 relying-party quirks (this fork, verified live 2026-08-31)

The Grafana RP provider (grafana, pk 9, confidential client) was created and verified end-to-end via ak shell ORM drives. This fork's OAuth2 provider implementation diverges from upstream in ways that bit us; all verified live:

  • Application row required. OAuth2Provider.objects.create() does not create the single-table-inheritance Application row; without it the flow executor can never complete an authorize request. Create both rows.
  • grant_types defaults to empty. Until the provider's grant_types contains "authorization_code" (enum value with underscore), the token endpoint rejects every exchange with invalid_request.
  • Token endpoint is form-encoded + Basic-auth only. A JSON body makes parse_token_request see no grant_type → unsupported_grant_type; POST-body client secrets are not accepted — authenticate the confidential client with Authorization: Basic base64(client_id:client_secret).
  • Provider resolution is by client_id query param on the global /application/o/authorize/ route; per-slug authorize paths do not exist in this build. Token/userinfo live at /application/o/token/ and /application/o/userinfo/?api=1.
  • Property-mapping evaluator is broken → userinfo returns sub only. PropertyMappingEvaluator.evaluate(*args, **kwargs) forwards kwargs to BaseEvaluator.evaluate(expression_source), so the first context kwarg becomes the expression source instead of the context; every call raises a swallowed PropertyMappingExpressionException and the claim is skipped. Scope-grant filtering has the same class of bug: ScopeMapping has no provider FK (linkage lives in the ProviderPropertyMapping through model), so filter(provider=...) always yields an empty queryset and tokens asked to carry scopes get zero. Net effect: userinfo/introspection answers with {sub} only — email/groups/entitlements claims cannot be delivered by this build until the evaluator is patched or upgraded. Clients consuming this provider must key identity off sub.
  • Scope mappings for this provider exist — grafana-email-scope and grafana-profile-scope are both rows in authentik_core_propertymapping (read-only SELECT, 2026-10-18) — and will start emitting claims the moment the evaluator bug is fixed; no config change is needed then.

LDAP provider objects (provisioned via ak shell ORM)

The LDAP outpost's own deployment — unit, image digest, published ports, token env var — is in Authentik. The objects it serves:

Read from the live database (read-only SELECTs against the authentik-postgres container, 2026-10-18):

Object Value
LDAP provider Jellyfin LDAP (pk 11), base DN dc=franksdank,dc=cloud, search_mode=direct, bind_mode=direct
Bind DN cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud (unverified 2026-10-18: base DN confirmed in the DB, the ou=users path was not probed — an LDAP bind needs the bind secret)
Service account svc-jellyfin-ldap (user pk 8). It is not passwordless: its password field is a real hash, not Django's !-prefixed unusable marker (starts_with(password,'!') → f) — an earlier revision's "passwordless/unusable" claim is wrong, which matters because an account with a usable password can sign in through the ordinary password flow (unverified 2026-10-18: whether anyone holds that password)
Bind token exactly one row for pk 8, intent=app_password, non-expiring — an earlier revision recorded intent=verification; the live value is app_password
RBAC role svc-jellyfin-ldap-search, carrying exactly one object-level permission authentik_providers_ldap.search_full_directory (verified via guardian_roleobjectpermission)
Groups jellyfin_users, jellyfin_admins (both exist in authentik_core_group)
Application name Jellyfin, slug jellyfin-ldap (an earlier revision said slug jellyfin, which does not exist)

Hard-won rules (the first is now contested by the live database):

  • Token intent: contested, do not trust either value blindly. An earlier revision asserted that a bind token with intent=app_password is rejected by the flow's password stage with Invalid password and only intent=verification passes. The live token for svc-jellyfin-ldap is app_password (authentik_core_token, 2026-10-18), so either the rule is wrong or the current Jellyfin bind path is not that token. Nothing observable settles it today: the LDAP outpost's retained log holds only its 5-minute initialized direct binder / Update providers config cycles and no bind activity (unverified 2026-10-18: no bind was performed — reproducing it requires the bind secret). Re-verify with a real bind before changing the token's intent either way.
  • Search needs search_full_directory. Without the RBAC grant the outpost returns only the bind user itself — a (memberOf=…) filter then matches zero entries and the plugin logs Found no users matching X. The permission is object-level on the provider; grant it via an RBAC role and retest.
  • ak shell import path for the provider model in 2026.8: from authentik.providers.ldap.models import LDAPProvider. (unverified 2026-10-18: importing it outside a configured Django shell fails with ImproperlyConfigured, and no ak shell was driven against the live auth service for this check)

Self-serve user-settings flow (password change)

Users change their own account details through the default-user-settings-flow (https://auth.franksdank.cloud/if/flow/default-user-settings-flow/) — the target of the homepage footer's "account" link. It is a stage_configuration designated flow, so it renders every bound ConfigurableStage for the logged-in user.

Stages, in execution order (the FlowStageBinding.order field, not the M2M insertion order):

order stage type what it does
20 default-user-settings PromptStage name / email / locale + password
100 default-user-settings-write UserWriteStage writes the prompt data to the user

Password change is a prompt field, not a stage. The UserWriteStage writes whatever the preceding PromptStage collected: for the key password it calls user.set_password(value) (stages/user_write/stage.py). So the fix is a password field on the default-user-settings prompt (field_key=password, type=password, required=False, order 204) — NOT a PasswordStage. A PasswordStage is an authentication stage: its validate_password runs authenticate() against the backends to check the current password; it never sets a new one. (A PasswordStage was added on 2026-09-25 and removed the same day for exactly this reason.)

Two ORM gotchas: Flow.stages.add() fails on this through model (Can't bulk create a multi-table inherited model — FlowStageBinding is MTI), so create the FlowStageBinding row directly with an explicit order; and a prompt field is a Prompt row linked through PromptStageField (prompt_stage + prompt; the order comes from Prompt.order).

A blank password must not set an empty password. set_password("") would make the account log in with no password, so a validation policy (default-user-settings-no-empty-password, bound to the prompt via PromptStageValidationPolicy) denies the form when prompt_data["password"] is "" and allows it when set. Verified with the PolicyEvaluator (empty → deny, set → allow, absent → allow) and end-to-end by driving UserWriteStageView.update_user on a throwaway user (new password valid, old invalid).

Live database shape

For live DB truth prefer the postgres container, and it works without any credential — the local socket is trusted, so a read-only audit is just podman exec -i authentik-postgres psql -U authentik -d authentik -At -f - with SQL on stdin (quoting SQL through ssh+zsh otherwise fights you). Verify before guessing: 2026.x renamed both tables and columns, and the models are MTI all the way down — authentik_core_provider holds id, name and the three *_flow_id columns; the proxy table is authentik_providers_proxy_proxyprovider (doubled proxy) and its PK column really is oauth2provider_ptr_id (proxy providers inherit the OAuth2 table in 2026.8), the LDAP table is authentik_providers_ldap_ldapprovider with PK provider_ptr_id; authentik_core_application attaches to a provider through provider_id (its PK is policybindingmodel_ptr_id, not id), and authentik_outposts_outpost is keyed by uuid. So Provider.objects.filter(type=…) raising FieldError is the same story from the ORM side: type lives in the subclass tables. Never select secret columns (client_secret, key, password) into a log.

Cross-references

  • Authentik — what the service is, its deployment, wiring, operations and failure modes
  • Authentik Processes — the postgres, server and worker containers
  • Caddy — the reverse proxy carrying the :8016/:8017 auth-chain listeners
  • Jellyfin — consumes the LDAP outpost via its LDAP-Auth plugin
  • Grafana — guarded vhost and OAuth2 relying party of this provider