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.
231 lines
14 KiB
Markdown
231 lines
14 KiB
Markdown
---
|
|
type: reference
|
|
description: "Outpost, proxy-provider, OAuth2, LDAP and flow configuration for the homelab's Authentik SSO."
|
|
tags:
|
|
- infrastructure
|
|
- authentik
|
|
timestamp: 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 `SELECT`s, 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 `SELECT`s 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
|