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
+14 -12
View File
@@ -7,7 +7,7 @@ architecture context).
```bash
npm install
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET / TEAM_JWT_SECRET
cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP / GUEST_JWT_SECRET / TEAM_JWT_SECRET
npx prisma generate
npx prisma migrate dev --name init # requires a running PostgreSQL instance
npm run start:dev
@@ -24,12 +24,14 @@ client's host - no separate web server is needed.
"Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying
access tokens against Authentik's JWKS (`AuthentikStrategy`). The local
`User` is provisioned just-in-time on first login from the token claims
(`resolveOrProvisionAuthentikUser`); role + KC/Gemeinde scope then come
from local `Membership` rows (only `status = ACTIVE` ones count). A freshly
provisioned user has no membership and thus no rights until one is granted
(LT: manually for now; Verantwortliche: the `onboarding/` approval flow).
Clients perform the Authorization Code + PKCE flow against Authentik
directly.
(`resolveOrProvisionAuthentikUser`), and `User.isLeitungsteam` is
reconciled on every login from the token's `groups` claim vs.
`AUTHENTIK_LEITUNGSTEAM_GROUP``toAuthenticatedUser` then synthesises a
virtual global `LEITUNGSTEAM` membership from that flag. Other roles come
from local `Membership` rows (only `status = ACTIVE` ones count).
Verantwortliche self-provision through the `onboarding/` approval flow;
a user with neither the LT flag nor a membership has no rights. Clients
perform the Authorization Code + PKCE flow against Authentik directly.
- Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a
`passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde
Verantwortliche/r creates them directly or via a `TeamerInvite`
@@ -110,8 +112,8 @@ client's host - no separate web server is needed.
All planned backend phases are implemented. `npm test` runs Jest unit tests
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`,
`resolveOrProvisionAuthentikUser`; Prisma mocked). Remaining work: the
Flutter clients (see repo root README), deriving the LT `Membership` from
Authentik group claims (the `User` is provisioned, the role is not), invite
email delivery, and the first real Prisma migration (only `schema.prisma`
exists so far).
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
Remaining work: the Flutter clients (see repo root README), invite email
delivery, push notifications, and the first real Prisma migration (only
`schema.prisma` exists so far). Ops note: the Authentik provider must emit a
`groups` claim in the access token for the LT check to work.