feat(backend): derive LEITUNGSTEAM from the Authentik groups claim

On every Authentik login the token's `groups` claim is compared against
AUTHENTIK_LEITUNGSTEAM_GROUP (default "Leitungsteam") and mirrored to the
new User.isLeitungsteam column. LT is global, not KC-scoped, so it lives on
the User rather than as a per-KC Membership row: toAuthenticatedUser()
synthesises a virtual global LEITUNGSTEAM membership from the flag, so
RolesGuard / visibility / TeamerService keep working unchanged.

- provision helper gains an isLeitungsteam arg and reconciles the flag both
  ways (grant on join, drop when the group is gone), capturing a User
  UPDATE to the sync log.
- verifyAuthentikClaims() now also returns isLeitungsteam; strategy, WS
  path and onboarding all funnel through the shared helper + mapper.
- new env var AUTHENTIK_LEITUNGSTEAM_GROUP.

Tests: provision-user.spec.ts extended (flag up/down, virtual membership);
npm test green at 55. Docs updated; ops note added that the Authentik
provider must emit the groups claim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:13:20 +02:00
co-authored by Claude Sonnet 5
parent 5079d48905
commit 24f8070b8a
9 changed files with 248 additions and 103 deletions
+13 -8
View File
@@ -60,14 +60,19 @@ enum MembershipStatus {
/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash`
/// set, `authentikSub` null, `kcId` set) and, like guests, scoped to one KC.
model User {
id String @id @default(cuid())
authentikSub String? @unique
email String @unique
firstName String
lastName String
passwordHash String?
kcId String?
createdAt DateTime @default(now())
id String @id @default(cuid())
authentikSub String? @unique
email String @unique
firstName String
lastName String
passwordHash String?
kcId String?
/// Mirrored from the caller's Authentik group membership on every login.
/// LEITUNGSTEAM is global (not KC-scoped), so it lives here rather than as
/// a per-KC Membership row; the auth layer synthesises a virtual global
/// LEITUNGSTEAM membership from this flag.
isLeitungsteam Boolean @default(false)
createdAt DateTime @default(now())
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]