diff --git a/README.md b/README.md index fd8764e..d005f65 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,15 @@ client's host - no separate web server is needed. or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`, since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept `'team'` tokens too, so Teamer see non-Konfi files and chat. +- `onboarding/` — self-registration for Gemeinde Verantwortliche. + `GET /onboarding/kc/:inviteCode` (public) returns the KC name + its + Gemeinden to pick from. `POST /onboarding/verantwortliche` takes the + caller's raw Authentik bearer token (no local `Membership` needed yet), + JIT-provisions the local `User` from the token claims, and creates a + `Membership` with `status = PENDING`. Leitungsteam reviews via + `GET /onboarding/requests?kcId=` and `POST /onboarding/requests/:id/approve` + or `.../reject`. Auth strategies only load `ACTIVE` memberships, so a + pending request grants nothing until approved. - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → @@ -96,7 +105,8 @@ 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`; Prisma mocked). -Remaining work: the Flutter clients (see repo root README), Authentik -provisioning for LT/Verantwortliche, and the first real Prisma migration +(`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). diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5812b27..460f05f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,14 @@ enum Role { GEMEINDE_TEAMER } +/// PENDING memberships come from self-registration and grant no rights until +/// a Leitungsteam member approves them. Everything created by LT/Verantwortliche +/// directly is ACTIVE from the start. +enum MembershipStatus { + ACTIVE + PENDING +} + /// A team member account. Leitungsteam and Gemeinde Verantwortliche are /// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde /// Teamer are local accounts created by a Verantwortliche/r (`passwordHash` @@ -70,12 +78,13 @@ model User { /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). /// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId. model Membership { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String kcId String gemeindeId String? role Role - createdAt DateTime @default(now()) + status MembershipStatus @default(ACTIVE) + createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) diff --git a/src/app.module.ts b/src/app.module.ts index 806bcf0..ffffde1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.module'; import { GemeindeModule } from './gemeinde/gemeinde.module'; import { TeamerModule } from './teamer/teamer.module'; +import { OnboardingModule } from './onboarding/onboarding.module'; import { WahlModule } from './wahl/wahl.module'; import { FilesModule } from './files/files.module'; import { ChatModule } from './chat/chat.module'; @@ -27,6 +28,7 @@ import { SyncModule } from './sync/sync.module'; KcModule, GemeindeModule, TeamerModule, + OnboardingModule, WahlModule, FilesModule, ChatModule, diff --git a/src/auth/authentik.strategy.ts b/src/auth/authentik.strategy.ts index 3cf9465..f5b88b8 100644 --- a/src/auth/authentik.strategy.ts +++ b/src/auth/authentik.strategy.ts @@ -43,7 +43,7 @@ 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: true }, + include: { memberships: { where: { status: 'ACTIVE' } } }, }); if (!user) { throw new UnauthorizedException('User not provisioned locally yet'); diff --git a/src/auth/team-auth.service.ts b/src/auth/team-auth.service.ts index 5ab4833..048333e 100644 --- a/src/auth/team-auth.service.ts +++ b/src/auth/team-auth.service.ts @@ -141,7 +141,7 @@ export class TeamAuthService { async resolve(userId: string): Promise { const user = await this.prisma.user.findFirst({ where: { id: userId, passwordHash: { not: null } }, - include: { memberships: true }, + include: { memberships: { where: { status: 'ACTIVE' } } }, }); if (!user) { throw new UnauthorizedException('Team account no longer exists'); diff --git a/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index 6823c52..73a09b5 100644 --- a/src/auth/token-verification.service.ts +++ b/src/auth/token-verification.service.ts @@ -25,7 +25,15 @@ export class TokenVerificationService { this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); } - async verifyAuthentik(token: string): Promise { + /// 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; + }> { const decoded = jwt.decode(token, { complete: true }); const kid = decoded?.header.kid; if (!kid) { @@ -35,14 +43,28 @@ export class TokenVerificationService { const payload = jwt.verify(token, key.getPublicKey(), { issuer: this.issuerUrl, algorithms: ['RS256'], - }) as jwt.JwtPayload; - if (!payload.sub) { - throw new UnauthorizedException('Authentik token missing subject'); + }) as jwt.JwtPayload & { + email?: string; + given_name?: string; + family_name?: string; + }; + if (!payload.sub || !payload.email) { + throw new UnauthorizedException('Authentik token missing subject or email'); } + return { + sub: payload.sub, + email: payload.email, + firstName: payload.given_name ?? '', + lastName: payload.family_name ?? '', + }; + } + + async verifyAuthentik(token: string): Promise { + const { sub } = await this.verifyAuthentikClaims(token); const user = await this.prisma.user.findUnique({ - where: { authentikSub: payload.sub }, - include: { memberships: true }, + where: { authentikSub: sub }, + include: { memberships: { where: { status: 'ACTIVE' } } }, }); if (!user) { throw new UnauthorizedException('User not provisioned locally yet'); diff --git a/src/onboarding/dto/register-verantwortliche.dto.ts b/src/onboarding/dto/register-verantwortliche.dto.ts new file mode 100644 index 0000000..13048aa --- /dev/null +++ b/src/onboarding/dto/register-verantwortliche.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class RegisterVerantwortlicheDto { + @IsString() + @IsNotEmpty() + inviteCode!: string; + + @IsString() + @IsNotEmpty() + gemeindeId!: string; +} diff --git a/src/onboarding/onboarding.controller.ts b/src/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..4fb1861 --- /dev/null +++ b/src/onboarding/onboarding.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + Get, + Headers, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { OnboardingService } from './onboarding.service'; +import { RegisterVerantwortlicheDto } from './dto/register-verantwortliche.dto'; +import { Roles } from '../common/roles.decorator'; +import { RolesGuard } from '../common/roles.guard'; +import { Role } from '../common/role.enum'; + +function bearer(header?: string): string | undefined { + return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined; +} + +@Controller('onboarding') +export class OnboardingController { + constructor(private readonly onboarding: OnboardingService) {} + + /// Public lookup: invite code -> KC name + selectable Gemeinden. + @Get('kc/:inviteCode') + resolveInvite(@Param('inviteCode') inviteCode: string) { + return this.onboarding.resolveInvite(inviteCode); + } + + /// Self-registration as Gemeinde Verantwortliche/r. Authenticated by the + /// caller's raw Authentik bearer token (no local Membership required yet). + @Post('verantwortliche') + registerVerantwortliche( + @Body() dto: RegisterVerantwortlicheDto, + @Headers('authorization') authorization?: string, + ) { + return this.onboarding.registerVerantwortliche( + bearer(authorization), + dto.inviteCode, + dto.gemeindeId, + ); + } + + /// Leitungsteam: review and act on pending self-registrations. + @Get('requests') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + listRequests(@Query('kcId') kcId: string) { + return this.onboarding.listRequests(kcId); + } + + @Post('requests/:membershipId/approve') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + approve(@Param('membershipId') membershipId: string) { + return this.onboarding.approve(membershipId); + } + + @Post('requests/:membershipId/reject') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + reject(@Param('membershipId') membershipId: string) { + return this.onboarding.reject(membershipId); + } +} diff --git a/src/onboarding/onboarding.module.ts b/src/onboarding/onboarding.module.ts new file mode 100644 index 0000000..307c076 --- /dev/null +++ b/src/onboarding/onboarding.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { OnboardingService } from './onboarding.service'; +import { OnboardingController } from './onboarding.controller'; + +@Module({ + imports: [AuthModule], + providers: [OnboardingService], + controllers: [OnboardingController], +}) +export class OnboardingModule {} diff --git a/src/onboarding/onboarding.service.spec.ts b/src/onboarding/onboarding.service.spec.ts new file mode 100644 index 0000000..7e1ee35 --- /dev/null +++ b/src/onboarding/onboarding.service.spec.ts @@ -0,0 +1,224 @@ +import { + BadRequestException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { MembershipStatus, Role } from '@prisma/client'; +import { OnboardingService } from './onboarding.service'; + +/// Prisma / Sync / TokenVerification faked in memory. + +const CLAIMS = { + sub: 'authentik-sub-1', + email: 'Vera@example.org', + firstName: 'Vera', + lastName: 'Wong', +}; + +function makeService(seed: { + kc?: { id: string; name: string; inviteCode: string; isActive: boolean } | null; + gemeinde?: { id: string; name: string; kcId: string } | null; + user?: { id: string; authentikSub: string } | null; + membership?: { + id: string; + status: MembershipStatus; + userId: string; + kcId: string; + gemeindeId: string; + } | null; + tokenThrows?: boolean; +}) { + const state = { + membership: seed.membership ?? null, + }; + + const prisma = { + kc: { + findUnique: jest.fn().mockResolvedValue( + seed.kc === undefined + ? { id: 'kc-1', name: 'KC 2026', inviteCode: 'code-1', isActive: true, gemeinden: [] } + : seed.kc, + ), + }, + gemeinde: { + findUnique: jest.fn().mockResolvedValue( + seed.gemeinde === undefined ? { id: 'gem-1', name: 'Nord', kcId: 'kc-1' } : seed.gemeinde, + ), + }, + user: { + findUnique: jest.fn().mockResolvedValue(seed.user ?? null), + create: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ id: 'u-new', ...data }), + ), + }, + membership: { + findUnique: jest.fn(() => Promise.resolve(state.membership)), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(({ data }: { data: Record }) => { + state.membership = { id: 'mem-new', ...data } as never; + return Promise.resolve(state.membership); + }), + update: jest.fn(({ data }: { data: { status: MembershipStatus } }) => { + state.membership = { ...state.membership!, ...data }; + return Promise.resolve(state.membership); + }), + delete: jest.fn(() => Promise.resolve(state.membership!)), + }, + }; + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; + const tokens = { + verifyAuthentikClaims: seed.tokenThrows + ? jest.fn().mockRejectedValue(new UnauthorizedException('bad token')) + : jest.fn().mockResolvedValue(CLAIMS), + }; + + const service = new OnboardingService(prisma as never, sync as never, tokens as never); + return { service, prisma, sync, tokens }; +} + +describe('OnboardingService.resolveInvite', () => { + it('404s an unknown code', async () => { + const { service } = makeService({ kc: null }); + await expect(service.resolveInvite('nope')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s an inactive KC', async () => { + const { service } = makeService({ + kc: { id: 'kc-1', name: 'KC', inviteCode: 'c', isActive: false }, + }); + await expect(service.resolveInvite('c')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('returns the KC name and its Gemeinden', async () => { + const { service, prisma } = makeService({}); + prisma.kc.findUnique = jest.fn().mockResolvedValue({ + id: 'kc-1', + name: 'KC 2026', + isActive: true, + gemeinden: [{ id: 'gem-1', name: 'Nord' }], + }); + await expect(service.resolveInvite('code-1')).resolves.toEqual({ + kcId: 'kc-1', + kcName: 'KC 2026', + gemeinden: [{ id: 'gem-1', name: 'Nord' }], + }); + }); +}); + +describe('OnboardingService.registerVerantwortliche', () => { + it('rejects a missing token', async () => { + const { service } = makeService({}); + await expect( + service.registerVerantwortliche(undefined, 'code-1', 'gem-1'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('propagates an invalid token', async () => { + const { service } = makeService({ tokenThrows: true }); + await expect( + service.registerVerantwortliche('t', 'code-1', 'gem-1'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('404s an unknown invite code', async () => { + const { service } = makeService({ kc: null }); + await expect( + service.registerVerantwortliche('t', 'bad', 'gem-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('400s when the Gemeinde is not part of the KC', async () => { + const { service } = makeService({ gemeinde: { id: 'gem-9', name: 'X', kcId: 'other-kc' } }); + await expect( + service.registerVerantwortliche('t', 'code-1', 'gem-9'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('provisions the user and creates a PENDING membership', async () => { + const { service, prisma, sync } = makeService({ user: null, membership: null }); + const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1'); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + authentikSub: 'authentik-sub-1', + email: 'vera@example.org', + }), + }); + expect(prisma.membership.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + role: Role.GEMEINDE_VERANTWORTLICHER, + status: MembershipStatus.PENDING, + gemeindeId: 'gem-1', + }), + }); + expect(res).toMatchObject({ status: MembershipStatus.PENDING, kcName: 'KC 2026' }); + expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything()); + expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything()); + }); + + it('does not re-create the user when one already exists', async () => { + const { service, prisma } = makeService({ + user: { id: 'u-1', authentikSub: 'authentik-sub-1' }, + membership: null, + }); + await service.registerVerantwortliche('t', 'code-1', 'gem-1'); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(prisma.membership.create).toHaveBeenCalled(); + }); + + it('returns the existing membership status without creating a second one', async () => { + const { service, prisma } = makeService({ + user: { id: 'u-1', authentikSub: 'authentik-sub-1' }, + membership: { + id: 'mem-1', + status: MembershipStatus.ACTIVE, + userId: 'u-1', + kcId: 'kc-1', + gemeindeId: 'gem-1', + }, + }); + const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1'); + expect(res).toMatchObject({ membershipId: 'mem-1', status: MembershipStatus.ACTIVE }); + expect(prisma.membership.create).not.toHaveBeenCalled(); + }); +}); + +describe('OnboardingService.approve / reject', () => { + const pending = { + id: 'mem-1', + status: MembershipStatus.PENDING, + userId: 'u-1', + kcId: 'kc-1', + gemeindeId: 'gem-1', + }; + + it('404s approving an unknown request', async () => { + const { service } = makeService({ membership: null }); + await expect(service.approve('mem-x')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('400s approving a non-pending request', async () => { + const { service } = makeService({ + membership: { ...pending, status: MembershipStatus.ACTIVE }, + }); + await expect(service.approve('mem-1')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('flips the status to ACTIVE and captures the update', async () => { + const { service, prisma, sync } = makeService({ membership: { ...pending } }); + await service.approve('mem-1'); + expect(prisma.membership.update).toHaveBeenCalledWith({ + where: { id: 'mem-1' }, + data: { status: MembershipStatus.ACTIVE }, + }); + expect(sync.capture).toHaveBeenCalledWith('Membership', 'UPDATE', 'mem-1', expect.anything()); + }); + + it('deletes on reject and captures the delete', async () => { + const { service, prisma, sync } = makeService({ membership: { ...pending } }); + const res = await service.reject('mem-1'); + expect(res).toEqual({ id: 'mem-1' }); + expect(prisma.membership.delete).toHaveBeenCalledWith({ where: { id: 'mem-1' } }); + expect(sync.capture).toHaveBeenCalledWith('Membership', 'DELETE', 'mem-1', { id: 'mem-1' }); + }); +}); diff --git a/src/onboarding/onboarding.service.ts b/src/onboarding/onboarding.service.ts new file mode 100644 index 0000000..71b606f --- /dev/null +++ b/src/onboarding/onboarding.service.ts @@ -0,0 +1,155 @@ +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +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'; + +/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in +/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus +/// the Gemeinde they belong to; this provisions their local User (JIT) and a +/// PENDING membership that a Leitungsteam member must approve before it grants +/// any rights. +@Injectable() +export class OnboardingService { + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + private readonly tokens: TokenVerificationService, + ) {} + + /// Public: resolves an invite code to the KC name and its Gemeinden so the + /// registrant can pick theirs. The code itself is the shared secret. + async resolveInvite(inviteCode: string) { + const kc = await this.prisma.kc.findUnique({ + where: { inviteCode }, + include: { + gemeinden: { select: { id: true, name: true }, orderBy: { name: 'asc' } }, + }, + }); + if (!kc || !kc.isActive) { + throw new NotFoundException('Unknown or inactive KC invite code'); + } + return { kcId: kc.id, kcName: kc.name, gemeinden: kc.gemeinden }; + } + + async registerVerantwortliche(token: string | undefined, inviteCode: string, gemeindeId: string) { + if (!token) { + throw new UnauthorizedException('Missing Authentik bearer token'); + } + const claims = await this.tokens.verifyAuthentikClaims(token); + + const kc = await this.prisma.kc.findUnique({ where: { inviteCode } }); + if (!kc || !kc.isActive) { + throw new NotFoundException('Unknown or inactive KC invite code'); + } + const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } }); + if (!gemeinde || gemeinde.kcId !== kc.id) { + throw new BadRequestException('Gemeinde does not belong to this KC'); + } + + const user = await this.upsertUser(claims); + + const existing = await this.prisma.membership.findUnique({ + where: { + userId_kcId_gemeindeId: { userId: user.id, kcId: kc.id, gemeindeId }, + }, + }); + if (existing) { + return this.summary(existing.id, existing.status, kc.name, gemeinde.name); + } + + const membership = await this.prisma.membership.create({ + data: { + userId: user.id, + kcId: kc.id, + gemeindeId, + role: Role.GEMEINDE_VERANTWORTLICHER, + status: MembershipStatus.PENDING, + }, + }); + await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership); + return this.summary(membership.id, membership.status, kc.name, gemeinde.name); + } + + async listRequests(kcId: string) { + return this.prisma.membership.findMany({ + where: { + kcId, + status: MembershipStatus.PENDING, + role: Role.GEMEINDE_VERANTWORTLICHER, + }, + include: { + user: { select: { id: true, email: true, firstName: true, lastName: true } }, + gemeinde: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + } + + async approve(membershipId: string) { + await this.getPendingOrThrow(membershipId); + const membership = await this.prisma.membership.update({ + where: { id: membershipId }, + data: { status: MembershipStatus.ACTIVE }, + }); + await this.sync.capture('Membership', SyncOperation.UPDATE, membership.id, membership); + return membership; + } + + async reject(membershipId: string) { + await this.getPendingOrThrow(membershipId); + const membership = await this.prisma.membership.delete({ where: { id: membershipId } }); + await this.sync.capture('Membership', SyncOperation.DELETE, membership.id, { id: membership.id }); + return { id: membership.id }; + } + + private async getPendingOrThrow(membershipId: string) { + const membership = await this.prisma.membership.findUnique({ where: { id: membershipId } }); + if (!membership) { + throw new NotFoundException('Request not found'); + } + if (membership.status !== MembershipStatus.PENDING) { + throw new BadRequestException('Request is not pending'); + } + 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, + kcName: string, + gemeindeName: string, + ) { + return { membershipId, status, kcName, gemeindeName }; + } +}