#217: map IdP groups and roles onto the permission model #285
@ -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;
|
||||||
@ -37,6 +37,11 @@ model User {
|
|||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
locale String @default("en")
|
locale String @default("en")
|
||||||
isSiteAdmin Boolean @default(false) @map("is_site_admin")
|
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.
|
/// Auto-watch preferences (issue #93): watch pages I create / comment on.
|
||||||
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
|
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
|
||||||
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
|
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
|
||||||
@ -285,6 +290,10 @@ model RoleGrant {
|
|||||||
scopeType GrantScopeType @map("scope_type")
|
scopeType GrantScopeType @map("scope_type")
|
||||||
scopeId String? @map("scope_id")
|
scopeId String? @map("scope_id")
|
||||||
effect GrantEffect
|
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")
|
createdBy String @map("created_by")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
|||||||
@ -115,7 +115,9 @@ export class UserAdminService {
|
|||||||
if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin();
|
if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin();
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id },
|
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({
|
await this.audit.record({
|
||||||
action: 'user.site_admin_set',
|
action: 'user.site_admin_set',
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { GrantsModule } from '../grants/grants.module';
|
||||||
|
|
||||||
import { MailModule } from '../mail/mail.module';
|
import { MailModule } from '../mail/mail.module';
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
@ -10,17 +11,19 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthGuard } from './auth.guard';
|
import { AuthGuard } from './auth.guard';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthTokensService } from './auth-tokens.service';
|
import { AuthTokensService } from './auth-tokens.service';
|
||||||
|
import { ClaimMappingService } from './claim-mapping.service';
|
||||||
import { OidcController } from './oidc.controller';
|
import { OidcController } from './oidc.controller';
|
||||||
import { OidcService } from './oidc.service';
|
import { OidcService } from './oidc.service';
|
||||||
import { ProxyIdentityService } from './proxy-identity.service';
|
import { ProxyIdentityService } from './proxy-identity.service';
|
||||||
import { SessionsModule } from './sessions.module';
|
import { SessionsModule } from './sessions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, MailModule, SessionsModule, PondsModule],
|
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule],
|
||||||
controllers: [AuthController, OidcController],
|
controllers: [AuthController, OidcController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
AuthTokensService,
|
AuthTokensService,
|
||||||
|
ClaimMappingService,
|
||||||
OidcService,
|
OidcService,
|
||||||
ProxyIdentityService,
|
ProxyIdentityService,
|
||||||
// Global default-protected: every route needs a session unless it
|
// Global default-protected: every route needs a session unless it
|
||||||
|
|||||||
272
apps/api/src/auth/claim-mapping.e2e.db.test.ts
Normal file
272
apps/api/src/auth/claim-mapping.e2e.db.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
161
apps/api/src/auth/claim-mapping.service.ts
Normal file
161
apps/api/src/auth/claim-mapping.service.ts
Normal 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';
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ import { PondsService } from '../ponds/ponds.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
import { ClaimMappingService } from './claim-mapping.service';
|
||||||
import { SessionsService } from './sessions.service';
|
import { SessionsService } from './sessions.service';
|
||||||
|
|
||||||
/** The state cookie's signed payload lives this long — ample for one
|
/** 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 users: UsersService,
|
||||||
private readonly sessions: SessionsService,
|
private readonly sessions: SessionsService,
|
||||||
private readonly ponds: PondsService,
|
private readonly ponds: PondsService,
|
||||||
|
private readonly claimMapping: ClaimMappingService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
@ -243,6 +245,10 @@ export class OidcService {
|
|||||||
if (user.status === 'DISABLED') {
|
if (user.status === 'DISABLED') {
|
||||||
throw new BadRequestException({ code: 'account_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);
|
const sessionToken = await this.sessions.create(user.id, userAgent);
|
||||||
await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
|
|||||||
@ -152,7 +152,14 @@ export class GrantsService {
|
|||||||
* pond_admin only at pond scope for a user subject, no extra admins on a
|
* 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.
|
* 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 pond = await this.requireLivePond(pondId);
|
||||||
|
|
||||||
const invalid = grantValidationError(grant, {
|
const invalid = grantValidationError(grant, {
|
||||||
@ -169,7 +176,7 @@ export class GrantsService {
|
|||||||
if (existing) throw new ConflictException({ code: 'grant_exists' });
|
if (existing) throw new ConflictException({ code: 'grant_exists' });
|
||||||
|
|
||||||
const created = await this.prisma.roleGrant.create({
|
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.accessChanged(pondId);
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
@ -185,6 +192,7 @@ export class GrantsService {
|
|||||||
scope: grant.scopeType,
|
scope: grant.scopeType,
|
||||||
scopeId: grant.scopeId,
|
scopeId: grant.scopeId,
|
||||||
effect: grant.effect,
|
effect: grant.effect,
|
||||||
|
...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return GrantsService.viewOf(created);
|
return GrantsService.viewOf(created);
|
||||||
@ -195,7 +203,12 @@ export class GrantsService {
|
|||||||
* grant is protected — deleting it would leave the pond unmanageable
|
* grant is protected — deleting it would leave the pond unmanageable
|
||||||
* (only a Site Admin could recover it).
|
* (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 } });
|
const grant = await this.prisma.roleGrant.findFirst({ where: { id: grantId, pondId } });
|
||||||
if (!grant) throw new NotFoundException();
|
if (!grant) throw new NotFoundException();
|
||||||
|
|
||||||
@ -213,7 +226,12 @@ export class GrantsService {
|
|||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
targetType: 'pond',
|
targetType: 'pond',
|
||||||
targetId: pondId,
|
targetId: pondId,
|
||||||
details: { grantId, subjectId: grant.subjectId, role: grant.role },
|
details: {
|
||||||
|
grantId,
|
||||||
|
subjectId: grant.subjectId,
|
||||||
|
role: grant.role,
|
||||||
|
...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,33 @@ export const INSTANCE_SETTINGS = {
|
|||||||
// bounded. PENDING rows — including failed-but-retryable ones — are
|
// bounded. PENDING rows — including failed-but-retryable ones — are
|
||||||
// never touched; the retry loop owns them.
|
// never touched; the retry loop owns them.
|
||||||
'mail.outboxRetentionDays': z.number().int().min(1).default(30),
|
'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
|
// Read-trail master switch (issue #225, ADR 0023). Default OFF: read
|
||||||
// logging is employee monitoring in a works council's eyes — an ordinary
|
// logging is employee monitoring in a works council's eyes — an ordinary
|
||||||
// instance must not surveil reads. The VS-NfD reference configuration
|
// instance must not surveil reads. The VS-NfD reference configuration
|
||||||
|
|||||||
@ -100,6 +100,19 @@ expect the application to trust a header or a client certificate.
|
|||||||
sessions: they authorize API access under their own switches
|
sessions: they authorize API access under their own switches
|
||||||
(`api.enabled`, `feeds.enabled`), they are not interactive sign-in.
|
(`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
|
## Consequences
|
||||||
|
|
||||||
- Bootstrapping needs a documented answer: the first-run wizard creates a
|
- Bootstrapping needs a documented answer: the first-run wizard creates a
|
||||||
|
|||||||
@ -41,6 +41,7 @@ erDiagram
|
|||||||
| `display_name` | shown at cursors, comments |
|
| `display_name` | shown at cursors, comments |
|
||||||
| `locale` | UI language (ADR 0012) |
|
| `locale` | UI language (ADR 0012) |
|
||||||
| `is_site_admin` | boolean; Site Admin is a user flag, not a grant |
|
| `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` |
|
| `status` | `active` / `disabled` / `pending_verification` |
|
||||||
| `created_at`, `last_login_at` | |
|
| `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 |
|
| `scope_id` | label id or page id when scoped, else null |
|
||||||
| `effect` | `allow` / `deny` |
|
| `effect` | `allow` / `deny` |
|
||||||
| `created_by`, `created_at` | audit |
|
| `created_by`, `created_at` | audit |
|
||||||
|
| `origin` | `manual` / `idp` (claim mapping, issue #217) |
|
||||||
|
|
||||||
Unique on (`pond_id`, `subject_type`, `subject_id`, `role`, `scope_type`,
|
Unique on (`pond_id`, `subject_type`, `subject_id`, `role`, `scope_type`,
|
||||||
`scope_id`). `pond_admin` grants are only valid with `scope_type = pond`
|
`scope_id`). `pond_admin` grants are only valid with `scope_type = pond`
|
||||||
|
|||||||
@ -35,6 +35,41 @@ A grant is `(subject, role, scope, effect)` inside one pond
|
|||||||
- **scope**: the whole pond, one label, or one page.
|
- **scope**: the whole pond, one label, or one page.
|
||||||
- **effect**: `allow` or `deny`. `deny` expresses the vision's "all pages
|
- **effect**: `allow` or `deny`. `deny` expresses the vision's "all pages
|
||||||
_except_ label X" (pond-scope allow + label-scope deny).
|
_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
|
## Resolution algorithm
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ _Meilenstein: `M27 — VS-NfD: external authentication`_
|
|||||||
Client-Zertifikat · 2 AT · #215
|
Client-Zertifikat · 2 AT · #215
|
||||||
- [x] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und
|
- [x] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und
|
||||||
Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216
|
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
|
### P1-2 Einstufung als First-Class-Metadatum · 14–18 AT
|
||||||
|
|
||||||
|
|||||||
@ -26,23 +26,24 @@ Produkt-Beleg).
|
|||||||
Nach jeder Änderung an Instanz-Settings die api neu starten — der
|
Nach jeder Änderung an Instanz-Settings die api neu starten — der
|
||||||
Settings-Cache ist in-process (operations.md).
|
Settings-Cache ist in-process (operations.md).
|
||||||
|
|
||||||
| Setting | Referenzwert | Default | Warum |
|
| Setting | Referenzwert | Default | Warum |
|
||||||
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
|
| `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). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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.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. |
|
| `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.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). |
|
| `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). |
|
| `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). |
|
| `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.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.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. |
|
| `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. |
|
| `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)
|
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user