Configuring authentik¶
terraform/authentik-config owns everything inside authentik: the Active
Directory source today, and the providers, applications and groups that relying
parties need as they arrive. The Flux tree only deploys the software.
The root has no credential of its own. It reads the bootstrap token out of the
authentik-auth Secret with the kubernetes provider and authenticates as
akadmin, exactly as terraform/grafana-config reads grafana-auth. That
couples the root to the cluster, which costs nothing it was not already paying:
authentik has to be up and reachable for an apply to do anything.
Users come from Active Directory¶
The source mirrors the Keycloak federation that already exists, so both systems resolve the same people from the same directory. Keycloak's configuration screen does not map field-for-field onto authentik's, and four differences are deliberate rather than oversights:
| Setting | Value | Why |
|---|---|---|
server_uri |
ldaps://ldaps.gewis.nl |
the only name the certificate is valid for |
start_tls |
false |
the provider defaults it true, which on port 636 means TLS inside TLS |
object_uniqueness_field |
objectSid |
authentik's documented default for AD; Keycloak's objectGUID is proven there, not here |
sync_users_password |
false |
that flag writes passwords back to AD, and the bind account only reads |
ldaps.gewis.nl is load-bearing. The certificate carries
CN=ldaps.gewis.nl with SANs for ldaps.gewis.nl and gewisdc03.gewis.nl only,
so connecting to gewiswg.gewis.nl or a raw address fails hostname verification.
It is publicly trusted (Let's Encrypt), so no CA has to be distributed, and it
resolves from the pod network without a resolver entry.
Reachability is asymmetric and worth knowing before debugging: the cluster can reach AD on 636, a workstation on the mesh cannot. Anything that has to talk to the directory by hand runs from a pod.
Since password_login_update_internal_password is left at its default of
false, authentik never stores a local password for a directory user. Logins
bind against AD every time, so an AD outage means nobody can log in — the
correct posture for a directory-backed IdP, but not a surprise worth having
during one.
Only privilege holders are synced¶
user_object_filter restricts the source to members of
PRIV - Logon Keycloak GEWISWG, matched through
memberOf:1.2.840.113556.1.4.1941: so nested groups count. Without it the source
would pull the entire directory.
That group is Keycloak's, which welds authentik's user set to Keycloak's: anyone
who may log into one may log into the other. A dedicated
PRIV - Logon Authentik GEWISWG would let them diverge, and swapping to it later
costs a filter change and a re-sync.
The bind account is authentik's own¶
svc-authentik@gewiswg.gewis.nl, not the svc-auth account Keycloak binds with.
Sharing one credential between two systems means rotating it is an outage in the
other, and resetting it to find out is an outage in GEWIS SSO — which Grafana
still depends on.
It needs read on users and groups under DC=GEWISWG,DC=GEWIS,DC=nl and nothing
else: no privileged groups, and not a member of the logon privilege group,
which governs who is synced rather than who may bind.
Its password is the one secret¶
secrets/authentik.yaml is admin-readable, declared through
nix/recipients.nix — .sops.yaml is generated, so add the name there and run
nix run .#sops-config rather than editing it by hand. .envrc exports the
value as TF_VAR_ad_bind_password and says so when it cannot.
Applying¶
Then read the synced groups back, because their names are what any OpenBao or Grafana mapping has to match character-for-character:
TOK=$(kubectl -n authentik get secret authentik-auth \
-o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d)
curl -sS -H "Authorization: Bearer $TOK" \
'https://authentik.cbc.gewis.nl:8443/api/v3/core/groups/?page_size=100' \
| jq -r '.results[].name'
Transcribing them from ADUC instead is how a mapping silently matches nothing.
The groups claim comes from the directory, not from authentik's groups¶
authentik's built-in profile scope emits a groups claim, but it builds it from
authentik's own group table:
That table is direct membership only, and this directory nests heavily. Every
application-role group holds zero users — all 14 GRAFANA-*, all 13 PG01 - *,
PRIV - Grafana Admin — while team groups like
CBC - Application Hosting Team (ADM) are full of them, because the role groups
contain groups. member hands authentik a group DN it cannot match to a user, so
the membership is dropped silently and the claim arrives empty. For one account:
memberOf lists 12 groups and none of them is a GRAFANA-*.
Keycloak papers over this with Preserve Group Inheritance, importing the
hierarchy so membership is transitive. authentik has no equivalent, and asking AD
to walk the chain instead — lookup_groups_from_user with
memberOf:1.2.840.113556.1.4.1941: — costs one unindexed search per synced group,
which against 1765 groups is tens of minutes per sync.
None of that is necessary, because AD publishes the answer directly.
memberOfFlattened is a custom attribute on every user holding its full
transitive membership: 156 entries for the same account whose memberOf has 12,
CN=GRAFANA-CBC-RW among them. So the source stores it,
return {"attributes": {"groups": [
dn.split(",")[0].removeprefix("CN=")
for dn in (ldap.get("memberOfFlattened") or [])
]}}
and the Grafana provider carries its own profile scope mapping that emits the
claim from that attribute instead of from request.user.groups:
which is the same shape as the mapper on Keycloak's Grafana client. The managed
goauthentik.io/providers/oauth2/scope-profile is left off the provider, because
two mappings claiming profile would both write groups in an undefined order; the
local one reproduces the rest of that scope's claims — name, preferred_username,
nickname — which login_attribute_path and name_attribute_path key on.
Group membership inside authentik stays direct-only and cheap, from member. It
needs the GEWISWG: distinguishedName mapping to work at all — matching is
attributes.distinguishedName IN members, and none of authentik's nine managed LDAP
mappings writes a DN — but nothing about the OIDC claim depends on it any more.
OIDC clients are a map¶
Each relying party is one entry in oidc_clients:
grafana = {
display_name = "Grafana"
namespace = "observability"
launch_url = "https://grafana.cbc.gewis.nl:8443/"
redirect_uris = ["https://grafana.cbc.gewis.nl:8443/login/generic_oauth"]
}
That renders an authentik_provider_oauth2 and an authentik_application,
generates the client secret, and writes client_id and client_secret to
OpenBao at authentik/<namespace>/<client> with a read policy and a Kubernetes
auth role for that namespace. The consuming namespace pulls them in with an
ExternalSecret, exactly as it does for Garage credentials — so the secret is
generated, stored and consumed without anyone reading it.
Redirect URIs carry :8443. The gateway is published on that port, it is
part of the issuer, and matching_mode = "strict" means a missing port is a
failed login with no useful error.
grant_types must be set explicitly. authentik's provider model defaults the
field to an empty list (ArrayField(..., default=list)), and the Terraform
provider marks it Generated, so a provider created without it permits no grant at
all. Every authorization then dies inside
authentik.providers.oauth2.views.authorize on
which reaches the browser as Grafana's "Login provider denied login request" —
a message that names neither the grant type nor the provider. The list is
["authorization_code", "refresh_token"]: the first for the code flow, the second
because a relying party asking for offline_access renews with it, and the token
endpoint runs the same check.
Grafana authenticates against the directory through authentik¶
The endpoints are the shared per-provider ones, not per-application:
auth_url = https://authentik.cbc.gewis.nl:8443/application/o/authorize/
token_url = https://authentik.cbc.gewis.nl:8443/application/o/token/
api_url = https://authentik.cbc.gewis.nl:8443/application/o/userinfo/
Grafana reaches those from inside the cluster over the public name, which hairpins back through the gateway — verified from the pod, and it costs the documented few-second TLS tax on the first connection. Using the in-cluster Service instead would work for the token exchange but would make the issuer disagree with what the browser sees.
The org mapping did not have to change. Grafana keyed off Keycloak's roles
claim; authentik emits the same names in groups, because GRAFANA-CBC-RO,
GRAFANA-ABC_CRM-RW and the rest are Active Directory groups that Keycloak was
surfacing too. So org_attribute_path moves from roles to groups and the
org_mapping string is untouched.
Two attribute paths do change, because the claim names differ: username becomes
preferred_username and full_name becomes name.