From eb6f64a0c50616fa31584358b6d03383e7b5aca2 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:13:20 +0200 Subject: [PATCH] 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 --- .env.example | 5 + README.md | 26 ++--- prisma/schema.prisma | 21 +++-- src/auth/authentik.strategy.ts | 42 +++++---- src/auth/provision-user.spec.ts | 125 +++++++++++++++++++------ src/auth/provision-user.ts | 63 ++++++++++++- src/auth/token-verification.service.ts | 46 ++++----- src/onboarding/onboarding.service.ts | 9 +- 8 files changed, 241 insertions(+), 96 deletions(-) diff --git a/.env.example b/.env.example index bced9a5..668d845 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public" # Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/ AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" +# Name of the Authentik group whose members are Leitungsteam. Mirrored to +# User.isLeitungsteam on every login (the access token must carry a `groups` +# claim; add the "groups" scope to the Authentik provider). +AUTHENTIK_LEITUNGSTEAM_GROUP="Leitungsteam" + # Secret used to sign guest/Konfi session tokens (local accounts only) GUEST_JWT_SECRET="change-me" diff --git a/README.md b/README.md index 6077422..64a605f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 460f05f..5b89be4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/src/auth/authentik.strategy.ts b/src/auth/authentik.strategy.ts index ffcd0d3..9fca443 100644 --- a/src/auth/authentik.strategy.ts +++ b/src/auth/authentik.strategy.ts @@ -7,22 +7,26 @@ import { Request } from 'express'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; -import { resolveOrProvisionAuthentikUser } from './provision-user'; +import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user'; interface AuthentikJwtPayload { sub: string; email?: string; given_name?: string; family_name?: string; + groups?: string[]; } /// Validates access tokens issued by Authentik (resource-server pattern): /// signature is checked against Authentik's JWKS, the local `User` is -/// provisioned on first login (JIT), then the local Membership table decides -/// what the user may do. Authentik itself is only the identity source, never -/// asked for authorization here. +/// provisioned on first login (JIT) and its LEITUNGSTEAM flag reconciled with +/// the token's `groups` claim, then the local Membership table decides what +/// the user may do. Authentik itself is only the identity source, never asked +/// for authorization here. @Injectable() export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { + private readonly leitungsteamGroup: string; + constructor( config: ConfigService, private readonly prisma: PrismaClient, @@ -42,27 +46,25 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { issuer: issuerUrl, algorithms: ['RS256'], }); + this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); } async validate(payload: AuthentikJwtPayload): Promise { if (!payload.email) { throw new UnauthorizedException('Authentik token missing email claim'); } - const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, { - sub: payload.sub, - email: payload.email, - firstName: payload.given_name ?? '', - lastName: payload.family_name ?? '', - }); - return { - userId: user.id, - authentikSub: user.authentikSub, - email: user.email, - memberships: user.memberships.map((m) => ({ - kcId: m.kcId, - gemeindeId: m.gemeindeId, - role: m.role, - })), - }; + const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup); + const user = await resolveOrProvisionAuthentikUser( + this.prisma, + this.sync, + { + sub: payload.sub, + email: payload.email, + firstName: payload.given_name ?? '', + lastName: payload.family_name ?? '', + }, + isLeitungsteam, + ); + return toAuthenticatedUser(user); } } diff --git a/src/auth/provision-user.spec.ts b/src/auth/provision-user.spec.ts index 8377bec..6555462 100644 --- a/src/auth/provision-user.spec.ts +++ b/src/auth/provision-user.spec.ts @@ -1,5 +1,9 @@ -import { Prisma } from '@prisma/client'; -import { resolveOrProvisionAuthentikUser } from './provision-user'; +import { Prisma, Role } from '@prisma/client'; +import { + GLOBAL_LT_KC_ID, + resolveOrProvisionAuthentikUser, + toAuthenticatedUser, +} from './provision-user'; const CLAIMS = { sub: 'sub-1', @@ -15,38 +19,36 @@ function p2002() { }); } -function makeMocks() { - const sync = { capture: jest.fn().mockResolvedValue(undefined) }; - return { sync }; -} - describe('resolveOrProvisionAuthentikUser', () => { - it('returns the existing user without creating or capturing', async () => { - const { sync } = makeMocks(); - const existing = { id: 'u-1', authentikSub: 'sub-1', memberships: [] }; + it('returns the existing user without creating or capturing when nothing changed', async () => { + const sync = { capture: jest.fn() }; + const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] }; const prisma = { user: { findUnique: jest.fn().mockResolvedValue(existing), create: jest.fn(), + update: jest.fn(), }, }; - const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false); expect(res).toBe(existing); expect(prisma.user.create).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); expect(sync.capture).not.toHaveBeenCalled(); }); it('provisions a new user from claims (lowercased email) and captures it', async () => { - const { sync } = makeMocks(); + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; const prisma = { user: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn(({ data }: { data: Record }) => - Promise.resolve({ id: 'u-2', ...data }), + Promise.resolve({ id: 'u-2', isLeitungsteam: false, ...data }), ), + update: jest.fn(), }, }; - const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false); expect(prisma.user.create).toHaveBeenCalledWith({ data: { authentikSub: 'sub-1', @@ -59,46 +61,115 @@ describe('resolveOrProvisionAuthentikUser', () => { expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything()); }); - it('recovers from a concurrent-create race (P2002) by re-reading', async () => { - const { sync } = makeMocks(); - const raced = { id: 'u-3', authentikSub: 'sub-1', memberships: [] }; + it('reconciles the LEITUNGSTEAM flag up when the token now has the group', async () => { + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; + const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] }; const prisma = { user: { - findUnique: jest - .fn() - .mockResolvedValueOnce(null) // first check: not there yet - .mockResolvedValueOnce(raced), // after the failed insert: it exists - create: jest.fn().mockRejectedValue(p2002()), + findUnique: jest.fn().mockResolvedValue(existing), + create: jest.fn(), + update: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...existing, ...data, memberships: [] }), + ), }, }; - const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, true); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'u-1' }, + data: { isLeitungsteam: true }, + include: { memberships: { where: { status: 'ACTIVE' } } }, + }); + expect(res.isLeitungsteam).toBe(true); + expect(sync.capture).toHaveBeenCalledWith('User', 'UPDATE', 'u-1', expect.anything()); + }); + + it('reconciles the LEITUNGSTEAM flag down when the group is gone', async () => { + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; + const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: true, memberships: [] }; + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(existing), + create: jest.fn(), + update: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...existing, ...data, memberships: [] }), + ), + }, + }; + await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { isLeitungsteam: false } }), + ); + }); + + it('recovers from a concurrent-create race (P2002) by re-reading', async () => { + const sync = { capture: jest.fn() }; + const raced = { id: 'u-3', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] }; + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(raced), + create: jest.fn().mockRejectedValue(p2002()), + update: jest.fn(), + }, + }; + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false); expect(res).toBe(raced); expect(sync.capture).not.toHaveBeenCalled(); }); it('rethrows a P2002 when the row still cannot be found', async () => { - const { sync } = makeMocks(); + const sync = { capture: jest.fn() }; const prisma = { user: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockRejectedValue(p2002()), + update: jest.fn(), }, }; await expect( - resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS), + resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false), ).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError); }); it('rethrows a non-P2002 error', async () => { - const { sync } = makeMocks(); + const sync = { capture: jest.fn() }; const prisma = { user: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockRejectedValue(new Error('db down')), + update: jest.fn(), }, }; await expect( - resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS), + resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false), ).rejects.toThrow('db down'); }); }); + +describe('toAuthenticatedUser', () => { + const row = { + id: 'u-1', + authentikSub: 'sub-1', + email: 'a@b.org', + isLeitungsteam: false, + memberships: [ + { kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER }, + ], + }; + + it('maps membership rows straight through when not Leitungsteam', () => { + const res = toAuthenticatedUser(row as never); + expect(res.memberships).toEqual([ + { kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER }, + ]); + }); + + it('prepends a synthetic global LEITUNGSTEAM membership when the flag is set', () => { + const res = toAuthenticatedUser({ ...row, isLeitungsteam: true } as never); + expect(res.memberships[0]).toEqual({ + kcId: GLOBAL_LT_KC_ID, + gemeindeId: null, + role: Role.LEITUNGSTEAM, + }); + expect(res.memberships).toHaveLength(2); + }); +}); diff --git a/src/auth/provision-user.ts b/src/auth/provision-user.ts index 4e15fed..be68c4e 100644 --- a/src/auth/provision-user.ts +++ b/src/auth/provision-user.ts @@ -1,6 +1,7 @@ -import { Prisma, SyncOperation } from '@prisma/client'; +import { Prisma, Role, SyncOperation } from '@prisma/client'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; +import { AuthenticatedUser } from './authenticated-request'; export interface AuthentikClaims { sub: string; @@ -9,19 +10,48 @@ export interface AuthentikClaims { lastName: string; } +/// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard +/// never compares it (LT short-circuits the KC check), it only needs to exist. +export const GLOBAL_LT_KC_ID = '*'; + type UserWithActiveMemberships = Prisma.UserGetPayload<{ include: { memberships: true }; }>; /// Resolves an Authentik identity to its local `User`, creating one from the -/// token claims on first login (JIT provisioning). The new user has no -/// memberships and therefore no rights until one is granted (LT via Authentik -/// group sync — still manual — or the onboarding approval flow). Shared by -/// AuthentikStrategy and the WS token path so both provision identically. +/// token claims on first login (JIT provisioning), and reconciling the +/// `isLeitungsteam` flag with the caller's current Authentik group membership +/// on every login. A brand-new user has no `Membership` and therefore no +/// rights until one is granted (the onboarding approval flow) or the LT flag +/// is set. Shared by AuthentikStrategy and the WS token path so both behave +/// identically. export async function resolveOrProvisionAuthentikUser( prisma: PrismaClient, sync: SyncService, claims: AuthentikClaims, + isLeitungsteam: boolean, +): Promise { + const user = await loadOrCreate(prisma, sync, claims); + + if (user.isLeitungsteam !== isLeitungsteam) { + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { isLeitungsteam }, + include: { memberships: { where: { status: 'ACTIVE' } } }, + }); + await sync.capture('User', SyncOperation.UPDATE, updated.id, { + ...updated, + memberships: undefined, + }); + return updated; + } + return user; +} + +async function loadOrCreate( + prisma: PrismaClient, + sync: SyncService, + claims: AuthentikClaims, ): Promise { const existing = await prisma.user.findUnique({ where: { authentikSub: claims.sub }, @@ -56,3 +86,26 @@ export async function resolveOrProvisionAuthentikUser( throw err; } } + +/// Maps a provisioned user row to the request-scoped shape, prepending a +/// synthetic global LEITUNGSTEAM membership when the flag is set. +export function toAuthenticatedUser(user: UserWithActiveMemberships): AuthenticatedUser { + const memberships = user.memberships.map((m) => ({ + kcId: m.kcId, + gemeindeId: m.gemeindeId, + role: m.role, + })); + if (user.isLeitungsteam) { + memberships.unshift({ + kcId: GLOBAL_LT_KC_ID, + gemeindeId: null, + role: Role.LEITUNGSTEAM, + }); + } + return { + userId: user.id, + authentikSub: user.authentikSub, + email: user.email, + memberships, + }; +} diff --git a/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index a6fe7f7..f3caa60 100644 --- a/src/auth/token-verification.service.ts +++ b/src/auth/token-verification.service.ts @@ -8,7 +8,11 @@ import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; import { GuestJwtPayload } from './guest-auth.service'; import { TeamAuthService } from './team-auth.service'; -import { resolveOrProvisionAuthentikUser } from './provision-user'; +import { + AuthentikClaims, + resolveOrProvisionAuthentikUser, + toAuthenticatedUser, +} from './provision-user'; /// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for /// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply. @@ -16,6 +20,7 @@ import { resolveOrProvisionAuthentikUser } from './provision-user'; export class TokenVerificationService { private readonly issuerUrl: string; private readonly jwks: jwksRsa.JwksClient; + private readonly leitungsteamGroup: string; constructor( private readonly config: ConfigService, @@ -26,17 +31,16 @@ export class TokenVerificationService { ) { this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); + this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); } - /// Verifies an Authentik token's signature and returns its identity claims, - /// without requiring a local User to exist yet (used by the onboarding - /// self-registration path, which provisions that User). - async verifyAuthentikClaims(token: string): Promise<{ - sub: string; - email: string; - firstName: string; - lastName: string; - }> { + /// Verifies an Authentik token's signature and returns its identity claims + /// plus whether the caller is in the Leitungsteam group, without requiring + /// a local User to exist yet (used by the onboarding self-registration + /// path, which provisions that User). + async verifyAuthentikClaims( + token: string, + ): Promise { const decoded = jwt.decode(token, { complete: true }); const kid = decoded?.header.kid; if (!kid) { @@ -50,6 +54,7 @@ export class TokenVerificationService { email?: string; given_name?: string; family_name?: string; + groups?: string[]; }; if (!payload.sub || !payload.email) { throw new UnauthorizedException('Authentik token missing subject or email'); @@ -59,22 +64,19 @@ export class TokenVerificationService { email: payload.email, firstName: payload.given_name ?? '', lastName: payload.family_name ?? '', + isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup), }; } async verifyAuthentik(token: string): Promise { - const claims = await this.verifyAuthentikClaims(token); - const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims); - return { - userId: user.id, - authentikSub: user.authentikSub, - email: user.email, - memberships: user.memberships.map((m) => ({ - kcId: m.kcId, - gemeindeId: m.gemeindeId, - role: m.role, - })), - }; + const { isLeitungsteam, ...claims } = await this.verifyAuthentikClaims(token); + const user = await resolveOrProvisionAuthentikUser( + this.prisma, + this.sync, + claims, + isLeitungsteam, + ); + return toAuthenticatedUser(user); } async verifyGuest(token: string): Promise { diff --git a/src/onboarding/onboarding.service.ts b/src/onboarding/onboarding.service.ts index 081c6e5..0a0dad1 100644 --- a/src/onboarding/onboarding.service.ts +++ b/src/onboarding/onboarding.service.ts @@ -42,7 +42,7 @@ export class OnboardingService { if (!token) { throw new UnauthorizedException('Missing Authentik bearer token'); } - const claims = await this.tokens.verifyAuthentikClaims(token); + const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token); const kc = await this.prisma.kc.findUnique({ where: { inviteCode } }); if (!kc || !kc.isActive) { @@ -53,7 +53,12 @@ export class OnboardingService { throw new BadRequestException('Gemeinde does not belong to this KC'); } - const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims); + const user = await resolveOrProvisionAuthentikUser( + this.prisma, + this.sync, + claims, + isLeitungsteam, + ); const existing = await this.prisma.membership.findUnique({ where: {