Files
axion1337.chat-gitops/apps/authentik/authentik-blueprints.yaml
T
Thore CimbalandClaude Opus 4.8 afc4ad3f20 feat(authentik): reject colliding usernames case-insensitively at registration
Authentik's own uniqueness is case-sensitive, so 'Boje' and 'boje' pass as
distinct while Matrix treats them as the same localpart. ADR-0011 closed the
takeover vector with on_conflict:fail, but that only bites at login: the user
registers happily and fails later with no explanation. This policy answers where
the mistake is made.

Deliberately reads only prompt_data and never request.user — the stage runs in an
anonymous enrollment context, which is exactly what the previously attached system
policies crashed on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00

510 lines
24 KiB
YAML

apiVersion: v1
kind: ConfigMap
metadata:
name: authentik-blueprints
namespace: authentik
data:
matrix-invitation-flow.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: matrix-invitation-flow
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# Reaffirm the flow itself (already created manually; matched by slug)
- model: authentik_flows.flow
state: present
identifiers:
slug: matrix-invitation
id: matrix_invitation_flow
attrs:
name: matrix-invitation
title: matrix-invitation
designation: enrollment
# The prompt stage had accumulated 16 unrelated system validation_policies
# (e.g. default-user-settings-authorization, default-oobe-password-usable)
# from manual UI setup, likely a "select all" slip in the policy picker.
# These crash on an anonymous enrollment context ('AnonymousUser' object
# has no attribute 'group_attributes', etc). A prompt stage needs none here.
# Eindeutigkeit case-insensitiv pruefen (#0043). Authentiks eigene Pruefung
# ist case-sensitiv, "Boje" und "boje" gelten ihr also als verschieden --
# in Matrix kollidieren sie. Seit ADR-0011 faengt MAS das mit
# on_conflict:fail ab, aber erst BEIM LOGIN: der Nutzer registriert sich
# erfolgreich und scheitert spaeter ohne Erklaerung. Diese Policy gibt die
# Rueckmeldung dort, wo der Fehler entsteht.
#
# ⚠️ Bewusst ohne Zugriff auf request.user: die Stage laeuft im ANONYMEN
# Enrollment-Kontext. Genau daran sind die frueher hier haengenden
# System-Policies gescheitert ('AnonymousUser' hat kein group_attributes).
# Gelesen wird ausschliesslich prompt_data.
- model: authentik_policies_expression.expressionpolicy
state: present
identifiers:
name: matrix-username-eindeutig-ci
id: username_unique_ci
attrs:
execution_logging: false
expression: |
from authentik.core.models import User
gewuenscht = (request.context.get("prompt_data") or {}).get("username") or ""
gewuenscht = gewuenscht.strip()
if not gewuenscht:
return True # Pflichtfeld-Pruefung macht der Prompt selbst
if User.objects.filter(username__iexact=gewuenscht).exists():
ak_message("Dieser Benutzername ist bereits vergeben - auch in anderer "
"Gross-/Kleinschreibung. Bitte waehle einen anderen.")
return False
return True
- model: authentik_stages_prompt.promptstage
state: present
identifiers:
name: matrix-invitation-prompt
attrs:
validation_policies:
- !KeyOf username_unique_ci
# Correct stage chain, mirroring the working matrix-enrollment flow:
# Invite -> Prompt (username/email/password) -> Write -> Password -> Login
# Root cause of the original bug: only Invite+Prompt were bound, both at
# order=0, so the flow never wrote the user to the DB or logged them in.
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 0
attrs:
stage: !Find [authentik_stages_invitation.invitationstage, [name, matrix-enrollment-invitation]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 1
attrs:
stage: !Find [authentik_stages_prompt.promptstage, [name, matrix-invitation-prompt]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 2
attrs:
stage: !Find [authentik_stages_user_write.userwritestage, [name, default-source-enrollment-write]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 3
attrs:
stage: !Find [authentik_stages_password.passwordstage, [name, default-authentication-password]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 4
attrs:
stage: !Find [authentik_stages_user_login.userloginstage, [name, default-source-enrollment-login]]
# Without an explicit destination, the flow falls back to Authentik's own
# /if/user/ dashboard, which refuses type=external users ("Die Oberflaeche
# kann nur von internen Nutzern geoeffnet werden") - exactly the user type
# these Matrix-only accounts correctly have. Send them to Element instead.
- model: authentik_stages_redirect.redirectstage
state: present
identifiers:
name: matrix-invitation-redirect
id: matrix_invitation_redirect_stage
attrs:
mode: static
target_static: https://axion1337.chat
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 5
attrs:
stage: !KeyOf matrix_invitation_redirect_stage
matrix-recovery-flow.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: matrix-recovery-flow
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# matrix-recovery existed but had zero stage bindings (dead flow), and the
# real login flow (default-authentication-flow, used by the MAS OAuth2
# provider's authentication_flow) didn't link to it at all - no "Forgot
# password?" link was ever shown. Reuses the same default-recovery-*
# stages the built-in default-recovery-flow already uses successfully,
# plus our own redirect stage instead of falling back to the authentik
# dashboard (blocked for type=external Matrix users).
# ⚠️ Hier steckten ZWEI Fehler uebereinander (gitops#60).
#
# 1. !KeyOf koppelt jede Bindung daran, dass DIESER Eintrag im selben Lauf
# eine Model-Instanz erzeugt. Scheitert er, faellt der ganze Blueprint.
# Schlimmer: beim Protokollieren des Fehlers ruft Authentik str() auf das
# !KeyOf-Objekt, dessen __repr__ gegen ein LEERES Blueprint aufloest und
# dabei dieselbe Ausnahme wirft - die echte Fehlermeldung wird dadurch
# ueberdeckt. Man sieht nur noch "KeyOf: failed to find entry".
# Deshalb jetzt !Find gegen die Datenbank, wie in den Blueprints darunter
# ohnehin ueblich. Der Flow existiert, das ist stabiler und entkoppelt.
#
# 2. name und title MUESSEN gesetzt sein. Der FlowSerializer verlangt beide
# (keine Model-Defaults). Fehlen sie, scheitert die Validierung, der Eintrag
# bekommt keine Model-Instanz - und JEDES nachfolgende `!KeyOf
# matrix_recovery_flow` laeuft ins Leere. Genau das war der Zustand bis
# 2026-08-07: der Blueprint wurde bei jedem Lauf verworfen (gitops#60).
# Gegenprobe im selben ConfigMap: matrix-invitation-flow setzt beide und
# laeuft durch.
#
# Die Werte sind der Stand aus der Datenbank, damit sich an der angezeigten
# Seite nichts aendert. (Der Tippfehler "mail-adress" ist so gewachsen und
# bleibt bewusst stehen - Textaenderung waere eine eigene Entscheidung.)
- model: authentik_flows.flow
state: present
identifiers:
slug: matrix-recovery
attrs:
name: "Welcome to aXion1337! Please provide a username or mail-adress."
title: "Welcome to aXion1337! Please provide a username or mail-adress."
designation: recovery
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 10
attrs:
stage: !Find [authentik_stages_identification.identificationstage, [name, default-recovery-identification]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 20
attrs:
stage: !Find [authentik_stages_email.emailstage, [name, default-recovery-email]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 30
attrs:
stage: !Find [authentik_stages_prompt.promptstage, [name, "Change your password"]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 40
attrs:
stage: !Find [authentik_stages_user_write.userwritestage, [name, default-recovery-user-write]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 100
attrs:
stage: !Find [authentik_stages_user_login.userloginstage, [name, default-recovery-user-login]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 110
attrs:
stage: !Find [authentik_stages_redirect.redirectstage, [name, matrix-invitation-redirect]]
# Wire the "Forgot password?" link on the real login flow used by MAS.
#
# ⚠️ Hier reicht recovery_flow allein NICHT. Der Serializer validiert das
# ganze Objekt, nicht nur die angegebenen Felder - ohne user_fields faellt
# er mit "When no user fields are selected, at least one source must be
# selected". Das war die eigentliche Ursache von gitops#60; sichtbar wurde
# sie erst, nachdem die !KeyOf-Verweise weg waren (die haben die Meldung
# ueberdeckt).
#
# Die uebrigen Felder stehen bewusst mit drin, obwohl sie den Model-Defaults
# entsprechen: Was der Serializer nicht bekommt, setzt er auf den Default
# zurueck. Ein Blueprint, der nur ein Feld nennt, kann so still andere
# Einstellungen kippen. Werte sind der Stand aus der Datenbank.
- model: authentik_stages_identification.identificationstage
state: present
identifiers:
name: default-authentication-identification
attrs:
recovery_flow: !Find [authentik_flows.flow, [slug, matrix-recovery]]
user_fields:
- email
- username
case_insensitive_matching: true
show_matched_user: true
pretend_user_exists: true
show_source_labels: false
enable_remember_me: false
matrix-mfa-setup-redirect.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: matrix-mfa-setup-redirect
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# 2FA is optional (default-authentication-mfa-validation has
# not_configured_action=skip - login never blocks on missing MFA).
# Users who want to opt in use these built-in single-stage setup flows
# directly (unreachable via /if/user/, which is blocked for type=external
# Matrix accounts). Without a stage after the setup itself, completion
# fell back to the same blocked /if/user/ dashboard - append our redirect.
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, default-authenticator-totp-setup]]
order: 10
attrs:
stage: !Find [authentik_stages_redirect.redirectstage, [name, matrix-invitation-redirect]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, default-authenticator-webauthn-setup]]
order: 10
attrs:
stage: !Find [authentik_stages_redirect.redirectstage, [name, matrix-invitation-redirect]]
admin-mfa-enforcement.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: admin-mfa-enforcement
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# MFA-Pflicht fuer Admins, ohne sie fuer alle anderen zu erzwingen.
#
# Warum eine ZWEITE Validate-Stage statt not_configured_action am
# bestehenden default-authentication-mfa-validation umzustellen: dieses Feld
# haengt an der Stage, nicht an der Bindung - eine Umstellung wuerde alle
# Mitglieder treffen. Und die Standard-Stage anzufassen hiesse, ein Objekt
# aus Authentiks eigenem Blueprint zu veraendern.
#
# Diese Loesung fasst KEIN Authentik-Standardobjekt an. Sie haengt eine
# eigene Stage hinter die vorhandene und bindet sie an die Admin-Gruppe.
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
state: present
identifiers:
name: admin-mfa-validation
id: admin_mfa_stage
attrs:
# configure statt deny: Wer keinen Faktor hat, wird beim Login durch die
# Einrichtung GEFUEHRT. deny wuerde ihn aussperren - und bei einem
# Admin-Konto gibt es dann keinen Weg zurueck ausser ueber den Cluster.
not_configured_action: configure
configuration_stages:
- !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]]
- !Find [authentik_stages_authenticator_webauthn.authenticatorwebauthnstage, [name, default-authenticator-webauthn-setup]]
# Verhindert die doppelte Abfrage: Die Standard-Stage auf Ordnung 30
# validiert bereits, wer einen Faktor hat. Diese hier laeuft danach und
# ueberspringt sich, wenn das Geraet gerade eben benutzt wurde. Uebrig
# bleibt genau der Fall, um den es geht - Admin ohne zweiten Faktor.
last_auth_threshold: hours=1
# Ordnung 31: direkt hinter der Standard-MFA-Stage (30), vor dem Login (100).
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
order: 31
id: admin_mfa_binding
attrs:
stage: !KeyOf admin_mfa_stage
# Eine PolicyBinding mit gesetztem "group" prueft Gruppenmitgliedschaft
# (PolicyResult(group.is_member(user))). Ohne diese Bindung wuerde die
# Stage fuer ALLE gelten - dann waere 2FA fuer die ganze Instanz Pflicht.
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf admin_mfa_binding
order: 0
attrs:
group: !Find [authentik_core.group, [name, "authentik Admins"]]
matrix-brand-default-app.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: matrix-brand-default-app
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# Root cause behind several dead ends: an authenticated user hitting "/"
# with no other destination (e.g. after logging in mid-way through the
# TOTP/WebAuthn setup flows) falls back to Brand.default_application: if
# unset, that's /if/user/, which type=external Matrix accounts can't
# open. Only affects the bare "/" fallback - explicit URLs like
# /if/admin/ are unaffected, so internal/staff access is unchanged.
- model: authentik_brands.brand
state: present
identifiers:
domain: authentik-default
attrs:
default_application: !Find [authentik_core.application, [slug, matrix]]
# ThreadNet-Branding: Wer sich anmeldet, soll nicht auf einer Seite
# landen, die sich "authentik" nennt und ein fremdes Logo zeigt.
# Assets werden bewusst NICHT in Authentik hochgeladen, sondern von
# der Client-Auslieferung referenziert - sie liegen dort ohnehin und
# ein zweites Mal gepflegt zu werden ist genau die Quelle, aus der
# spaeter Abweichungen entstehen. Ein ConfigMap-Mount scheidet aus:
# die drei Dateien sind zusammen ~775 KB, base64-kodiert reissen sie
# das 1-MiB-Limit einer ConfigMap.
#
# Preis dieser Wahl: faellt Element Web aus, zeigt die Anmeldeseite
# Platzhalter statt Logo und Hintergrund. Kosmetisch, nicht
# funktional - anmelden kann man sich weiterhin.
#
# ⚠️ alpenglow.jpg existiert erst nach dem naechsten Client-Deploy.
# Wird diese Brand vorher ausgerollt, ist der Hintergrund 404 und
# Authentik zeigt Grau.
branding_title: ThreadNet
# Vorerst Authentiks eigenes Logo. Unser 512-px-PNG rendert in der
# Anmeldemaske in Naturgroesse und damit viel zu gross: Authentiks
# Default ist ein SVG, das sich seiner Box anpasst, ein PNG nicht.
#
# Explizit auf den Default gesetzt statt die Zeile zu loeschen - ein
# Blueprint mit state: present setzt weggelassene Felder NICHT zurueck,
# der alte Wert bliebe in der Datenbank stehen.
#
# ⚠️ SOLANGE DIESE ZEILE HIER STEHT, GEWINNT SIE. Wer das Logo in der
# Authentik-Oberflaeche auswaehlt oder hochlaedt, sieht es bis zur
# naechsten Blueprint-Reconciliation - danach steht wieder der Wert von
# hier. Ein neues Logo gehoert deshalb in diese Zeile, nicht in die UI.
#
# Was schon oeffentlich erreichbar ist und hier eingetragen werden kann:
# https://axion1337.chat/vector-icons/{24,120,144,152,180,512,1024}.png
# Alles quadratische Bildmarken. Fuer diesen Slot passt eher eine
# Wortmarke im Querformat - die gibt es noch nicht. Die vorhandene
# threadnet-logo-wortmarke.png liegt im wiki-Repo (Gruppe homelab) und
# ist von Hetzner aus NICHT erreichbar; sie muesste erst mit dem Client
# ausgeliefert werden, damit Authentik sie laden kann.
branding_logo: /static/dist/assets/icons/icon_left_brand.svg
branding_favicon: https://axion1337.chat/vector-icons/favicon.ico
branding_default_flow_background: https://axion1337.chat/themes/element/img/backgrounds/alpenglow.jpg
matrix-oidc-provider.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: matrix-oidc-provider
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# The OIDC Provider + Application linking Authentik to MAS was originally
# clicked together by hand in the UI and existed nowhere as code (issue
# #36): losing the Authentik DB would have meant re-creating this from
# scratch, including a new client_secret that MAS would then no longer
# match. client_secret is read from AUTHENTIK_MAS_OIDC_CLIENT_SECRET
# (see authentik.yaml HelmRelease values) rather than inlined here,
# since this ConfigMap itself is not SOPS-encrypted - the actual value
# lives in the authentik-credentials Secret instead.
- model: authentik_providers_oauth2.oauth2provider
state: present
identifiers:
name: Matrix Authentication Service
id: matrix_mas_provider
attrs:
client_type: confidential
client_id: dHbTAgAgXvjh3VALh220mB3dxcVXAifiXU2ZO3U6
client_secret: !Env AUTHENTIK_MAS_OIDC_CLIENT_SECRET
# Path includes MAS's own upstream-provider ID, not Authentik's -
# must match MAS's config exactly or the OIDC callback breaks.
redirect_uris:
- matching_mode: strict
url: https://account.axion1337.chat/upstream/callback/01KQDJTR1ZVTG8JQ220F5BNBFZ
# Stable across username renames - this is what keeps
# upstream_oauth_links rows valid after e.g. the elbojoloco rename.
sub_mode: hashed_user_id
include_claims_in_id_token: true
access_code_validity: minutes=1
access_token_validity: minutes=5
signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]]
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
property_mappings:
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-openid"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-email"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-profile"]]
- model: authentik_core.application
state: present
identifiers:
slug: matrix
attrs:
name: aXion1337.chat Accountverwaltung
provider: !KeyOf matrix_mas_provider
meta_description: Matrixclient tailored for aXionCommunity
meta_publisher: aXionGaming
policy_engine_mode: any
open_in_new_tab: false
wiki-oidc-provider.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: wiki-oidc-provider
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_providers_oauth2.oauth2provider
state: present
identifiers:
name: ThreadNet Wiki (Wiki.js)
id: wiki_oidc_provider
attrs:
client_type: confidential
# client_id + client_secret generiert Authentik selbst (kein Env-/SOPS-
# Aufwand; client_id ist ohnehin nicht geheim). Beide nach dem Apply in
# der Authentik-UI ablesen und in Wiki.js eintragen.
# redirect_uris: echte Wiki.js-Callback-URL (OIDC-Strategy-Key), 2026-08-12.
redirect_uris:
- matching_mode: strict
url: https://wiki.axion1337.chat/login/d3e7d0e4-adff-4421-b016-7758c44fd697/callback
sub_mode: hashed_user_id
include_claims_in_id_token: true
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
property_mappings:
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-openid"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-email"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-profile"]]
# + Gruppen-Claim: entweder das mitgelieferte Groups-Scope-Mapping
# (falls vorhanden) oder ein eigenes Scope-Mapping, das "groups"
# zurückgibt — nötig für Rollen/Abschottung (#0049).
- model: authentik_core.application
state: present
identifiers:
slug: wiki-js
attrs:
name: ThreadNet Wiki
provider: !KeyOf wiki_oidc_provider
meta_description: Internes Wiki (Betrieb/Anwender), Zugriff nach Gruppe
policy_engine_mode: any
open_in_new_tab: false
# Rollen (#0049): Admin = bestehende Gruppe `authentik Admins` (liest+schreibt
# alles). `wiki-anwender` = kuratierte Leser von /anwender. Beide kommen über
# den profile->groups-Claim in Wiki.js an; Mitglieder pflegt sorb in Authentik.
- model: authentik_core.group
state: present
identifiers:
name: wiki-anwender