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
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.
335 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|