docs(vault): fleet-wide fact re-verification pass (67 pages)

The docs-fact census wave re-read the live hosts and re-keyed every page
it could: runtime/payload identities, ports, sizes, ledger figures, unit
state, and the retirement banners that drifted as the fleet moved. The
wave was interrupted mid-run, so this commit carries only what passed the
integrity gates: every touched page parses (front matter or H1+status
convention intact, balanced fences, no truncated tables, trailing
newline), the docs_completeness and link-integrity seams are green, and
bats is 384/384.

Numbers that this session proved on live hosts are keyed to the measured
value, not the page's earlier claim.
This commit is contained in:
Frank committed 2026-10-18 13:15:43 -07:00
1 parent 466fd477d8
commit e7f0d93c86
67 files changed
+2619 -1152

No files matched your search

+243 -104
View File
@@ -11,27 +11,43 @@ timestamp: 2026-09-16
Authentik is the single sign-on (SSO) and OpenID Connect (OIDC) provider for the
homelab. It protects the Caddy vhosts that do not carry their own
application-level authentication with its embedded proxy outpost. Apps that
have their own login (Grafana, Gitea, OpenWebUI, Jellyfin, the *arr stack,
Pi-hole) are not wrapped — they handle their own session.
application-level authentication with its proxy outpost. Not wrapped: Gitea,
OpenWebUI, Jellyfin (LDAP outpost), the *arr stack, Pi-hole (basicauth edge) —
they handle their own session. Grafana is the exception that is both: it is a
guarded vhost (`grafana-proxy`, pk 8, locked to Authentik SSO) *and* an OAuth2
relying party (`grafana`, pk 9) (`dankclaw/Caddyfile:122-132`,
`dankclaw/Caddyfile:374-377`; live provider table below).
- **Version:** `2026.8.3` (pinned in `dankclaw/authentik/quadlet/*.container`)
- **Version:** `2026.8.3` (pinned in `dankclaw/authentik/quadlet/*.container`;
the outpost's boot log reads `"Booting authentik" … "version": "2026.8.3"`,
2026-10-18)
- **Host:** dankclaw (`192.168.0.143`)
- **Name:** `auth.franksdank.cloud`
- **Stack:** 3 compose services — `authentik-postgres`, `authentik-server`,
`authentik-worker` (see [Pi-hole](Pi-hole.md) for the LAN-DNS pattern)
- **LDAP outpost:** a 4th, quadlet-deployed unit (`authentik-outpost-ldap`)
serves LDAP for Jellyfin — see the section below.
- **Stack: five quadlet units, no compose.** `authentik-postgres`,
`authentik-server`, `authentik-worker` (`deploy_authentik`,
`scripts/deploy-remote.sh:980-1004`) plus two outposts — `authentik-outpost`
(the Rust **proxy** outpost that actually serves `/outpost.goauthentik.io/*`,
`deploy_authentik_outpost`, `scripts/deploy-remote.sh:1006-1018`) and
`authentik-outpost-ldap` (`deploy_authentik_outpost_ldap`,
`scripts/deploy-remote.sh:1020-1032`). All five report `generated` +
`active`, `Restart=always`, `WantedBy=default.target`, with linger on
(`systemctl --user`, 2026-10-18). The "3 compose services" wording is the
pre-quadlet era, retired.
- **LDAP outpost:** serves LDAP for Jellyfin — see the section below.
## Why Authentik
The comparison that chose it (against Authelia, Keycloak, Zitadel, Ory) is in
`.docaudit/research/auth.md`. In short: full OIDC with single-logout, a web GUI
for all configuration, an outpost that terminates auth for reverse-proxied
apps, and a footprint that fits on a 3-user homelab (~1–1.5 GB idle). Redis
was removed in 2025.10, so the stack is 3 services, not 4. (The integration
mechanism changed in 2026.x — see Architecture; the research file predates
the pin.)
The comparison that chose it (against Authelia, Keycloak, Zitadel, Ory) was
recorded in `.docaudit/research/auth.md`, which no longer exists in the repo or
in the host checkout (`ls`, 2026-10-18) *(unverified 2026-10-18: the
comparison itself)*. In short: full OIDC with single-logout, a web GUI for all
configuration, an outpost that terminates auth for reverse-proxied apps, and a
footprint that fits on a 3-user homelab — measured idle today
`podman stats --no-stream`: postgres 233.8 MB + server 739.1 MB + worker
292.4 MB + outpost 294.3 MB + ldap outpost 46.6 MB ≈ **1.6 GB** across five
containers (the older "~1–1.5 GB" figure was the three-service stack). Redis
was removed in 2025.10; the integration mechanism changed again in 2026.x (see
Architecture).
## Architecture
@@ -46,6 +62,25 @@ graph LR
S --> PG[(postgres)]
```
Who answers was re-measured 2026-10-18 from inside the `caddy` container with a
tenant-matching `Host: search.franksdank.cloud`:
`http://authentik-server:9000/outpost.goauthentik.io/start` → **302 into the
authentication flow**, while `http://authentik-outpost:9000/…` answers **404**
for `/outpost.goauthentik.io/*` *and* for `/if/flow/default-authentication-flow/`.
So the **server process** still terminates the proxy-provider decision, exactly
as Caddy's `:8016 → authentik-server:9000` hop assumes
(`dankclaw/Caddyfile:93-98`). The `authentik-outpost` unit is installed,
enabled (`generated`) and active, and it is the DB's `authentik Embedded
Outpost` (`7e39655b-…`, nine providers attached) holding the websocket channel
to core — but it serves no HTTP surface on the paths the chain needs. The unit
header's stronger claim that the URL space "is served by the Rust worker
binary, not the server"
(`dankclaw/authentik/quadlet/authentik-outpost.container:6-14`) is **not** what
this probe shows; do not point a new listener at `authentik-outpost:9000`
without re-measuring. Note also that a probe without a tenant `Host` (e.g.
`curl http://127.0.0.1:9000/…`) 404s on paths that work — that is the
Host-routing rule below, not an outage.
Two mechanisms were disproved live (2026-08-29/30) and one is the actual
contract:
@@ -97,7 +132,7 @@ http://:8016 {
}
```
The embedded outpost inside the server process decides by `Host`:
The outpost decides by `Host`:
no session → the provider's `authentication_flow` is served in place;
session valid → the request is forwarded to the provider's `internal_host`
(`http://caddy:8017`), where per-vhost `handle` blocks reach the apps.
@@ -107,10 +142,15 @@ routing table, and the Authentik proxy-provider list must be the same set of
hosts. A guarded host with no provider 404s at login time; a provider with no
DNS record breaks off-LAN clients.
Live provider inventory (verified 2026-09-02), all attached to outpost
`7e39655b-…` ("authentik Embedded Outpost"), all with
`internal_host = http://caddy:8017`, all three flows set, and each linked to
an Application (see the provisioning rules below):
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 |
|---|---|---|---|
@@ -122,13 +162,21 @@ an Application (see the provisioning rules below):
| 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 |
Provider 3 (`hermes-proxy`) still exists but is dormant — its vhost was
decommissioned from Caddy on 2026-08-31. Provider 9 (`grafana`) and 12
(`Seerr OIDC`) are OAuth2 relying-party providers, not proxy providers; the
outpost does not deserialize them. Trailing slashes on `external_host` (the
runbook-recipe style) and bare origins both intercept correctly — the outpost
matches on hostname.
That is exactly the Caddyfile's guarded set (`dankclaw/Caddyfile:283-423`), so
the lockstep rule 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.
Two provisioning rules, each verified live by an outage:
@@ -188,47 +236,65 @@ implementation diverges from upstream in ways that bit us; all verified live:
`{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`,
`grafana-profile-scope`, Python-syntax expressions) and will start emitting
claims the moment the evaluator bug is fixed — no config change needed then.
- 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 outpost (Jellyfin auth, live 2026-09-01)
A fourth unit, `dankclaw/authentik/quadlet/authentik-outpost-ldap.container`
→ `authentik-outpost-ldap.service`, serves LDAP for Jellyfin's LDAP-Auth
plugin. The image is pinned by its **amd64 single-platform digest**
(`ghcr.io/goauthentik/ldap@sha256:fa33bc8f522113830e1662f4c034352f9eb08e0a7d3da2ddadad9a4b33b4cbf4`):
podman 5.7.0 refuses the
`ghcr.io/goauthentik/ldap:2026.8.0` tag because the tag resolves to a
*nested* manifest list (index → per-platform manifest lists) —
`unexpectedly received a manifest list instead of a manifest for a single
image`. Resolve once with `podman manifest inspect <image> | jq -r
'.manifests[] | select(.platform.architecture=="amd64") | .digest'` and pin
that digest.
The second outpost — `dankclaw/authentik/quadlet/authentik-outpost-ldap.container`
→ `authentik-outpost-ldap.service`, one of the five units in this stack —
serves LDAP for Jellyfin's LDAP-Auth plugin (`enabled`=generated, `active`,
`Restart=always`, container up, 2026-10-18). The image is pinned by its **amd64
single-platform digest**
`ghcr.io/goauthentik/ldap@sha256:2e7b259eb8c37faac916946ddf48ec681ee9d3a9f724fe8521bf161973f13cf9`
(`dankclaw/authentik/quadlet/authentik-outpost-ldap.container:26`, matching the
running image in `podman ps`, 2026-10-18). The digest in this page before 2026-10-18
(`…fa33bc8f…`) is superseded. The reason for pinning a digest at all: podman
5.7.0 refuses the plain tag because it resolves to a *nested* manifest list
(index → per-platform manifest lists) — `unexpectedly received a manifest list
instead of a manifest for a single image` — first hit on `:2026.8.0`, still the
rule at the current `2026.8.3` pin. Resolve once with `podman manifest inspect
<image> | jq -r '.manifests[] | select(.platform.architecture=="amd64") |
.digest'` and pin that digest.
- Ports **389** (ldap) and **636** (ldaps) publish on the LAN; Jellyfin on
Dankserver binds `192.168.0.143:389`. The outpost token travels in
`AUTHENTIK_LDAP_OUTPOST_TOKEN` from the host `.env` (git-ignored).
- Ports **389** (ldap) and **636** (ldaps) publish on the LAN
(`PublishPort=389:3389`, `636:6636`,
`dankclaw/authentik/quadlet/authentik-outpost-ldap.container:30-31`; `ss -ltnp`
shows `0.0.0.0:389` and `0.0.0.0:636`, 2026-10-18) and Dankserver can reach
them — a bare TCP connect from Dankserver to `192.168.0.143:389` succeeds
(2026-10-18). The outpost token travels in `AUTHENTIK_LDAP_OUTPOST_TOKEN`
from the host `dankclaw/.env` (git-ignored).
### Authentik-side objects (provisioned via `ak shell` ORM)
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` |
| Bind DN | `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud` |
| Service account | `svc-jellyfin-ldap` — passwordless/unusable; authenticates by token |
| Bind token | intent **`verification`**, non-expiring — its key is the LDAP bind password |
| RBAC role | `svc-jellyfin-ldap-search` carrying object-level permission `authentik_providers_ldap.search_full_directory` on the provider |
| Groups | `jellyfin_users`, `jellyfin_admins` |
| Application | `jellyfin` (slug `jellyfin`) |
| 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`) — the page's earlier "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 — the page previously 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`** (the page previously said slug `jellyfin`, which does not exist) |
Hard-won rules, each verified live:
Hard-won rules (the first is now contested by the live database):
- **Token intent is load-bearing.** A bind token with `intent=app_password`
is rejected by the flow's password stage with `Invalid password`;
`intent=verification` passes. The outpost's direct binder executes the
provider's authorization flow, and that password stage only accepts
verification-intent tokens as static passwords.
- **Token intent: contested, do not trust either value blindly.** This page
long 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`.
@@ -236,19 +302,38 @@ Hard-won rules, each verified live:
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)*
## Configuration (secrets)
Two secrets live in the host `.env` (git-ignored; placeholders in
`.env.example`):
**Four** variables live in the host env file the units read,
`/home/dank/serverAppConfigs/dankclaw/.env` (git-ignored; the quadlets expand
`${…}` from it via `EnvironmentFile`):
- `AUTHENTIK_PG_PASS` — the PostgreSQL password. Generate: `openssl rand -base64 32`.
- `AUTHENTIK_SECRET_KEY` — the server's signing key. Generate: `openssl rand -base64 48`.
- `AUTHENTIK_OUTPOST_TOKEN` — proxy outpost token
(`dankclaw/authentik/quadlet/authentik-outpost.container:34`).
- `AUTHENTIK_LDAP_OUTPOST_TOKEN` — LDAP outpost token
(`…/authentik-outpost-ldap.container:29`).
Only the first two have placeholders in `dankclaw/.env.example`; the two outpost
tokens have no example entry (there is no repository-root `.env.example` —
`find`, 2026-10-18). Rotating `AUTHENTIK_SECRET_KEY` fleet-wide invalidates
every outpost session (see the ORM playbook).
## First-time setup
1. `./scripts/deploy.sh dankclaw` — pulls, starts the 3 services (postgres
health-gated), reloads Caddy.
1. `./scripts/deploy.sh dankclaw --service authentik-server` (any of
`authentik-postgres|authentik-server|authentik-worker` runs
`deploy_authentik`, `scripts/deploy-remote.sh:980-1004`) — installs the three
core quadlets, starts postgres health-gated (`wait_container_healthy
authentik-postgres 120`), then the server (300 s health wait) and the worker.
The outposts are separate handlers: `--service authentik-outpost` and
`--service authentik-outpost-ldap` (`scripts/deploy-remote.sh:1006-1032`,
dispatch at `:1579-1588`).
2. Open `https://auth.franksdank.cloud` — the initial setup wizard runs. It
creates the superuser (email + password) and generates the DB schema.
3. For every guarded vhost, in the admin interface: **Directory →
@@ -257,20 +342,36 @@ Two secrets live in the host `.env` (git-ignored; placeholders in
(`https://<host>`), `internal_host = http://caddy:8017`, and **all three**
flows set (see the provisioning pitfall above). Attach the provider to the
embedded outpost.
4. For apps with native OIDC (Grafana, Gitea, OpenWebUI): add an **OAuth2/OpenID
Connect** provider, create the app with the correct redirect URI, and paste
the `client_id` / `client_secret` into the app's SSO settings.
4. For apps with native OIDC: add an **OAuth2/OpenID Connect** provider, create
the app with the correct redirect URI, and paste the `client_id` /
`client_secret` into the app's SSO settings. The relying parties that exist
today are `grafana` (pk 9), `Seerr OIDC` (pk 12) and `Open WebUI` (pk 14);
**Gitea has no provider row at all** (live provider table, 2026-10-18) — it
authenticates itself and stays off the chain by design
(`dankclaw/Caddyfile:110-120`).
## Operations
- **Health:** `https://auth.franksdank.cloud/-/health/` (the server healthcheck
probes this).
- **Health:** there is **no** HTTP health path on this build —
`/-/health/`, `/-/live/` and `/api/-/health/` all answer **404**, on
`127.0.0.1:9000` and through `https://auth.franksdank.cloud` alike
(`curl`, 2026-10-18). The container's own probe is Django-side:
`HealthCmd=python /manage.py check --database default`
(`dankclaw/authentik/quadlet/authentik-server.container:36`), and
`podman inspect authentik-server` reports `healthy`. A usable liveness URL is
a flow page: `https://auth.franksdank.cloud/if/flow/default-user-settings-flow/`
→ 200 (2026-10-18). The earlier "the server healthcheck probes `/-/health/`"
line was wrong in both halves.
- **Logs:** `podman logs authentik-server` / `podman logs authentik-worker`.
- **Backups:** `pg_dump` the `authentik-postgres` database (the single source of
truth). The `/home/dank/Containers/authentik/data` dir holds media + static files.
- **Upgrades:** bump the image tag in the three units under
`dankclaw/authentik/quadlet/`, run `./scripts/deploy.sh dankclaw`. Authentik
migrates the schema on startup.
truth). The `/home/dank/Containers/authentik/data` dir holds media + static
files (mounted by all three app units).
- **Upgrades:** bump the tag in **all three app units** under
`dankclaw/authentik/quadlet/` and re-deploy; `authentik-outpost` carries the
same tag (it runs the same image, `Exec=worker`) and `authentik-outpost-ldap`
is **digest-pinned**, so its pin must be re-resolved for the new version (see
the LDAP section) — bumping only the tag would leave the LDAP outpost behind.
Authentik migrates the schema on startup.
## Self-serve user-settings flow (password change)
@@ -374,8 +475,11 @@ client → TLS edge → Anubis :8923 → outpost decision → internal
`podman exec caddy sh -c "grep -n -A3 <handler> /etc/caddy/Caddyfile"` —
after the deploy prints its reload line; confirm the reload actually ran,
don't trust exit 0.
- Outpost reachability: `podman exec authentik-server getent hosts caddy`
resolves the compose DNS alias (10.89.1.x) that `internal_host` dials.
- Outpost reachability: `podman exec authentik-server getent hosts caddy` →
`10.89.1.8 caddy.dns.podman` (2026-10-18) — that is Caddy's address on the
`dankclaw_default` podman network, the alias `internal_host` dials. It is a
netavark allocation, not a pinned address and not a compose artifact (the
compose stack is gone); never hard-code it.
**Pitfalls:** probing `http://localhost:8017/` from the host returns HTTP
000 (connection refused) — the post-auth listener lives in the caddy
@@ -403,22 +507,36 @@ EOF
- The heredoc IS the program: `ak shell` reads all of stdin and execs it
(heredoc-through-ssh quoting breaks; the raw-stdin pattern is the proven
one).
- Bootstrap prints JSON log lines, then
`### authentik shell (2026.8.3)` and `229 objects imported automatically…`
— pipe through `sed -n '/229 objects/,$p'` to drop the noise.
- 229 models are pre-imported. Import paths (each wrong guess costs a failed
exec): `User`, `Group`, `Application`, `ApplicationEntitlement`,
`PropertyMapping` → `authentik.core.models`; `ProxyProvider` →
- Bootstrap prints JSON log lines, then a shell banner and an
"`NNN objects imported automatically…`" line — pipe through
`sed -n '/objects imported/,$p'` to drop the noise *(unverified 2026-10-18:
the exact banner text and the 229-object count — no `ak shell` was driven
against the live auth service for this audit)*.
- Import paths (each wrong guess costs a failed exec): `User`, `Group`,
`Application`, `ApplicationEntitlement`, `PropertyMapping` →
`authentik.core.models`; `ProxyProvider` →
`authentik.providers.proxy.models` (NOT `authentik.outposts.models`);
`LDAPProvider` → `authentik.providers.ldap.models`;
`authentik.core.models.application` does NOT exist as an import path; there
is no `authentik.policies.group` module in this build.
- For live DB truth prefer the postgres container
(`podman exec authentik-postgres psql -U authentik -d authentik -At -c
"select * from …"`) — **query with `select *` and stop guessing column
names**: 2026.x renamed columns (`Provider.objects.filter(type=…)` raises
FieldError — `type` moved to subclass tables; the outpost `enabled` column
may not exist under that name; provider pk is `oauth2provider_ptr_id`).
*(unverified 2026-10-18: these module paths were not re-executed — importing
outside a configured Django shell fails with `ImproperlyConfigured`)*
- 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.
## User provisioning playbook (user-level account)
@@ -435,33 +553,42 @@ for g in ("Non-Admin Users", "jellyfin_users", "openwebui_users"):
u.groups.add(Group.objects.get(name=g))
```
`path` defaults to `users` (regular). The access map (verified live
2026-10-04): there are NO per-app policy bindings on the proxy providers
(all use the implicit-consent "Authorize Application" flow), so guarded-vhost
access is decided entirely by GROUP MEMBERSHIP consumed downstream —
`path` defaults to `users` (regular). The access map (re-checked against the
live DB 2026-10-18): **every one of the nine proxy providers carries the same
authorization flow UUID**, `default-provider-authorization-implicit-consent`
(name "Authorize Application"), i.e. there are no per-app policy bindings to
delta, so guarded-vhost access is decided entirely by GROUP MEMBERSHIP consumed
downstream —
- `homepage-admins` → Caddy RBAC for homepage `/admin` (`X-Authentik-Groups`
regex gate) and the homepage admin panel; regular users get only
`category: user` panels from `services.json`.
- `openwebui_users` / `openwebui_admins` → ApplicationEntitlements
"Open WebUI Users"/"Open WebUI Admins" (bound by group), consumed by the
"Open WebUI roles" property mapping → `roles` claim. Verify live with
**"Open WebUI Users"** and **"Open WebUI Admins"**, both rows attached to the
`Open WebUI` application (`authentik_core_applicationentitlement`,
2026-10-18), consumed by the "Open WebUI roles" property mapping → `roles`
claim *(unverified 2026-10-18: the group→entitlement binding and the mapping
expression were not read; the mapping itself is not visible under that name
in `authentik_core_propertymapping`)*. Verify live with
`u.app_entitlements(app)`.
- `jellyfin_users` / `jellyfin_admins` → Jellyfin via the LDAP outpost
(filter `(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`).
- `Non-Admin Users` = the plain-user marker group (precedents: qa-e2e, and
every human account).
- **The human-user template** (ridley, kassie, alex, artem, kirby, ry, nic,
debo, freya, mitch, eric — mitch (pk 22) and eric (pk 23) created
2026-10-15, kirby/ry/nic/debo/freya (pk 17–21) created 2026-10-09, kassie
and alex created 2026-10-08, artem (pk 16) created 2026-10-07): `username` +
`<user>@franksdank.cloud` + display name +
`is_active=True` + the password set via `set_password`, membership in
`Non-Admin Users` + `jellyfin_users` + `openwebui_users`. `alex` was
recreated from scratch on 2026-10-08 into this shape (the old row had an
empty email and no `openwebui_users`; audit first — the row carried no MFA
device, token, entitlement, or proxy session, and events survive a user
delete because `Event.user` is a denormalized JSON field, not a FK).
debo, freya, mitch, eric). The pks in this page are confirmed against
`authentik_core_user` on 2026-10-18: ridley 13, kassie 14, alex 15,
artem 16, kirby 17, ry 18, nic 19, debo 20, freya 21, mitch 22, eric 23 —
and every one carries the `<user>@franksdank.cloud` address the template
prescribes. Creation dates (2026-10-07 → 2026-10-15) were not re-checked
*(unverified 2026-10-18)*. Template: `username` +
`<user>@franksdank.cloud` + display name + `is_active=True` + the password
set via `set_password`, membership in `Non-Admin Users` + `jellyfin_users` +
`openwebui_users`. `alex` was recreated from scratch on 2026-10-08 into this
shape (the old row had an empty email and no `openwebui_users`; audit first —
the row carried no MFA device, token, entitlement, or proxy session, and
events survive a user delete because `Event.user` is a denormalized JSON
field, not a FK).
- EXCLUDE from a user-level account: `homepage-admins`, `jellyfin_admins`,
`openwebui_admins`, `authentik Admins`, `authentik Read-only`.
- Other user-category vhosts (SearXNG, Gitea, Calibre-Web, RomM, Copyparty,
@@ -479,14 +606,24 @@ assert authenticate(request=None, username="ridley", password="nope") is None
Look flows up by slug, not name:
`Flow.objects.get(slug="default-authentication-flow")` (its `.name` is
"Welcome to authentik!" — `.get(name=…)` fails). Stage order:
Identification(10) → Password(20) → AuthenticatorValidate(30; no-op with no
enrolled device) → UserLogin(100). With no MFA enrolled, nothing else blocks.
"Welcome to authentik!" — `.get(name=…)` fails; both confirmed from
`authentik_flows_flow`, 2026-10-18). Stage order, read from
`authentik_flows_flowstagebinding` the same day:
`default-authentication-identification`(10) →
`default-authentication-password`(20) →
`default-authentication-mfa-validation`(30; no-op with no enrolled device) →
`default-authentication-login`(100). With no MFA enrolled, nothing else blocks.
More field gotchas (each cost a failed exec): `ApplicationEntitlement.application`
can be null (guard with `getattr`); `LDAPProvider` has
`search_mode`/`bind_mode`/`base_dn`, lacks `search_base`/`of_groups`/
`organization`; `OAuth2Provider` has no `.scopes`; `Outpost.config` is an
`search_mode`/`bind_mode`/`base_dn` and lacks `search_base`/`of_groups`/
`organization` — the backing table
(`authentik_providers_ldap_ldapprovider`) has exactly `provider_ptr_id`,
`base_dn`, `certificate_id`, `tls_server_name`, `gid_start_number`,
`uid_start_number`, `search_mode`, `bind_mode`, `mfa_support`
(`information_schema.columns`, 2026-10-18); `OAuth2Provider` has no `.scopes`
(scopes are linked through `ProviderPropertyMapping`, which is why
`filter(provider=…)` on a scope mapping is empty); `Outpost.config` is an
`OutpostConfig` object, not a dict; `Policy` has no uniform `.policy_cls`
(use `type(p).__name__`); `User` has no `.status`.
@@ -536,3 +673,5 @@ podman run --rm --network container:<container> --entrypoint /bin/busybox \
- [Jellyfin](../Dankserver/Jellyfin.md) — consumes the LDAP outpost via its LDAP-Auth plugin
- [Network Architecture](../Architecture/Network%20Architecture.md) — the flat naming scheme
- Research: `.docaudit/research/auth.md` — the full provider comparison
*(the file is gone from the repo and from the host checkout as of
2026-10-18)*