Files
Harlekin abf42c5187
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Ranggefuege: Rollen, die aufeinander aufbauen
Bisher trug jede Rolle ihre Rechte vollstaendig selbst. Bei achtzehn Rollen
und zweiundzwanzig Rechten heisst das, dass eine hoehere Rangrolle die ganze
Liste der darunterliegenden noch einmal abhaken muss - beim Anlegen
fehleranfaellig und bei jeder spaeteren Aenderung doppelt zu pflegen.

Ein Gefuege ist eine benannte, geordnete Folge von Rollen. Jede Sprosse
erbt alles von jeder Sprosse unter ihr, verwaltet wird nur noch das, was
oben dazukommt. Geerbt wird nicht nur die Rechteliste, sondern auch
Freigabestufe und Anmeldung: sonst waere eine Befoerderung unter Umstaenden
eine Herabstufung, naemlich immer dann, wenn Discord beim Befoerdern die
alte Rangrolle abnimmt.

Die Ordnung ist bewusst eine Reihenfolge und kein Zeiger je Rolle - ueber
ganzen Zahlen gibt es keinen Zyklus, den man abfangen muesste. Aufloesen
eines Gefueges laesst die Rollen stehen und nimmt nur das Geerbte weg.

Die Rechnung steht als reine Funktion in lib/auth/ladders.ts und wird an
drei Stellen benutzt: bei der Rechteaufloesung, in der Verwaltung und in
der Vorschau des Editors, die im Browser mitrechnet. Eine Vorschau, die den
Stand vor dem Verschieben zeigt, waere schlimmer als keine.

Die Rollenseite zeigt Geerbtes kursiv und gesperrt an, damit dort niemand
blind Kaestchen setzt, die laengst gesetzt sind.

Nebenbei: Aenderungen an der Rollenzuordnung landen jetzt im Protokoll, mit
Vorher und Nachher. Wer Rollen aendert, aendert die Rechte anderer - genau
dafuer ist es da.
2026-09-04 14:53:29 +02:00

335 lines
12 KiB
TypeScript

// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import { invalidateAll, resolvePermissions } from '@/lib/auth/effective'
import { withInherited, resolveAll, highestRungPerLadder } from '@/lib/auth/ladders'
import { createLadder, deleteLadder, listLadders, setLadderRoles } from '@/lib/roles/ladders'
import type { EffectivePermissions } from '@/lib/auth/permissions'
/**
* Ranggefüge: Rollen, die aufeinander aufbauen.
*
* Zwei Hälften, beide hier: die reine Rechnung, und dass sie auch dort
* ankommt, wo über Zugriff entschieden wird. Die zweite Hälfte ist die
* wichtigere - eine Vererbung, die nur in der Vorschau stimmt, wäre schlimmer
* als gar keine.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `gefuege-${Date.now()}`
function rung(
id: string,
rang: number | null,
leiter: string | null,
rechte: string[],
stufe = 0,
tuer = false,
) {
return {
discordRoleId: id,
clearanceLevel: stufe,
permissions: rechte,
grantsMembership: tuer,
ladderId: leiter,
ladderRank: rang,
}
}
describe('Die Rechnung selbst', () => {
it('nimmt alles von den Sprossen darunter', () => {
const unten = rung('u', 0, 'L', ['case.read'], 1, true)
const mitte = rung('m', 1, 'L', ['case.create'], 2)
const oben = rung('o', 2, 'L', ['case.close'], 0)
const ergebnis = withInherited(oben, [unten, mitte, oben])
expect([...ergebnis.permissions].sort()).toEqual([
'case.close',
'case.create',
'case.read',
])
// Eine Beförderung darf keine Herabstufung sein: die Stufe der oberen
// Sprosse steht auf 0, wirksam sind trotzdem 2.
expect(ergebnis.clearanceLevel).toBe(2)
expect(ergebnis.ownClearanceLevel).toBe(0)
expect(ergebnis.grantsMembership).toBe(true)
})
it('vererbt nur nach oben, nie nach unten', () => {
const unten = rung('u', 0, 'L', ['case.read'])
const oben = rung('o', 1, 'L', ['admin'], 5)
const ergebnis = withInherited(unten, [unten, oben])
expect([...ergebnis.permissions]).toEqual(['case.read'])
expect(ergebnis.clearanceLevel).toBe(0)
expect(ergebnis.inherited.size).toBe(0)
})
it('hält zwei Gefüge auseinander', () => {
const eins = rung('a', 0, 'L1', ['case.read'], 3)
const zwei = rung('b', 5, 'L2', ['admin'], 5)
const ergebnis = withInherited(zwei, [eins, zwei])
expect(ergebnis.permissions.has('case.read')).toBe(false)
expect(ergebnis.clearanceLevel).toBe(5)
})
it('lässt eine Rolle ohne Gefüge unberührt', () => {
const frei = rung('f', null, null, ['case.read'], 1)
const andere = rung('x', 0, 'L', ['admin'], 5)
const ergebnis = withInherited(frei, [frei, andere])
expect([...ergebnis.permissions]).toEqual(['case.read'])
expect(ergebnis.clearanceLevel).toBe(1)
})
it('zählt ein Recht nicht als geerbt, das die Rolle selbst trägt', () => {
// Für die Anzeige: das Kästchen muss abwählbar bleiben, sonst kann man
// seine eigene Vergabe nicht mehr zurücknehmen.
const unten = rung('u', 0, 'L', ['case.read'])
const oben = rung('o', 1, 'L', ['case.read', 'case.close'])
const ergebnis = withInherited(oben, [unten, oben])
expect([...ergebnis.inherited]).toEqual([])
expect(ergebnis.permissions.has('case.read')).toBe(true)
})
it('verwirft unbekannte Rechte, auch geerbte', () => {
// Die Spalte ist ein String[]; was dort steht, ist nicht automatisch ein
// gültiges Recht. Eine alte Zeile darf keinen Zugriff öffnen.
const unten = rung('u', 0, 'L', ['case.read', 'erfunden', '__proto__'])
const oben = rung('o', 1, 'L', [])
const ergebnis = withInherited(oben, [unten, oben])
expect([...ergebnis.permissions]).toEqual(['case.read'])
})
it('kommt mit gleichem Rang zurecht, ohne sich aufzuhängen', () => {
// Doppelte Ränge sind erlaubt, die Datenbank verbietet sie nicht. Sie erben
// dann gegenseitig - endlich, weil nicht rekursiv gerechnet wird.
const a = rung('a', 1, 'L', ['case.read'])
const b = rung('b', 1, 'L', ['case.close'])
expect([...resolveAll([a, b]).get('a')!.permissions].sort()).toEqual([
'case.close',
'case.read',
])
})
it('findet je Gefüge die höchste getragene Sprosse', () => {
const hoechste = highestRungPerLadder([
{ ladderId: 'L', ladderRank: 0 },
{ ladderId: 'L', ladderRank: 3 },
{ ladderId: 'L', ladderRank: 1 },
{ ladderId: 'M', ladderRank: 2 },
{ ladderId: null, ladderRank: null },
])
expect(hoechste.get('L')).toBe(3)
expect(hoechste.get('M')).toBe(2)
expect(hoechste.size).toBe(2)
})
})
suite('Ranggefüge wirken auf den Zugriff', () => {
const nutzer = `${marke}-nutzer`
const unten = `${marke}-rolle-unten`
const oben = `${marke}-rolle-oben`
const frei = `${marke}-rolle-frei`
let leiter = ''
const admin: EffectivePermissions = {
userId: `${marke}-admin`,
clearanceLevel: 5,
permissions: new Set(['roles.manage']),
isAdmin: true,
hasMembership: true,
}
beforeAll(async () => {
await db.user.createMany({
data: [
{ id: nutzer, username: nutzer, displayName: nutzer },
{ id: admin.userId, username: admin.userId, displayName: admin.userId },
],
})
await db.roleMapping.createMany({
data: [
{
discordRoleId: unten,
label: 'Ermittler',
clearanceLevel: 3,
permissions: ['case.read', 'entry.create'],
grantsMembership: true,
},
{
discordRoleId: oben,
label: 'Leitender Ermittler',
clearanceLevel: 0,
permissions: ['case.close'],
grantsMembership: false,
},
{
discordRoleId: frei,
label: 'Ohne Gefüge',
clearanceLevel: 1,
permissions: ['personnel.read'],
},
],
})
leiter = await createLadder(admin, { name: `${marke}-Dienstgrade` })
await setLadderRoles(admin, { id: leiter, roleIds: [unten, oben] })
})
afterAll(async () => {
await db.auditLog.deleteMany({ where: { actorId: admin.userId } })
await db.userRole.deleteMany({ where: { userId: nutzer } })
await db.roleMapping.deleteMany({ where: { discordRoleId: { in: [unten, oben, frei] } } })
await db.roleLadder.deleteMany({ where: { id: leiter } })
await db.user.deleteMany({ where: { id: { in: [nutzer, admin.userId] } } })
})
it('gibt der oberen Sprosse alles der unteren, auch ohne die untere Rolle', async () => {
// Der Fall, für den das Ganze da ist: beim Befördern nimmt Discord die
// alte Rangrolle ab. Der Nutzer trägt nur noch die obere.
await db.userRole.deleteMany({ where: { userId: nutzer } })
await db.userRole.create({ data: { userId: nutzer, discordRoleId: oben } })
invalidateAll()
const rechte = await resolvePermissions(nutzer)
expect(rechte.permissions.has('case.close')).toBe(true)
expect(rechte.permissions.has('case.read')).toBe(true)
expect(rechte.permissions.has('entry.create')).toBe(true)
// Stufe und Türsteher kommen ebenfalls von unten.
expect(rechte.clearanceLevel).toBe(3)
expect(rechte.hasMembership).toBe(true)
})
it('gibt der unteren Sprosse nichts von oben', async () => {
// Der negative Fall. Ohne ihn wäre jede Leiter eine Rechteausweitung für
// alle, die darin stehen.
await db.userRole.deleteMany({ where: { userId: nutzer } })
await db.userRole.create({ data: { userId: nutzer, discordRoleId: unten } })
invalidateAll()
const rechte = await resolvePermissions(nutzer)
expect(rechte.permissions.has('case.read')).toBe(true)
expect(rechte.permissions.has('case.close')).toBe(false)
})
it('lässt eine Rolle ausserhalb jedes Gefüges unberührt', async () => {
await db.userRole.deleteMany({ where: { userId: nutzer } })
await db.userRole.create({ data: { userId: nutzer, discordRoleId: frei } })
invalidateAll()
const rechte = await resolvePermissions(nutzer)
expect([...rechte.permissions]).toEqual(['personnel.read'])
expect(rechte.clearanceLevel).toBe(1)
expect(rechte.hasMembership).toBe(false)
})
it('nimmt das Geerbte wieder weg, sobald die Reihenfolge sich dreht', async () => {
await db.userRole.deleteMany({ where: { userId: nutzer } })
await db.userRole.create({ data: { userId: nutzer, discordRoleId: oben } })
invalidateAll()
expect((await resolvePermissions(nutzer)).permissions.has('case.read')).toBe(true)
// Umdrehen: die vorher obere Sprosse steht jetzt unten.
await setLadderRoles(admin, { id: leiter, roleIds: [oben, unten] })
invalidateAll()
const rechte = await resolvePermissions(nutzer)
expect(rechte.permissions.has('case.read')).toBe(false)
expect(rechte.clearanceLevel).toBe(0)
await setLadderRoles(admin, { id: leiter, roleIds: [unten, oben] })
invalidateAll()
})
it('nimmt das Geerbte weg, wenn das Gefüge aufgelöst wird', async () => {
const eigene = await createLadder(admin, { name: `${marke}-kurz` })
await setLadderRoles(admin, { id: eigene, roleIds: [unten, oben] })
await db.userRole.deleteMany({ where: { userId: nutzer } })
await db.userRole.create({ data: { userId: nutzer, discordRoleId: oben } })
invalidateAll()
expect((await resolvePermissions(nutzer)).permissions.has('case.read')).toBe(true)
await deleteLadder(admin, eigene)
invalidateAll()
const rechte = await resolvePermissions(nutzer)
expect(rechte.permissions.has('case.read')).toBe(false)
// Die eigenen Rechte bleiben, nur das Geerbte fällt weg.
expect(rechte.permissions.has('case.close')).toBe(true)
// Die Rollen selbst gibt es noch.
expect(await db.roleMapping.count({ where: { discordRoleId: { in: [unten, oben] } } })).toBe(2)
await setLadderRoles(admin, { id: leiter, roleIds: [unten, oben] })
})
it('lässt eine Rolle in höchstens einem Gefüge stehen', async () => {
const zweites = await createLadder(admin, { name: `${marke}-zweitens` })
await setLadderRoles(admin, { id: zweites, roleIds: [oben] })
const rolle = await db.roleMapping.findUniqueOrThrow({ where: { discordRoleId: oben } })
expect(rolle.ladderId).toBe(zweites)
const alt = await listLadders(admin)
expect(alt.find((l) => l.id === leiter)?.roles.map((r) => r.discordRoleId)).toEqual([unten])
await deleteLadder(admin, zweites)
await setLadderRoles(admin, { id: leiter, roleIds: [unten, oben] })
})
it('weist jemanden ohne roles.manage ab', async () => {
const ohne: EffectivePermissions = {
userId: nutzer,
clearanceLevel: 5,
permissions: new Set(['case.read']),
isAdmin: false,
hasMembership: true,
}
await expect(createLadder(ohne, { name: `${marke}-verboten` })).rejects.toThrow()
await expect(setLadderRoles(ohne, { id: leiter, roleIds: [] })).rejects.toThrow()
await expect(deleteLadder(ohne, leiter)).rejects.toThrow()
await expect(listLadders(ohne)).rejects.toThrow()
expect(await db.roleLadder.count({ where: { name: `${marke}-verboten` } })).toBe(0)
})
it('nimmt keinen zweiten Namen doppelt und keine erfundene Rolle', async () => {
await expect(createLadder(admin, { name: `${marke}-Dienstgrade` })).rejects.toThrow()
await expect(createLadder(admin, { name: ' ' })).rejects.toThrow()
await expect(
setLadderRoles(admin, { id: leiter, roleIds: [unten, 'gibt-es-nicht'] }),
).rejects.toThrow()
// Nach dem abgewiesenen Versuch steht die Leiter unverändert da.
const stand = await listLadders(admin)
expect(stand.find((l) => l.id === leiter)?.roles.map((r) => r.discordRoleId)).toEqual([
unten,
oben,
])
})
it('schreibt jede Änderung ins Protokoll', async () => {
const eintraege = await db.auditLog.findMany({
where: { actorId: admin.userId, action: 'roles.update', targetType: 'RoleLadder' },
})
expect(eintraege.length).toBeGreaterThan(0)
})
it('zeigt in der Verwaltung dasselbe, was der Zugriff bekommt', async () => {
const stand = await listLadders(admin)
const dieses = stand.find((l) => l.id === leiter)
const obere = dieses?.roles.find((r) => r.discordRoleId === oben)
expect(obere?.inheritedPermissions).toEqual(['case.read', 'entry.create'])
expect(obere?.effectiveClearanceLevel).toBe(3)
expect(obere?.ownClearanceLevel).toBe(0)
expect(obere?.effectiveGrantsMembership).toBe(true)
})
})