#217: map IdP groups and roles onto the permission model #285

Merged
fable-5 merged 1 commits from issue-217-claim-mapping into main 2026-07-31 13:36:02 +02:00
14 changed files with 582 additions and 24 deletions
Showing only changes of commit 6aac785841 - Show all commits

View File

@ -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;

View File

@ -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")

View File

@ -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',

View File

@ -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

View File

@ -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<string, unknown>;
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<string> {
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<void>((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<void>((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);
});
});

View File

@ -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<void> {
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<string, 'pond_admin' | 'editor' | 'reader'>();
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<void> {
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';
}

View File

@ -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({

View File

@ -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<GrantView> {
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<GrantView> {
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<void> {
async deleteGrant(
user: User,
pondId: string,
grantId: string,
options: { origin?: 'manual' | 'idp' } = {},
): Promise<void> {
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' } : {}),
},
});
}

View File

@ -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

View File

@ -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

View File

@ -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`

View File

@ -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

View File

@ -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 · 23 AT · #217
- [x] Gruppen-/Rollen-Mapping aus IdP-Claims auf das Permission-Modell · 23 AT · #217
### P1-2 Einstufung als First-Class-Metadatum · 1418 AT

View File

@ -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)