#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")
|
||||
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")
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
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 { 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({
|
||||
|
||||
@ -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' } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ 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. |
|
||||
@ -42,6 +42,7 @@ Settings-Cache ist in-process (operations.md).
|
||||
| `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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user