From 6aac78584195f8fd6aee7d4b140eafc02185bf06 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 13:09:11 +0200 Subject: [PATCH] #217: map IdP groups and roles onto the permission model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declarative instance setting idpMapping.rules turns ID-token claims into pond roles and the site-admin flag on every OIDC login — configuration, not code. Mapped grants travel through the SAME GrantsService path as manual ones (permission cache invalidated, collab access notify fires so live sessions revalidate — asserted by test), never raw rows. Ownership makes precedence explicit: role_grants.origin marks mapped rows, users.is_site_admin_managed marks a mapping-set admin flag. The mapping only creates and revokes what it owns — manual wins: hand-made grants and hand-promoted admins are never revoked by a missing claim (a manual toggle clears the marker and takes ownership). Removal of a claim revokes the mapped grant and the managed flag on the next login. Every mapping-driven change is audited with origin idp_mapping. Failure containment: unknown pond slugs and the last-Pond-Admin protection log-and-skip — a mapping problem must never become a login lockout. Tests drive real OIDC logins against the fake IdP with group claims: grant + working access, revocation incl. notify, manual-wins, managed site-admin promote/demote/hands-off. Documented in permissions.md (own section), ADR 0021, data-model.md and the hardening guide (care rule: same PR). Refs #217. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8 --- .../migration.sql | 9 + apps/api/prisma/schema.prisma | 9 + apps/api/src/admin/user-admin.service.ts | 4 +- apps/api/src/auth/auth.module.ts | 5 +- .../api/src/auth/claim-mapping.e2e.db.test.ts | 272 ++++++++++++++++++ apps/api/src/auth/claim-mapping.service.ts | 161 +++++++++++ apps/api/src/auth/oidc.service.ts | 6 + apps/api/src/grants/grants.service.ts | 26 +- .../src/settings/instance-settings.service.ts | 27 ++ .../adr/0021-external-authentication.md | 13 + docs/architecture/data-model.md | 2 + docs/architecture/permissions.md | 35 +++ docs/vs-nfd/20-massnahmenplan.md | 2 +- docs/vs-nfd/50-haertungsleitfaden.md | 35 +-- 14 files changed, 582 insertions(+), 24 deletions(-) create mode 100644 apps/api/prisma/migrations/20260731190000_idp_claim_mapping/migration.sql create mode 100644 apps/api/src/auth/claim-mapping.e2e.db.test.ts create mode 100644 apps/api/src/auth/claim-mapping.service.ts diff --git a/apps/api/prisma/migrations/20260731190000_idp_claim_mapping/migration.sql b/apps/api/prisma/migrations/20260731190000_idp_claim_mapping/migration.sql new file mode 100644 index 0000000..bffcfdb --- /dev/null +++ b/apps/api/prisma/migrations/20260731190000_idp_claim_mapping/migration.sql @@ -0,0 +1,9 @@ +-- #217 (ADR 0021): IdP claim mapping. Grants gain an origin so mapped rows +-- are distinguishable from manual ones (the mapping only ever touches its +-- own); the site-admin flag gains a "managed" marker so only a +-- mapping-granted flag can be mapping-revoked. +ALTER TABLE "role_grants" + ADD COLUMN "origin" TEXT NOT NULL DEFAULT 'manual'; + +ALTER TABLE "users" + ADD COLUMN "is_site_admin_managed" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 61b07a0..835e7e9 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -37,6 +37,11 @@ model User { displayName String @map("display_name") locale String @default("en") isSiteAdmin Boolean @default(false) @map("is_site_admin") + /// True when the flag was last SET by the IdP claim mapping (issue #217): + /// only then may the mapping revoke it again on a later login. A manual + /// admin toggle clears the marker, so hand-granted admins are never + /// demoted by a missing claim. + isSiteAdminManaged Boolean @default(false) @map("is_site_admin_managed") /// Auto-watch preferences (issue #93): watch pages I create / comment on. autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages") autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment") @@ -285,6 +290,10 @@ model RoleGrant { scopeType GrantScopeType @map("scope_type") scopeId String? @map("scope_id") effect GrantEffect + /// `manual` (admin-created) or `idp` (written by the claim mapping, + /// issue #217). The mapping only ever creates and revokes ITS OWN rows — + /// manual grants are never touched, which is the documented precedence. + origin String @default("manual") createdBy String @map("created_by") createdAt DateTime @default(now()) @map("created_at") diff --git a/apps/api/src/admin/user-admin.service.ts b/apps/api/src/admin/user-admin.service.ts index 1f88f8c..2d230e4 100644 --- a/apps/api/src/admin/user-admin.service.ts +++ b/apps/api/src/admin/user-admin.service.ts @@ -115,7 +115,9 @@ export class UserAdminService { if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin(); const updated = await this.prisma.user.update({ where: { id }, - data: { isSiteAdmin: value }, + // A manual toggle takes ownership of the flag: the IdP mapping + // (#217) may only revoke what it itself set. + data: { isSiteAdmin: value, isSiteAdminManaged: false }, }); await this.audit.record({ action: 'user.site_admin_set', diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index f87b534..1a376ae 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -2,6 +2,7 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { AppConfig } from '../config/app-config.service'; +import { GrantsModule } from '../grants/grants.module'; import { MailModule } from '../mail/mail.module'; import { PondsModule } from '../ponds/ponds.module'; @@ -10,17 +11,19 @@ import { AuthController } from './auth.controller'; import { AuthGuard } from './auth.guard'; import { AuthService } from './auth.service'; import { AuthTokensService } from './auth-tokens.service'; +import { ClaimMappingService } from './claim-mapping.service'; import { OidcController } from './oidc.controller'; import { OidcService } from './oidc.service'; import { ProxyIdentityService } from './proxy-identity.service'; import { SessionsModule } from './sessions.module'; @Module({ - imports: [UsersModule, MailModule, SessionsModule, PondsModule], + imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule], controllers: [AuthController, OidcController], providers: [ AuthService, AuthTokensService, + ClaimMappingService, OidcService, ProxyIdentityService, // Global default-protected: every route needs a session unless it diff --git a/apps/api/src/auth/claim-mapping.e2e.db.test.ts b/apps/api/src/auth/claim-mapping.e2e.db.test.ts new file mode 100644 index 0000000..43dd1d1 --- /dev/null +++ b/apps/api/src/auth/claim-mapping.e2e.db.test.ts @@ -0,0 +1,272 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from 'jose'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { PondAccessNotifier } from '../ponds/pond-access-notifier.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * IdP claim mapping (issue #217, ADR 0021): declarative `idpMapping.rules` + * turn ID-token claims into pond roles and the site-admin flag on every + * OIDC login — through the same grant-service path as manual grants (the + * collab revocation notify is asserted), with removal on the next login, + * "manual wins" precedence, and audited changes. + */ +describe.skipIf(!hasTestDb)('idp claim mapping (e2e, issue #217)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let idp: Server; + let issuer: string; + const suffix = uniqueSuffix(); + + let signingKey: CryptoKey; + let publicJwk: Record; + let nextClaims: (nonce: string) => JWTPayload; + let currentNonce = ''; + + let adminId: string; + let pondId: string; + const pondSlug = `mapped-${suffix}`; + + const api = () => request(app.getHttpServer()); + + async function loginViaIdp(): Promise { + const begin = await api().get('/api/v1/auth/oidc/login').expect(302); + const url = new URL(begin.headers.location!); + currentNonce = url.searchParams.get('nonce')!; + const stateCookie = (begin.headers['set-cookie'] as unknown as string[]) + .find((c) => c.startsWith('dt_oidc='))! + .split(';')[0]!; + const res = await api() + .get( + `/api/v1/auth/oidc/callback?code=fake&state=${encodeURIComponent( + url.searchParams.get('state')!, + )}`, + ) + .set('Cookie', stateCookie) + .expect(302); + expect(res.headers.location!).toMatch(/\/$/); + return sessionCookieOf(res); + } + + function subjectClaims(groups: string[]): (nonce: string) => JWTPayload { + return (nonce) => ({ + iss: issuer, + aud: 'dorfteich-map', + sub: `mapped-${suffix}`, + nonce, + email: `mapped-${suffix}@idp.example`, + email_verified: true, + preferred_username: `mapped-${suffix}`, + groups, + }); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + let signingPublic: CryptoKey; + ({ privateKey: signingKey, publicKey: signingPublic } = await generateKeyPair('RS256', { + extractable: true, + })); + publicJwk = { ...(await exportJWK(signingPublic)), kid: 'map-key', alg: 'RS256' }; + + idp = createServer((req, res) => { + void (async () => { + res.setHeader('content-type', 'application/json'); + if (req.url === '/.well-known/openid-configuration') { + res.end( + JSON.stringify({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/jwks`, + }), + ); + } else if (req.url === '/jwks') { + res.end(JSON.stringify({ keys: [publicJwk] })); + } else if (req.url === '/token') { + req.resume(); + req.on('end', () => { + void (async () => { + const now = Math.floor(Date.now() / 1000); + const idToken = await new SignJWT({ ...nextClaims(currentNonce) }) + .setProtectedHeader({ alg: 'RS256', kid: 'map-key' }) + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(signingKey); + res.end(JSON.stringify({ id_token: idToken })); + })(); + }); + } else { + res.statusCode = 404; + res.end(); + } + })(); + }); + await new Promise((resolve) => idp.listen(0, '127.0.0.1', resolve)); + issuer = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`; + + process.env.OIDC_ISSUER = issuer; + process.env.OIDC_CLIENT_ID = 'dorfteich-map'; + app = await createTestApp(); + + // A pond to map into, owned by an admin user (created via the service, + // grants via prisma BEFORE the first permission query — test-db rule). + const users = app.get(UsersService); + const admin = await users.createUser({ + username: `map-admin-${suffix}`, + email: `map-admin-${suffix}@example.test`, + displayName: 'Map Admin', + password: 'mapping admin 123', + locale: 'en', + }); + await users.markEmailVerified(admin.id); + adminId = admin.id; + const pond = await prisma.pond.create({ + data: { slug: pondSlug, name: 'Mapped Pond', type: 'SHARED', ownerId: adminId }, + }); + pondId = pond.id; + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'USER', + subjectId: adminId, + role: 'POND_ADMIN', + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: adminId, + }, + }); + + await app.get(InstanceSettingsService).set( + 'idpMapping.rules', + [ + { claim: 'groups', value: 'wiki-editors', role: 'editor', pondSlug }, + { claim: 'groups', value: 'wiki-admins', role: 'site_admin' }, + ], + adminId, + ); + }); + + afterAll(async () => { + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + await new Promise((resolve) => idp.close(() => resolve())); + await prisma.instanceSetting.deleteMany({ where: { key: 'idpMapping.rules' } }); + await prisma.userIdentity.deleteMany({ where: { provider: `oidc:${issuer}` } }); + await prisma.roleGrant.deleteMany({ where: { pondId } }); + await prisma.page.deleteMany({ + where: { pond: { owner: { username: { contains: suffix } } } }, + }); + await prisma.roleGrant.deleteMany({ + where: { pond: { owner: { username: { contains: suffix } } } }, + }); + await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('grants the mapped pond role on login and access actually works', async () => { + nextClaims = subjectClaims(['wiki-editors']); + const session = await loginViaIdp(); + + const grant = await prisma.roleGrant.findFirst({ + where: { pondId, subjectType: 'USER', origin: 'idp' }, + }); + expect(grant).toMatchObject({ role: 'EDITOR', effect: 'ALLOW' }); + + // The permission model actually honours it (no raw-row bypass). + const pages = await api() + .get(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', session) + .expect(200); + expect(Array.isArray(pages.body)).toBe(true); + + const audit = await prisma.auditEntry.findFirst({ + where: { action: 'grant.created', targetId: pondId }, + orderBy: { at: 'desc' }, + }); + expect(audit?.details).toMatchObject({ origin: 'idp_mapping' }); + }); + + it('revokes the mapped grant on the next login without the claim — via the revocation path', async () => { + const notifier = app.get(PondAccessNotifier); + const notifySpy = vi.spyOn(notifier, 'notifyAccessChanged'); + nextClaims = subjectClaims([]); + const session = await loginViaIdp(); + try { + expect(await prisma.roleGrant.findFirst({ where: { pondId, origin: 'idp' } })).toBeNull(); + // The removal travelled through the grant service: the collab + // revocation notify fired for this pond (the pg_notify access + // listener terminates live sessions — that path's own tests cover + // the socket close). + expect(notifySpy.mock.calls.some(([id]) => id === pondId)).toBe(true); + // …and the pond is out of reach again (404: existence hidden). + await api().get(`/api/v1/ponds/${pondId}/pages`).set('Cookie', session).expect(404); + } finally { + notifySpy.mockRestore(); + } + }); + + it('never touches a manual grant, and re-creating over one is skipped (manual wins)', async () => { + const user = await prisma.user.findUnique({ + where: { email: `mapped-${suffix}@idp.example` }, + }); + // A manual reader grant made by the pond admin. + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'USER', + subjectId: user!.id, + role: 'READER', + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: adminId, + origin: 'manual', + }, + }); + + // Login without any mapped claim: the manual grant survives. + nextClaims = subjectClaims([]); + await loginViaIdp(); + const manual = await prisma.roleGrant.findFirst({ + where: { pondId, subjectId: user!.id, origin: 'manual' }, + }); + expect(manual).not.toBeNull(); + expect(manual!.role).toBe('READER'); + }); + + it('maps and revokes the site-admin flag — but never demotes a hand-promoted admin', async () => { + nextClaims = subjectClaims(['wiki-admins']); + await loginViaIdp(); + let user = await prisma.user.findUnique({ where: { email: `mapped-${suffix}@idp.example` } }); + expect(user).toMatchObject({ isSiteAdmin: true, isSiteAdminManaged: true }); + + nextClaims = subjectClaims([]); + await loginViaIdp(); + user = await prisma.user.findUnique({ where: { email: `mapped-${suffix}@idp.example` } }); + expect(user).toMatchObject({ isSiteAdmin: false, isSiteAdminManaged: false }); + + // Hand-promoted (managed=false): a claimless login must not demote. + await prisma.user.update({ + where: { id: user!.id }, + data: { isSiteAdmin: true, isSiteAdminManaged: false }, + }); + nextClaims = subjectClaims([]); + await loginViaIdp(); + user = await prisma.user.findUnique({ where: { email: `mapped-${suffix}@idp.example` } }); + expect(user!.isSiteAdmin).toBe(true); + }); +}); diff --git a/apps/api/src/auth/claim-mapping.service.ts b/apps/api/src/auth/claim-mapping.service.ts new file mode 100644 index 0000000..40268b9 --- /dev/null +++ b/apps/api/src/auth/claim-mapping.service.ts @@ -0,0 +1,161 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '@prisma/client'; +import type { JWTPayload } from 'jose'; +import { PinoLogger } from 'nestjs-pino'; + +import { AuditService } from '../audit/audit.service'; +import { GrantsService } from '../grants/grants.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; + +/** + * IdP claim mapping (issue #217, ADR 0021): on every OIDC login the + * declarative rules in `idpMapping.rules` are evaluated against the ID + * token's claims and reconciled against the user's MAPPING-OWNED state: + * + * - Pond grants are created and revoked through {@link GrantsService} — + * the same path as manual grants, so the permission cache is + * invalidated and live collab sessions are revalidated + * (`notifyAccessChanged` → the collab access listener) exactly as on a + * manual change. No raw row writes. + * - The mapping only ever touches rows with `origin = 'idp'` and only + * demotes a site admin whose flag it itself set + * (`isSiteAdminManaged`) — **manual wins**: hand-made grants and + * hand-promoted admins are never revoked by a missing claim. + * - Every change is audited (grant.created/grant.deleted with + * `origin: idp_mapping`; user.site_admin_set with the same marker). + * + * Reconciliation happens at login because that is when fresh claims + * exist; between logins the leaver case is the IdP's (disable there = + * no new login) plus the operator's account-disable flag. + */ +@Injectable() +export class ClaimMappingService { + constructor( + private readonly prisma: PrismaService, + private readonly grants: GrantsService, + private readonly settings: InstanceSettingsService, + private readonly audit: AuditService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(ClaimMappingService.name); + } + + async apply(user: User, payload: JWTPayload): Promise { + const rules = await this.settings.get('idpMapping.rules'); + if (rules.length === 0) return; + + const matched = rules.filter((rule) => claimMatches(payload[rule.claim], rule.value)); + + await this.reconcileSiteAdmin( + user, + matched.some((rule) => rule.role === 'site_admin'), + ); + + // Desired pond grants, resolved slug → id (unknown slugs are a + // configuration error: logged, never fatal for the login). + const desired = new Map(); + for (const rule of matched) { + if (rule.role === 'site_admin') continue; + const pond = await this.prisma.pond.findFirst({ + where: { slug: rule.pondSlug!, deletedAt: null }, + select: { id: true }, + }); + if (!pond) { + this.logger.warn({ pondSlug: rule.pondSlug }, 'idp mapping: unknown pond slug'); + continue; + } + // Multiple rules for one pond: the strongest role wins. + const current = desired.get(pond.id); + if (!current || rank(rule.role) > rank(current)) desired.set(pond.id, rule.role); + } + + const existing = await this.prisma.roleGrant.findMany({ + where: { subjectType: 'USER', subjectId: user.id, origin: 'idp' }, + }); + + for (const grant of existing) { + const wanted = desired.get(grant.pondId); + if (wanted && toDbRole(wanted) === grant.role) { + desired.delete(grant.pondId); // already in place + continue; + } + try { + await this.grants.deleteGrant(user, grant.pondId, grant.id, { origin: 'idp' }); + } catch (error) { + // E.g. the last-Pond-Admin protection: the grant stays, the login + // proceeds — an operator decision is needed, not a lockout. + this.logger.warn( + { grantId: grant.id, pondId: grant.pondId, err: error }, + 'idp mapping: grant revocation refused', + ); + } + } + + for (const [pondId, role] of desired) { + try { + await this.grants.createGrant( + user, + pondId, + { + subjectType: 'user', + subjectId: user.id, + role, + scopeType: 'pond', + scopeId: null, + effect: 'allow', + }, + { origin: 'idp' }, + ); + } catch (error) { + // A colliding MANUAL grant (grant_exists) is fine — manual wins, + // the mapping never replaces it with an owned copy. + this.logger.warn({ pondId, role, err: error }, 'idp mapping: grant creation skipped'); + } + } + } + + private async reconcileSiteAdmin(user: User, shouldBeAdmin: boolean): Promise { + if (shouldBeAdmin && !user.isSiteAdmin) { + await this.prisma.user.update({ + where: { id: user.id }, + data: { isSiteAdmin: true, isSiteAdminManaged: true }, + }); + await this.audit.record({ + action: 'user.site_admin_set', + actorId: user.id, + targetType: 'user', + targetId: user.id, + details: { isSiteAdmin: true, origin: 'idp_mapping' }, + }); + } else if (!shouldBeAdmin && user.isSiteAdmin && user.isSiteAdminManaged) { + // Only the mapping's own promotion is revocable by a missing claim. + await this.prisma.user.update({ + where: { id: user.id }, + data: { isSiteAdmin: false, isSiteAdminManaged: false }, + }); + await this.audit.record({ + action: 'user.site_admin_set', + actorId: user.id, + targetType: 'user', + targetId: user.id, + details: { isSiteAdmin: false, origin: 'idp_mapping' }, + }); + } + } +} + +/** A claim matches when it equals the value or, as an array, contains it. */ +function claimMatches(claim: unknown, value: string): boolean { + if (Array.isArray(claim)) return claim.some((entry) => String(entry) === value); + if (claim === undefined || claim === null) return false; + return String(claim) === value; +} + +function rank(role: 'pond_admin' | 'editor' | 'reader'): number { + return role === 'pond_admin' ? 3 : role === 'editor' ? 2 : 1; +} + +function toDbRole(role: 'pond_admin' | 'editor' | 'reader'): 'POND_ADMIN' | 'EDITOR' | 'READER' { + return role === 'pond_admin' ? 'POND_ADMIN' : role === 'editor' ? 'EDITOR' : 'READER'; +} diff --git a/apps/api/src/auth/oidc.service.ts b/apps/api/src/auth/oidc.service.ts index 6c17ffb..4946ff6 100644 --- a/apps/api/src/auth/oidc.service.ts +++ b/apps/api/src/auth/oidc.service.ts @@ -19,6 +19,7 @@ import { PondsService } from '../ponds/ponds.service'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; +import { ClaimMappingService } from './claim-mapping.service'; import { SessionsService } from './sessions.service'; /** The state cookie's signed payload lives this long — ample for one @@ -72,6 +73,7 @@ export class OidcService { private readonly users: UsersService, private readonly sessions: SessionsService, private readonly ponds: PondsService, + private readonly claimMapping: ClaimMappingService, private readonly audit: AuditService, private readonly config: AppConfig, private readonly logger: PinoLogger, @@ -243,6 +245,10 @@ export class OidcService { if (user.status === 'DISABLED') { throw new BadRequestException({ code: 'account_disabled' }); } + // Claim mapping (issue #217): reconcile mapped grants and the managed + // site-admin flag against this login's fresh claims — before the + // session exists, so the first request already sees the new state. + await this.claimMapping.apply(user, payload); const sessionToken = await this.sessions.create(user.id, userAgent); await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); await this.audit.record({ diff --git a/apps/api/src/grants/grants.service.ts b/apps/api/src/grants/grants.service.ts index 1f62766..fd7b08a 100644 --- a/apps/api/src/grants/grants.service.ts +++ b/apps/api/src/grants/grants.service.ts @@ -152,7 +152,14 @@ export class GrantsService { * pond_admin only at pond scope for a user subject, no extra admins on a * personal pond, and scope/subject must exist here. Rejects duplicates. */ - async createGrant(user: User, pondId: string, grant: Grant): Promise { + async createGrant( + user: User, + pondId: string, + grant: Grant, + // `idp` when the claim mapping writes (issue #217): the row is marked + // as mapping-owned and the audit entry names the origin. + options: { origin?: 'manual' | 'idp' } = {}, + ): Promise { const pond = await this.requireLivePond(pondId); const invalid = grantValidationError(grant, { @@ -169,7 +176,7 @@ export class GrantsService { if (existing) throw new ConflictException({ code: 'grant_exists' }); const created = await this.prisma.roleGrant.create({ - data: { pondId, createdBy: user.id, ...columns }, + data: { pondId, createdBy: user.id, origin: options.origin ?? 'manual', ...columns }, }); await this.accessChanged(pondId); await this.audit.record({ @@ -185,6 +192,7 @@ export class GrantsService { scope: grant.scopeType, scopeId: grant.scopeId, effect: grant.effect, + ...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}), }, }); return GrantsService.viewOf(created); @@ -195,7 +203,12 @@ export class GrantsService { * grant is protected — deleting it would leave the pond unmanageable * (only a Site Admin could recover it). */ - async deleteGrant(user: User, pondId: string, grantId: string): Promise { + async deleteGrant( + user: User, + pondId: string, + grantId: string, + options: { origin?: 'manual' | 'idp' } = {}, + ): Promise { const grant = await this.prisma.roleGrant.findFirst({ where: { id: grantId, pondId } }); if (!grant) throw new NotFoundException(); @@ -213,7 +226,12 @@ export class GrantsService { actorId: user.id, targetType: 'pond', targetId: pondId, - details: { grantId, subjectId: grant.subjectId, role: grant.role }, + details: { + grantId, + subjectId: grant.subjectId, + role: grant.role, + ...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}), + }, }); } diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index aebcaee..e1755f2 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -51,6 +51,33 @@ export const INSTANCE_SETTINGS = { // bounded. PENDING rows — including failed-but-retryable ones — are // never touched; the retry loop owns them. 'mail.outboxRetentionDays': z.number().int().min(1).default(30), + // IdP claim mapping (issue #217, ADR 0021): declarative rules turning + // ID-token claims into pond roles and the site-admin flag — instance + // configuration, not code. Applied on every OIDC login through the same + // grant service path as manual grants (cache + collab revocation stay + // correct); the mapping only creates/revokes rows it owns (origin `idp`) + // and only demotes a site admin it itself promoted. `site_admin` rules + // take no pond; every other role requires one. + 'idpMapping.rules': z + .array( + z.object({ + claim: z.string().min(1), + value: z.string().min(1), + role: z.enum(['site_admin', 'pond_admin', 'editor', 'reader']), + pondSlug: z.string().min(1).optional(), + }), + ) + .superRefine((rules, ctx) => { + rules.forEach((rule, index) => { + if (rule.role === 'site_admin' && rule.pondSlug) { + ctx.addIssue({ code: 'custom', path: [index], message: 'validation.invalid' }); + } + if (rule.role !== 'site_admin' && !rule.pondSlug) { + ctx.addIssue({ code: 'custom', path: [index], message: 'validation.required' }); + } + }); + }) + .default([]), // Read-trail master switch (issue #225, ADR 0023). Default OFF: read // logging is employee monitoring in a works council's eyes — an ordinary // instance must not surveil reads. The VS-NfD reference configuration diff --git a/docs/architecture/adr/0021-external-authentication.md b/docs/architecture/adr/0021-external-authentication.md index 673d361..7d5e043 100644 --- a/docs/architecture/adr/0021-external-authentication.md +++ b/docs/architecture/adr/0021-external-authentication.md @@ -100,6 +100,19 @@ expect the application to trust a header or a client certificate. sessions: they authorize API access under their own switches (`api.enabled`, `feeds.enabled`), they are not interactive sign-in. +## Decisions taken in #217 + +- **Reconciliation at login**, not by background sync: fresh claims exist + only there; between logins the leaver case belongs to the IdP (no new + login) and the operator's disable flag. +- **Ownership via `role_grants.origin` and `users.is_site_admin_managed`** + — the mapping creates, updates and revokes only what it owns; manual + grants and hand-promoted admins always win. A colliding manual grant is + left in place rather than adopted. +- **Failure containment**: unknown pond slugs and the last-Pond-Admin + protection log-and-skip — a mapping problem must never lock users out. +- Full semantics: `docs/architecture/permissions.md` §IdP claim mapping. + ## Consequences - Bootstrapping needs a documented answer: the first-run wizard creates a diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index ed6a8b9..6ca7b2c 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -41,6 +41,7 @@ erDiagram | `display_name` | shown at cursors, comments | | `locale` | UI language (ADR 0012) | | `is_site_admin` | boolean; Site Admin is a user flag, not a grant | +| `is_site_admin_managed` | true when the IdP claim mapping set the flag (issue #217) | | `status` | `active` / `disabled` / `pending_verification` | | `created_at`, `last_login_at` | | @@ -73,6 +74,7 @@ Single-use tokens for e-mail verification and password reset: hashed token, | `scope_id` | label id or page id when scoped, else null | | `effect` | `allow` / `deny` | | `created_by`, `created_at` | audit | +| `origin` | `manual` / `idp` (claim mapping, issue #217) | Unique on (`pond_id`, `subject_type`, `subject_id`, `role`, `scope_type`, `scope_id`). `pond_admin` grants are only valid with `scope_type = pond` diff --git a/docs/architecture/permissions.md b/docs/architecture/permissions.md index 4a20f5b..f593045 100644 --- a/docs/architecture/permissions.md +++ b/docs/architecture/permissions.md @@ -35,6 +35,41 @@ A grant is `(subject, role, scope, effect)` inside one pond - **scope**: the whole pond, one label, or one page. - **effect**: `allow` or `deny`. `deny` expresses the vision's "all pages _except_ label X" (pond-scope allow + label-scope deny). +- **origin**: `manual` (admin-created) or `idp` (written by the claim + mapping below). Resolution ignores the column — it only exists so the + mapping can tell its own rows apart. + +## IdP claim mapping (issue #217, ADR 0021) + +With external authentication (#214), the instance setting +`idpMapping.rules` maps ID-token claims declaratively onto this model — +configuration, not code: + +```json +[ + { "claim": "groups", "value": "wiki-editors", "role": "editor", "pondSlug": "team-wiki" }, + { "claim": "groups", "value": "wiki-admins", "role": "site_admin" } +] +``` + +Semantics, decided and tested: + +- **Applied on every OIDC login** (fresh claims exist only there). + Matching is string equality; array claims match by containment. Several + rules for one pond: the strongest role wins. +- **Same service path as manual grants** (`GrantsService`) — the + permission cache is invalidated and the collab access notify fires, so + live sessions revalidate exactly as on a manual change. Never raw rows. +- **Removal of a claim revokes the mapped grant on the next login.** The + mapping only ever touches rows with `origin = 'idp'` — **manual wins**: + hand-made grants and hand-promoted site admins are never revoked by a + missing claim (`users.is_site_admin_managed` marks a mapping-set flag; + a manual toggle clears the marker and takes ownership). +- **Every mapping-driven change is audited** (`grant.created` / + `grant.deleted` / `user.site_admin_set` with `origin: idp_mapping`). +- Configuration errors (unknown pond slug) and refusals (last-Pond-Admin + protection) are logged and skipped — a mapping problem must never + become a login lockout. ## Resolution algorithm diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 940dceb..71486fb 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -42,7 +42,7 @@ _Meilenstein: `M27 — VS-NfD: external authentication`_ Client-Zertifikat · 2 AT · #215 - [x] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216 -- [ ] Gruppen-/Rollen-Mapping aus IdP-Claims auf das Permission-Modell · 2–3 AT · #217 +- [x] Gruppen-/Rollen-Mapping aus IdP-Claims auf das Permission-Modell · 2–3 AT · #217 ### P1-2 Einstufung als First-Class-Metadatum · 14–18 AT diff --git a/docs/vs-nfd/50-haertungsleitfaden.md b/docs/vs-nfd/50-haertungsleitfaden.md index f00e837..662c1bb 100644 --- a/docs/vs-nfd/50-haertungsleitfaden.md +++ b/docs/vs-nfd/50-haertungsleitfaden.md @@ -26,23 +26,24 @@ Produkt-Beleg). Nach jeder Änderung an Instanz-Settings die api neu starten — der Settings-Cache ist in-process (operations.md). -| Setting | Referenzwert | Default | Warum | -| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. | -| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). | -| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. | -| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. | -| `plugins.enabled` | `false` | `true` | **explizit setzen** — kein Fremdcode in der VS-Zone (#200): alle Plugin-Flächen 404, Dropzone quarantänisiert; bestehende Blöcke degradieren zu ihrem deklarierten Text-Fallback. Hash-Pinning ist verschoben (#232, Restrisikoliste) — der Kill-Switch deckt das Risiko für diesen Betriebsmodus vollständig. | -| `classification.newPageDefault` | `vs_nfd` | `unclassified` | in einer VS-NfD-Instanz beginnt nichts unmarkiert (#204); die Vererbung (#205) hält den Baum konsistent. | -| `classification.uploadPolicy` | `block` | `warn` | Anhänge können die Kennzeichnung im Inhalt nicht tragen (#212) — die Referenzkonfiguration lehnt Uploads auf eingestufte Seiten serverseitig ab (403 `classified_upload_blocked`, #213) statt nur zu warnen. | -| `upload.svgPolicy` | `reject` | `sanitize` | SVG ist aktiver Inhalt; die Sanitisierung ist gut getestet, aber Ablehnen ist die kleinere Angriffsfläche. Abweichung vertretbar, wenn SVG gebraucht wird. | -| `upload.allowedExtensions` | nur das dienstlich Nötige (z. B. `pdf`) | Standardliste | jede zusätzliche Endung vergrößert die Menge nicht prüfbarer Binärformate im Bestand. Bilder sind davon unabhängig immer erlaubt (Magic-Byte-geprüft). | -| `backup.nextcloud.enabled` | `false` | `false` | „Backup nur lokal": kein Anwendungs-Upload von Restore-Sets zu Drittdiensten. Fernspiegel regelt ausschließlich die Deploy-Allowlist (1.2). | -| `trash.retentionDays`, `audit.retentionDays`, `conversion.payloadRetentionDays`, `mail.outboxRetentionDays` | Defaults (30/365/30/30) | ebd. | Aufbewahrung bewusst begrenzt; Verkürzung nach Betreiber-Löschkonzept zulässig (Betriebshandbuch §5). | -| `readTrail.enabled` | `true` | `false` | **explizit setzen** — der Lesetrail (#222–#225) evidenziert Lesezugriffe auf eingestufte Seiten; Default aus, weil Lesebeobachtung mitbestimmungsrelevant ist. Einschalten NUR zusammen mit der Zweckbindung (Sicherheitsdokumentation §7); die api meldet die Schalterstellung beim Start. | -| `readTrail.dedupWindowMinutes` | Default (5) | `5` | Dedup-Fenster des Lesetrails (#223): je (Sitzung, Seite, Kanal) ein Ereignis pro Fenster — begrenzt die Ereignisflut einer Live-Sitzung auf ~12/h. Kleiner = feineres Protokoll und mehr Zeilen; Änderung mit dem Zweckbindungs-Dokument (#225) abstimmen. | -| `readTrail.retentionDays` | Default (365) | `365` | eigene Aufbewahrung des Lesetrails (#224), bewusst getrennt von `audit.retentionDays`; Löschläufe sind selbst auditiert (`read_trail.pruned`). Dauer mit der Zweckbindung (#225) und dem Betreiber-Löschkonzept abstimmen. | -| `legal.imprint`, `legal.privacyPolicy` | befüllt | leer | Betreiberpflicht; leere Seiten zeigen einen Warnbanner. | +| Setting | Referenzwert | Default | Warum | +| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. | +| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). | +| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. | +| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. | +| `plugins.enabled` | `false` | `true` | **explizit setzen** — kein Fremdcode in der VS-Zone (#200): alle Plugin-Flächen 404, Dropzone quarantänisiert; bestehende Blöcke degradieren zu ihrem deklarierten Text-Fallback. Hash-Pinning ist verschoben (#232, Restrisikoliste) — der Kill-Switch deckt das Risiko für diesen Betriebsmodus vollständig. | +| `classification.newPageDefault` | `vs_nfd` | `unclassified` | in einer VS-NfD-Instanz beginnt nichts unmarkiert (#204); die Vererbung (#205) hält den Baum konsistent. | +| `classification.uploadPolicy` | `block` | `warn` | Anhänge können die Kennzeichnung im Inhalt nicht tragen (#212) — die Referenzkonfiguration lehnt Uploads auf eingestufte Seiten serverseitig ab (403 `classified_upload_blocked`, #213) statt nur zu warnen. | +| `upload.svgPolicy` | `reject` | `sanitize` | SVG ist aktiver Inhalt; die Sanitisierung ist gut getestet, aber Ablehnen ist die kleinere Angriffsfläche. Abweichung vertretbar, wenn SVG gebraucht wird. | +| `upload.allowedExtensions` | nur das dienstlich Nötige (z. B. `pdf`) | Standardliste | jede zusätzliche Endung vergrößert die Menge nicht prüfbarer Binärformate im Bestand. Bilder sind davon unabhängig immer erlaubt (Magic-Byte-geprüft). | +| `backup.nextcloud.enabled` | `false` | `false` | „Backup nur lokal": kein Anwendungs-Upload von Restore-Sets zu Drittdiensten. Fernspiegel regelt ausschließlich die Deploy-Allowlist (1.2). | +| `trash.retentionDays`, `audit.retentionDays`, `conversion.payloadRetentionDays`, `mail.outboxRetentionDays` | Defaults (30/365/30/30) | ebd. | Aufbewahrung bewusst begrenzt; Verkürzung nach Betreiber-Löschkonzept zulässig (Betriebshandbuch §5). | +| `readTrail.enabled` | `true` | `false` | **explizit setzen** — der Lesetrail (#222–#225) evidenziert Lesezugriffe auf eingestufte Seiten; Default aus, weil Lesebeobachtung mitbestimmungsrelevant ist. Einschalten NUR zusammen mit der Zweckbindung (Sicherheitsdokumentation §7); die api meldet die Schalterstellung beim Start. | +| `readTrail.dedupWindowMinutes` | Default (5) | `5` | Dedup-Fenster des Lesetrails (#223): je (Sitzung, Seite, Kanal) ein Ereignis pro Fenster — begrenzt die Ereignisflut einer Live-Sitzung auf ~12/h. Kleiner = feineres Protokoll und mehr Zeilen; Änderung mit dem Zweckbindungs-Dokument (#225) abstimmen. | +| `readTrail.retentionDays` | Default (365) | `365` | eigene Aufbewahrung des Lesetrails (#224), bewusst getrennt von `audit.retentionDays`; Löschläufe sind selbst auditiert (`read_trail.pruned`). Dauer mit der Zweckbindung (#225) und dem Betreiber-Löschkonzept abstimmen. | +| `idpMapping.rules` | Gruppen→Rollen der Behörde abbilden | `[]` | deklaratives Claim-Mapping (#217): IdP-Gruppen werden bei jedem OIDC-Login auf Teich-Rollen und das Site-Admin-Flag abgeglichen — sonst pflegt die Behörde Berechtigungen doppelt und die zweite Kopie driftet. Mapping fasst nur eigene Grants an (manuell gewinnt); Details: permissions.md §IdP claim mapping. | +| `legal.imprint`, `legal.privacyPolicy` | befüllt | leer | Betreiberpflicht; leere Seiten zeigen einen Warnbanner. | ### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)