diff --git a/backend/README.md b/backend/README.md index d005f65..6077422 100644 --- a/backend/README.md +++ b/backend/README.md @@ -20,12 +20,16 @@ client's host - no separate web server is needed. ## Auth model -- Leitungsteam and Gemeinde Verantwortliche are provisioned in Authentik - (the "Konfi-Castle-ID"); this API acts as an OIDC **resource server**, - verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and - then resolving local `Membership` rows to determine role + KC/Gemeinde - scope. Clients perform the actual Authorization Code + PKCE flow against - Authentik directly. +- Leitungsteam and Gemeinde Verantwortliche sign in with Authentik (the + "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. - 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` @@ -105,8 +109,9 @@ client's host - no separate web server is needed. Leitungsteam roles are global across all KCs). All planned backend phases are implemented. `npm test` runs Jest unit tests -(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`; -Prisma mocked). Remaining work: the Flutter clients (see repo root README), -Authentik JIT provisioning for LT (Verantwortliche already self-provision via -`onboarding/`), invite email delivery, and the first real Prisma migration -(only `schema.prisma` exists so far). +(`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). diff --git a/backend/src/auth/authentik.strategy.ts b/backend/src/auth/authentik.strategy.ts index f5b88b8..ffcd0d3 100644 --- a/backend/src/auth/authentik.strategy.ts +++ b/backend/src/auth/authentik.strategy.ts @@ -5,24 +5,28 @@ import { Strategy } from 'passport-jwt'; import * as jwksRsa from 'jwks-rsa'; 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'; interface AuthentikJwtPayload { sub: string; - email: string; + email?: string; given_name?: string; family_name?: string; } /// Validates access tokens issued by Authentik (resource-server pattern): -/// signature is checked against Authentik's JWKS, then the local Membership -/// table decides what the user may do. Authentik itself is only the identity -/// source, never asked for authorization here. +/// 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. @Injectable() export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { constructor( config: ConfigService, private readonly prisma: PrismaClient, + private readonly sync: SyncService, ) { const issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); super({ @@ -41,13 +45,15 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { } async validate(payload: AuthentikJwtPayload): Promise { - const user = await this.prisma.user.findUnique({ - where: { authentikSub: payload.sub }, - include: { memberships: { where: { status: 'ACTIVE' } } }, - }); - if (!user) { - throw new UnauthorizedException('User not provisioned locally yet'); + 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, diff --git a/backend/src/auth/provision-user.spec.ts b/backend/src/auth/provision-user.spec.ts new file mode 100644 index 0000000..8377bec --- /dev/null +++ b/backend/src/auth/provision-user.spec.ts @@ -0,0 +1,104 @@ +import { Prisma } from '@prisma/client'; +import { resolveOrProvisionAuthentikUser } from './provision-user'; + +const CLAIMS = { + sub: 'sub-1', + email: 'New.Person@Example.org', + firstName: 'New', + lastName: 'Person', +}; + +function p2002() { + return new Prisma.PrismaClientKnownRequestError('unique', { + code: 'P2002', + clientVersion: 'test', + }); +} + +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: [] }; + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(existing), + create: jest.fn(), + }, + }; + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + expect(res).toBe(existing); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(sync.capture).not.toHaveBeenCalled(); + }); + + it('provisions a new user from claims (lowercased email) and captures it', async () => { + const { sync } = makeMocks(); + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ id: 'u-2', ...data }), + ), + }, + }; + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + expect(prisma.user.create).toHaveBeenCalledWith({ + data: { + authentikSub: 'sub-1', + email: 'new.person@example.org', + firstName: 'New', + lastName: 'Person', + }, + }); + expect(res.memberships).toEqual([]); + 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: [] }; + 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()), + }, + }; + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + 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 prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockRejectedValue(p2002()), + }, + }; + await expect( + resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS), + ).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError); + }); + + it('rethrows a non-P2002 error', async () => { + const { sync } = makeMocks(); + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockRejectedValue(new Error('db down')), + }, + }; + await expect( + resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS), + ).rejects.toThrow('db down'); + }); +}); diff --git a/backend/src/auth/provision-user.ts b/backend/src/auth/provision-user.ts new file mode 100644 index 0000000..4e15fed --- /dev/null +++ b/backend/src/auth/provision-user.ts @@ -0,0 +1,58 @@ +import { Prisma, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; + +export interface AuthentikClaims { + sub: string; + email: string; + firstName: string; + lastName: string; +} + +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. +export async function resolveOrProvisionAuthentikUser( + prisma: PrismaClient, + sync: SyncService, + claims: AuthentikClaims, +): Promise { + const existing = await prisma.user.findUnique({ + where: { authentikSub: claims.sub }, + include: { memberships: { where: { status: 'ACTIVE' } } }, + }); + if (existing) { + return existing; + } + + try { + const user = await prisma.user.create({ + data: { + authentikSub: claims.sub, + email: claims.email.toLowerCase(), + firstName: claims.firstName, + lastName: claims.lastName, + }, + }); + await sync.capture('User', SyncOperation.CREATE, user.id, user); + return { ...user, memberships: [] }; + } catch (err) { + // Lost a race with a concurrent first login — the row exists now. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + const user = await prisma.user.findUnique({ + where: { authentikSub: claims.sub }, + include: { memberships: { where: { status: 'ACTIVE' } } }, + }); + if (user) { + return user; + } + } + throw err; + } +} diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index 73a09b5..a6fe7f7 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -4,9 +4,11 @@ import { JwtService } from '@nestjs/jwt'; import * as jwt from 'jsonwebtoken'; import * as jwksRsa from 'jwks-rsa'; import { PrismaClient } from '../prisma/prisma.module'; +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'; /// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for /// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply. @@ -20,6 +22,7 @@ export class TokenVerificationService { private readonly prisma: PrismaClient, private readonly guestJwt: JwtService, private readonly teamAuth: TeamAuthService, + private readonly sync: SyncService, ) { this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); @@ -60,15 +63,8 @@ export class TokenVerificationService { } async verifyAuthentik(token: string): Promise { - const { sub } = await this.verifyAuthentikClaims(token); - - const user = await this.prisma.user.findUnique({ - where: { authentikSub: sub }, - include: { memberships: { where: { status: 'ACTIVE' } } }, - }); - if (!user) { - throw new UnauthorizedException('User not provisioned locally yet'); - } + const claims = await this.verifyAuthentikClaims(token); + const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims); return { userId: user.id, authentikSub: user.authentikSub, diff --git a/backend/src/onboarding/onboarding.service.ts b/backend/src/onboarding/onboarding.service.ts index 71b606f..081c6e5 100644 --- a/backend/src/onboarding/onboarding.service.ts +++ b/backend/src/onboarding/onboarding.service.ts @@ -8,6 +8,7 @@ import { MembershipStatus, Role, SyncOperation } from '@prisma/client'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { TokenVerificationService } from '../auth/token-verification.service'; +import { resolveOrProvisionAuthentikUser } from '../auth/provision-user'; /// Self-service onboarding for Gemeinde Verantwortliche. The person signs in /// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus @@ -52,7 +53,7 @@ export class OnboardingService { throw new BadRequestException('Gemeinde does not belong to this KC'); } - const user = await this.upsertUser(claims); + const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims); const existing = await this.prisma.membership.findUnique({ where: { @@ -119,31 +120,6 @@ export class OnboardingService { return membership; } - private async upsertUser(claims: { - sub: string; - email: string; - firstName: string; - lastName: string; - }) { - const email = claims.email.toLowerCase(); - const existing = await this.prisma.user.findUnique({ - where: { authentikSub: claims.sub }, - }); - if (existing) { - return existing; - } - const user = await this.prisma.user.create({ - data: { - authentikSub: claims.sub, - email, - firstName: claims.firstName, - lastName: claims.lastName, - }, - }); - await this.sync.capture('User', SyncOperation.CREATE, user.id, user); - return user; - } - private summary( membershipId: string, status: MembershipStatus, diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 6f2bbb2..ff3baf3 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -52,7 +52,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris - **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. -- **Authentik-Provisionierung**: Gemeinde Verantwortliche legen ihren lokalen `User` jetzt selbst über `onboarding/` an (JIT aus den Token-Claims, dann `PENDING` bis LT-Freigabe). **LT** dagegen wird von `AuthentikStrategy` noch nicht JIT angelegt — ein LT-`User` muss vor dem ersten Login manuell existieren. (Gemeinde Teamer sind seit dieser Session lokale Accounts, siehe `teamer/` + `auth/team-login`.) +- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` jetzt automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher). Was noch fehlt: die **Rollen-/Gruppen-Zuordnung aus Authentik** — ein frisch angelegter `User` hat keine `Membership`, also keine Rechte. LT-Rechte müssen aktuell per manueller `Membership(LEITUNGSTEAM)` gesetzt werden; Verantwortliche laufen über den `onboarding/`-Freigabepfad. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) - **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. - **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden. @@ -63,7 +63,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | Modul | Kernfunktion | Wichtige Endpunkte | |---|---|---| | `prisma/` | Geteilter `PrismaClient`-Provider | – | -| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert jetzt Authentik/Team/Guest) | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | +| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login (`resolveOrProvisionAuthentikUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | | `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) | @@ -110,11 +110,12 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). -4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 46 Tests): +4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 51 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. - `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung. - `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject. + - `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims inkl. Race-Recovery (P2002 → Re-Read) und Fehler-Weiterreichung. 5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). --- @@ -122,7 +123,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. -2. Authentik-JIT für **LT** vervollständigen: `AuthentikStrategy` legt den lokalen `User` beim ersten Login noch nicht selbst an (nur der `onboarding/`-Pfad für Verantwortliche tut das). LT bleibt bis dahin auf manuelle `User`-Anlage angewiesen. +2. LT-Rolle aus Authentik ableiten: `User` wird beim ersten Login jetzt JIT angelegt, aber die `Membership(LEITUNGSTEAM)` noch nicht — aus den Authentik-Gruppen-Claims des Tokens (oder per Admin-API-Sync) eine globale LT-Membership erzeugen/entfernen. 3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. 5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen.