docs(jellyfin,authentik): the LDAP admin filter is inert; the auth door lives in Users

Diagnosed 'authentik login does not work in Jellyfin' 2026-11-04: nothing was
broken. The Jellyfin admin account 'dank' is a LOCAL-provider account, and the
operator's authentik identity (akadmin) was not in jellyfin_users, so the plugin
search found nothing and Jellyfin fell back to local auth - same UI message,
opposite fix. akadmin is now in jellyfin_users (directory data, DB-owned).

- Jellyfin: the admin-filter law (DN match, so a group-OU base grants nothing;
  memberOf shape is the working one), the AuthenticationProviderId diagnostic,
  re-verified LDAP path (bind + memberOf search from Dankserver).
- Authentik Outpost Reference: settle the token-intent argument (the plugin
  sends a password, never a token), verify the bind DN live, and record the
  instruments/traps: the outpost journal never shows a bind, Event is keyed by
  created/action, Outpost has no apps, and piping ak shell steals its heredoc.
- Authentik: stop restating the LDAP outpost digest - the unit file owns it.
This commit is contained in:
Frank committed 2026-11-04 12:25:57 -08:00
1 parent 146c28da9f
commit 70be24e9d5
3 files changed
+81 -41

No files matched your search

@@ -148,36 +148,46 @@ Read from the live database (read-only `SELECT`s against the
| 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` |
| Bind DN | `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud` — **verified by a live bind 2026-11-04** (the bind and the `memberOf` search both succeed, so the `ou=users` path is right) |
| Service account | `svc-jellyfin-ldap` (user pk 8). It has a usable password and authenticates through the default flow — that password is held by Jellyfin in the plugin config (`LdapBindPassword`) and is what the bind actually uses (live bind 2026-11-04) |
| Bind token | exactly one row for pk 8, **`intent=app_password`**, non-expiring — and **unused by Jellyfin**, which has no token field (see the rule below) |
| 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):
Hard-won rules:
- **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.
- **Token intent is moot for Jellyfin.** The earlier argument (a bind token with
`intent=app_password` is rejected by the flow's password stage, only
`intent=verification` passes) never applied here: LDAP-Auth sends the service
account's **password** from `LdapBindPassword`, not a token. A live bind plus a
`memberOf=cn=jellyfin_users` search (11 entries) succeeded from Dankserver on
2026-11-04. Leave the token row alone.
- **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.
- **The outpost log never shows a bind.** Its retained journal is only the
5-minute `initialized direct binder` / `Update providers` config cycle; the
absence of a bind line is not evidence of anything. Instruments that do answer:
`ldapsearch` with the plugin's own DN, password and filter, and the authentik
event registry (`login` / `login_failed` with `client_ip` from the podman
gateway, since the published port SNATs).
- `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)*
`from authentik.providers.ldap.models import LDAPProvider` (verified inside a
live `ak shell`, 2026-11-04 — it only fails outside a configured Django shell).
- **`ak shell` field names, 2026.8:** `Event` is keyed by `created` and `action`
— there is no `timestamp` and no `type`; `Outpost` has `providers` but **no
`apps`**. Introspect first: `[f.name for f in Model._meta.get_fields()]`.
- **Never pipe `ak shell` inside a heredoc.** `podman exec -i … ak shell | grep
-v '^{'` binds the heredoc to `grep` (the redirection belongs to the LAST
element of the pipeline), so the shell gets no stdin and prints nothing at all.
And `2>/dev/null` on `ak shell` eats the traceback, which looks identical to "no
rows". Clean pattern: have the script write its results to a file and read that
file back —
`podman exec -i authentik-server ak shell >/dev/null 2>/dev/null <<'PY' … open("/tmp/x.txt","w") … PY`
then `podman exec authentik-server cat /tmp/x.txt`.
## Self-serve user-settings flow (password change)