diff --git a/packages/db/prisma/migrations/20260904140000_ranggefuege/migration.sql b/packages/db/prisma/migrations/20260904140000_ranggefuege/migration.sql new file mode 100644 index 0000000..e6300e6 --- /dev/null +++ b/packages/db/prisma/migrations/20260904140000_ranggefuege/migration.sql @@ -0,0 +1,68 @@ +-- Ranggefüge: Rollen, die aufeinander aufbauen. +-- +-- Bisher trug jede Rolle ihre Rechte vollständig selbst. Bei achtzehn Rollen +-- und zweiundzwanzig Rechten heisst das, dass beim Anlegen einer höheren +-- Rangrolle die ganze Liste der darunterliegenden noch einmal abgehakt werden +-- muss - fehleranfällig und bei jeder späteren Änderung doppelt zu pflegen. +-- +-- Eine Leiter ist eine benannte, geordnete Folge von Rollen. Jede Sprosse erbt +-- alles von jeder Sprosse unter ihr. Verwaltet wird nur noch das, was oben +-- hinzukommt. +-- +-- Die Reihenfolge steht in `ladderRank`, 0 ist unten. Bewusst **ohne** +-- eindeutigen Index über (ladderId, ladderRank): das Umsortieren schreibt alle +-- Sprossen einer Leiter in einem Zug neu, und ein sofort geprüfter Index +-- verböte die Zwischenzustände. Doppelte Ränge sind harmlos, sie erben dann +-- gegenseitig voneinander. +-- +-- Zyklen sind ausgeschlossen, weil eine Ordnung über ganzen Zahlen keine +-- zulässt. Das ist der Grund für die Leiter statt eines Zeigers je Rolle. + +CREATE TABLE "RoleLadder" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "note" TEXT, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RoleLadder_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "RoleLadder_name_key" ON "RoleLadder"("name"); + +ALTER TABLE "RoleMapping" ADD COLUMN "ladderId" TEXT; +ALTER TABLE "RoleMapping" ADD COLUMN "ladderRank" INTEGER; + +-- Verschwindet eine Leiter, stehen ihre Rollen wieder für sich. Ihre eigenen +-- Rechte behalten sie; nur das Geerbte fällt weg. Ein Rollenverlust durch +-- Löschen einer Leiter ist damit sichtbar und keine stille Rechteerweiterung. +ALTER TABLE "RoleMapping" + ADD CONSTRAINT "RoleMapping_ladderId_fkey" + FOREIGN KEY ("ladderId") REFERENCES "RoleLadder"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "RoleMapping_ladderId_ladderRank_idx" ON "RoleMapping"("ladderId", "ladderRank"); + +-- Eine Sprosse ohne Leiter ist sinnlos, eine Leiter ohne Sprosse nicht. +ALTER TABLE "RoleMapping" + ADD CONSTRAINT "RoleMapping_ladder_vollstaendig" + CHECK (("ladderId" IS NULL) = ("ladderRank" IS NULL)); + +-- Eine Änderung an der Leiter ändert die Rechte jedes Trägers. Derselbe Kanal +-- wie bei RoleMapping selbst, damit die Web-Anwendung ihre zwischengespeicherten +-- Rechte sofort verwirft und nicht erst nach der Verfallszeit. +-- Eigene Funktion, weil `notify_role_mapping_changed` die Spalte +-- "discordRoleId" liest, die es auf dieser Tabelle nicht gibt. Der Kanal ist +-- derselbe: die Gegenstelle unterscheidet nicht, wodurch die Zuordnung +-- ungueltig wurde, sie verwirft ohnehin alles. +CREATE OR REPLACE FUNCTION notify_role_ladder_changed() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('role_mapping_changed', COALESCE(NEW."id", OLD."id")); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER role_ladder_notify + AFTER INSERT OR UPDATE OR DELETE ON "RoleLadder" + FOR EACH ROW EXECUTE FUNCTION notify_role_ladder_changed(); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 82886e4..a81c061 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -107,9 +107,35 @@ model RoleMapping { /// loeschen: die Adminseite schlaegt sie anhand des Namens neu zu. isPresent Boolean @default(true) + /// Ranggefuege: die Leiter, in der diese Rolle steht, und ihre Sprosse + /// darin. Eine Rolle erbt alles von jeder Sprosse unter ihr - siehe + /// lib/auth/ladders.ts. Ohne Leiter steht die Rolle fuer sich. + ladderId String? + ladder RoleLadder? @relation(fields: [ladderId], references: [id], onDelete: SetNull) + + /// 0 ist die unterste Sprosse. Innerhalb einer Leiter aufsteigend. + ladderRank Int? + updatedAt DateTime @updatedAt @@index([category, sortOrder]) + @@index([ladderId, ladderRank]) +} + +/// Ein Ranggefuege: mehrere Rollen in einer festen Reihenfolge, in der die +/// obere alles kann, was die untere kann. Damit haken Verwalter bei einer +/// Befoerderungsrolle nur noch das Zusaetzliche an, statt die ganze Liste +/// erneut durchzugehen. +model RoleLadder { + id String @id @default(cuid()) + name String @unique + note String? + + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + roles RoleMapping[] } enum RoleCategory { diff --git a/web/app/(app)/admin/ranggefuege/LadderEditor.tsx b/web/app/(app)/admin/ranggefuege/LadderEditor.tsx new file mode 100644 index 0000000..45e6c09 --- /dev/null +++ b/web/app/(app)/admin/ranggefuege/LadderEditor.tsx @@ -0,0 +1,252 @@ +'use client' + +import { useMemo, useState, useTransition } from 'react' +import { ladderAdminText } from '@/lib/i18n/admin' +import { resolveAll, type LadderRung } from '@/lib/auth/ladders' +import { deleteLadderAction, setLadderRolesAction } from './actions' + +export type RoleOption = { + discordRoleId: string + label: string + isPresent: boolean + ladderId: string | null + clearanceLevel: number + permissions: string[] + grantsMembership: boolean +} + +/** + * Eine Leiter bearbeiten: welche Rollen darin stehen und in welcher Ordnung. + * + * Angezeigt wird von oben nach unten, weil man ein Ranggefüge so liest - die + * höchste Rolle zuerst. Gespeichert wird umgekehrt, `ladderRank` 0 ist unten. + * Die Umkehr steht genau an einer Stelle, in `speichern`. + * + * Die Vorschau rechnet **im Browser** mit derselben Funktion wie der Server + * (lib/auth/ladders.ts). Eine Vorschau, die den Stand vor dem Verschieben + * zeigt, wäre schlimmer als keine: sie sähe richtig aus und wäre falsch. + * + * Bewusst nicht optimistisch. Eine Änderung hier wirkt sofort für alle + * Träger dieser Rollen, deshalb Bestätigung und gesperrter Knopf statt einer + * vorweggenommenen Rückmeldung. Siehe docs/09-performance-und-ux.md. + */ +export function LadderEditor({ + ladder, + order, + allRoles, + levels, +}: { + ladder: { id: string; name: string; note: string | null } + /** Von unten nach oben, so wie es in der Datenbank steht. */ + order: string[] + allRoles: RoleOption[] + levels: { level: number; name: string }[] +}) { + // Im Zustand von oben nach unten, so wie es dasteht. + const [sprossen, setSprossen] = useState(() => [...order].reverse()) + const [fehler, setFehler] = useState(null) + const [gespeichert, setGespeichert] = useState(false) + const [pending, startTransition] = useTransition() + + const nachId = useMemo( + () => new Map(allRoles.map((rolle) => [rolle.discordRoleId, rolle])), + [allRoles], + ) + + const geaendert = sprossen.join() !== [...order].reverse().join() + + /** + * Dieselbe Rechnung wie serverseitig, auf dem aktuellen Entwurf. `sprossen` + * steht von oben nach unten, `ladderRank` zählt von unten - daher der + * gedrehte Index. + */ + const aufgeloest = useMemo(() => { + const rungs: LadderRung[] = sprossen.flatMap((id, index) => { + const rolle = nachId.get(id) + if (!rolle) return [] + return [ + { + discordRoleId: rolle.discordRoleId, + clearanceLevel: rolle.clearanceLevel, + permissions: rolle.permissions, + grantsMembership: rolle.grantsMembership, + ladderId: ladder.id, + ladderRank: sprossen.length - 1 - index, + }, + ] + }) + return resolveAll(rungs) + }, [sprossen, nachId, ladder.id]) + + const verfuegbar = allRoles.filter( + (rolle) => !sprossen.includes(rolle.discordRoleId) && rolle.ladderId === null, + ) + + function verschieben(index: number, richtung: -1 | 1) { + const ziel = index + richtung + if (ziel < 0 || ziel >= sprossen.length) return + const naechste = [...sprossen] + const [herausgenommen] = naechste.splice(index, 1) + naechste.splice(ziel, 0, herausgenommen!) + setSprossen(naechste) + setGespeichert(false) + } + + function herausnehmen(id: string) { + setSprossen((aktuell) => aktuell.filter((eintrag) => eintrag !== id)) + setGespeichert(false) + } + + function aufnehmen(id: string) { + if (id === '') return + setSprossen((aktuell) => [id, ...aktuell]) + setGespeichert(false) + } + + function speichern() { + if (!confirm(ladderAdminText.confirmSave)) return + setFehler(null) + startTransition(async () => { + // Zurück in die Reihenfolge der Datenbank: unten zuerst. + const ergebnis = await setLadderRolesAction({ + id: ladder.id, + roleIds: [...sprossen].reverse(), + }) + if (ergebnis.ok) setGespeichert(true) + else setFehler(ergebnis.error) + }) + } + + function aufloesen() { + if (!confirm(ladderAdminText.confirmRemove)) return + setFehler(null) + startTransition(async () => { + const ergebnis = await deleteLadderAction(ladder.id) + if (!ergebnis.ok) setFehler(ergebnis.error) + }) + } + + return ( +
+
+

{ladder.name}

+ {ladder.note ? {ladder.note} : null} +
+ + {sprossen.length === 0 ? ( +

{ladderAdminText.emptyLadder}

+ ) : ( +
    + {sprossen.map((id, index) => { + const rolle = nachId.get(id) + if (!rolle) return null + const ergebnis = aufgeloest.get(id) + const geerbt = ergebnis ? [...ergebnis.inherited].sort() : [] + const angehoben = + ergebnis !== undefined && ergebnis.clearanceLevel > ergebnis.ownClearanceLevel + const stufe = levels.find((l) => l.level === ergebnis?.clearanceLevel) + + return ( +
  1. +
    + {sprossen.length - index} + {rolle.label} + {!rolle.isPresent ? ( + ! + ) : null} + + {index === 0 && sprossen.length > 1 ? ladderAdminText.top : null} + {index === sprossen.length - 1 ? ladderAdminText.bottom : null} + + + + + + + +
    + +

    + {geerbt.length === 0 ? ( + ladderAdminText.inheritsNothing + ) : ( + <> + {ladderAdminText.inherits}{' '} + {geerbt.join(', ')} + + )} + {stufe ? ( + <> + {' · '} + {ladderAdminText.effectiveClearance} {stufe.level} · {stufe.name} + {angehoben ? ` (${ladderAdminText.raisedBy})` : ''} + + ) : null} +

    +
  2. + ) + })} +
+ )} + +
+ + + + + + {gespeichert && !geaendert ? ( + {ladderAdminText.saved} + ) : null} + {fehler ? {fehler} : null} +
+
+ ) +} diff --git a/web/app/(app)/admin/ranggefuege/NewLadderForm.tsx b/web/app/(app)/admin/ranggefuege/NewLadderForm.tsx new file mode 100644 index 0000000..ed26af6 --- /dev/null +++ b/web/app/(app)/admin/ranggefuege/NewLadderForm.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useActionState } from 'react' +import { ladderAdminText } from '@/lib/i18n/admin' +import { createLadderAction, type ActionResult } from './actions' + +/** Ein neues Gefüge anlegen. Die Sprossen kommen danach im Editor dazu. */ +export function NewLadderForm() { + const [state, action, pending] = useActionState( + createLadderAction, + null, + ) + + return ( +
+ + + + {state && !state.ok ? ( + {state.error} + ) : null} +
+ ) +} diff --git a/web/app/(app)/admin/ranggefuege/actions.ts b/web/app/(app)/admin/ranggefuege/actions.ts new file mode 100644 index 0000000..10fc204 --- /dev/null +++ b/web/app/(app)/admin/ranggefuege/actions.ts @@ -0,0 +1,102 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { requireUser } from '@/lib/auth/current' +import { AccessError } from '@/lib/errors' +import { createLadder, deleteLadder, setLadderRoles, updateLadder } from '@/lib/roles/ladders' +import { de } from '@/lib/i18n/de' + +export type ActionResult = { ok: true } | { ok: false; error: string } + +function toMessage(error: unknown): string { + if (error instanceof AccessError) { + return de.actionErrors[error.code] ?? de.actionErrors.default! + } + console.error('[ranggefuege] unerwartet', error) + return de.actionErrors.default! +} + +/** + * Die Prüfung steht im Dienst, nicht hier und nicht in der Oberfläche: eine + * Server Action ist ein Endpunkt und direkt aufrufbar, egal was das UI zeigt. + */ +function aktualisieren(): void { + revalidatePath('/admin/ranggefuege') + revalidatePath('/admin/rollen') +} + +const createSchema = z.object({ + name: z.string().min(1).max(80), + note: z.string().max(300).optional(), +}) + +export async function createLadderAction( + _prev: ActionResult | null, + formData: FormData, +): Promise { + const user = await requireUser() + const parsed = createSchema.safeParse(Object.fromEntries(formData)) + if (!parsed.success) return { ok: false, error: de.ladderAdmin.nameInvalid } + + try { + await createLadder(user.permissions, parsed.data) + aktualisieren() + return { ok: true } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} + +const updateSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(80), + note: z.string().max(300).optional(), +}) + +export async function updateLadderAction( + _prev: ActionResult | null, + formData: FormData, +): Promise { + const user = await requireUser() + const parsed = updateSchema.safeParse(Object.fromEntries(formData)) + if (!parsed.success) return { ok: false, error: de.ladderAdmin.nameInvalid } + + try { + await updateLadder(user.permissions, parsed.data) + aktualisieren() + return { ok: true } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} + +export async function deleteLadderAction(id: string): Promise { + const user = await requireUser() + try { + await deleteLadder(user.permissions, id) + aktualisieren() + return { ok: true } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} + +const orderSchema = z.object({ + id: z.string().min(1), + roleIds: z.array(z.string().min(1)).max(30), +}) + +export async function setLadderRolesAction(input: unknown): Promise { + const user = await requireUser() + const parsed = orderSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + try { + await setLadderRoles(user.permissions, parsed.data) + aktualisieren() + return { ok: true } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} diff --git a/web/app/(app)/admin/ranggefuege/loading.tsx b/web/app/(app)/admin/ranggefuege/loading.tsx new file mode 100644 index 0000000..51da5c9 --- /dev/null +++ b/web/app/(app)/admin/ranggefuege/loading.tsx @@ -0,0 +1,21 @@ +/** Skelett, keine Blockade. Siehe docs/09-performance-und-ux.md. */ +export default function Loading() { + return ( +
+
+
+
+
+ {[0, 1].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ) +} diff --git a/web/app/(app)/admin/ranggefuege/page.tsx b/web/app/(app)/admin/ranggefuege/page.tsx new file mode 100644 index 0000000..eec6e8b --- /dev/null +++ b/web/app/(app)/admin/ranggefuege/page.tsx @@ -0,0 +1,81 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { db } from '@/lib/db' +import { requireUser } from '@/lib/auth/current' +import { listLadders } from '@/lib/roles/ladders' +import { de } from '@/lib/i18n/de' +import { LadderEditor } from './LadderEditor' +import { NewLadderForm } from './NewLadderForm' + +export const metadata: Metadata = { title: de.ladderAdmin.heading } +export const dynamic = 'force-dynamic' + +/** + * Ranggefüge verwalten. + * + * Eigene Seite und nicht ein weiterer Block auf der Rollenseite: dort steht + * jede Rolle für sich mit zweiundzwanzig Kästchen, hier steht die Ordnung + * zwischen den Rollen. Das sind zwei verschiedene Fragen, und beide brauchen + * Platz. + */ +export default async function LadderAdminPage() { + const user = await requireUser() + if (!user.permissions.isAdmin && !user.permissions.permissions.has('roles.manage')) { + notFound() + } + + const [leitern, rollen, stufen] = await Promise.all([ + listLadders(user.permissions), + db.roleMapping.findMany({ + orderBy: [{ sortOrder: 'desc' }, { label: 'asc' }], + select: { + discordRoleId: true, + label: true, + ladderId: true, + isPresent: true, + clearanceLevel: true, + permissions: true, + grantsMembership: true, + }, + }), + db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }), + ]) + + return ( +
+

{de.ladderAdmin.heading}

+

+ {de.ladderAdmin.intro} +

+

+ {de.ladderAdmin.why} +

+ + {rollen.length === 0 ? ( +

{de.ladderAdmin.noRoles}

+ ) : ( + <> +
+ +
+ + {leitern.length === 0 ? ( +

{de.ladderAdmin.empty}

+ ) : ( +
+ {leitern.map((leiter) => ( + rolle.discordRoleId)} + allRoles={rollen} + levels={stufen.map((stufe) => ({ level: stufe.level, name: stufe.name }))} + /> + ))} +
+ )} + + )} +
+ ) +} diff --git a/web/app/(app)/admin/rollen/RoleRow.tsx b/web/app/(app)/admin/rollen/RoleRow.tsx index 3631c3c..51d0ffa 100644 --- a/web/app/(app)/admin/rollen/RoleRow.tsx +++ b/web/app/(app)/admin/rollen/RoleRow.tsx @@ -13,6 +13,11 @@ export type RoleRowData = { category: string permissions: string[] isPresent: boolean + /** Rechte, die nur aus dem Ranggefüge kommen. Hier nicht abwählbar. */ + inherited: string[] + ladderName: string | null + /** Stufe einschliesslich Gefüge - kann über der eigenen liegen. */ + effectiveClearanceLevel: number } const CATEGORIES = ['MEMBERSHIP', 'RANK', 'CLEARANCE', 'UNSORTED'] as const @@ -80,6 +85,11 @@ export function RoleRow({ {roleAdminText.missingFromGuild} ) : null} + {role.ladderName ? ( + + {roleAdminText.inLadder} {role.ladderName} + + ) : null}
@@ -117,6 +127,11 @@ export function RoleRow({ ))} + {role.effectiveClearanceLevel > clearanceLevel ? ( + + {roleAdminText.clearanceRaised} {role.effectiveClearanceLevel} + + ) : null}
- {roleAdminText.permissions} + + {roleAdminText.permissions} + {role.inherited.length > 0 ? ( + {roleAdminText.inheritedNote} + ) : null} +
{PERMISSIONS.map((permission) => { const optIn = OPT_IN_PERMISSIONS.includes(permission) + // Geerbtes ist hier nicht abwählbar: das Kästchen gehört zur + // niedrigeren Sprosse. Es abzublenden wäre die schlechtere Antwort - + // dann sähe die Rolle so aus, als könnte sie weniger, als sie kann. + const geerbt = role.inherited.includes(permission) return ( ) })} diff --git a/web/app/(app)/admin/rollen/actions.ts b/web/app/(app)/admin/rollen/actions.ts index f54d2c7..a945310 100644 --- a/web/app/(app)/admin/rollen/actions.ts +++ b/web/app/(app)/admin/rollen/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache' import { z } from 'zod' import { db } from '@/lib/db' import { requirePermissionForAction } from '@/lib/auth/current' +import { writeAudit } from '@/lib/audit/write' import { PERMISSIONS } from '@/lib/auth/permissions' import { invalidateAll } from '@/lib/auth/effective' import { de } from '@/lib/i18n/de' @@ -29,7 +30,7 @@ export type SaveResult = { ok: true } | { ok: false; error: string } * eigenen Prozess sofort ab, ohne auf die Runde über die Datenbank zu warten. */ export async function saveRoleMapping(input: unknown): Promise { - await requirePermissionForAction('roles.manage') + const user = await requirePermissionForAction('roles.manage') const parsed = schema.safeParse(input) if (!parsed.success) { @@ -41,14 +42,32 @@ export async function saveRoleMapping(input: unknown): Promise { const existing = await db.roleMapping.findUnique({ where: { discordRoleId } }) if (!existing) return { ok: false, error: de.roleAdmin.roleGone } - await db.roleMapping.update({ - where: { discordRoleId }, - data: { - clearanceLevel, - grantsMembership, - category, - permissions: [...new Set(permissions)].sort(), - }, + const neue = [...new Set(permissions)].sort() + + // Wer Rollen ändert, ändert die Rechte anderer. Genau dafür gibt es das + // Protokoll (harte Regel 3) - mit dem Vorher, sonst steht dort nur, dass + // jemand etwas geändert hat, aber nicht was. + await db.$transaction(async (tx) => { + await tx.roleMapping.update({ + where: { discordRoleId }, + data: { clearanceLevel, grantsMembership, category, permissions: neue }, + }) + await writeAudit(tx, { + actorId: user.id, + action: 'roles.update', + targetType: 'RoleMapping', + targetId: discordRoleId, + clearanceAtTime: user.permissions.clearanceLevel, + meta: { + label: existing.label, + from: { + clearanceLevel: existing.clearanceLevel, + grantsMembership: existing.grantsMembership, + permissions: existing.permissions, + }, + to: { clearanceLevel, grantsMembership, permissions: neue }, + }, + }) }) invalidateAll() diff --git a/web/app/(app)/admin/rollen/page.tsx b/web/app/(app)/admin/rollen/page.tsx index 1182e2d..266c107 100644 --- a/web/app/(app)/admin/rollen/page.tsx +++ b/web/app/(app)/admin/rollen/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { db } from '@/lib/db' import { requirePermission } from '@/lib/auth/current' import { de } from '@/lib/i18n/de' +import { resolveAll, type LadderRung } from '@/lib/auth/ladders' import { RoleRow } from './RoleRow' import { ClearancePreview } from './ClearancePreview' @@ -21,11 +22,18 @@ export default async function RoleAdminPage() { // Ohne roles.manage gibt es 404, nicht 403. await requirePermission('roles.manage') - const [mappings, levels] = await Promise.all([ + const [mappings, levels, ladders] = await Promise.all([ db.roleMapping.findMany({ orderBy: [{ sortOrder: 'desc' }, { label: 'asc' }] }), db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }), + db.roleLadder.findMany({ select: { id: true, name: true } }), ]) + // Was jede Rolle **tatsächlich** gewährt, einschliesslich ihres Ranggefüges. + // Ohne das zeigte diese Seite die eigene Zeile und die Vorschau eine Stufe, + // die für niemanden gilt - siehe lib/auth/ladders.ts. + const aufgeloest = resolveAll(mappings as LadderRung[]) + const ladderName = new Map(ladders.map((leiter) => [leiter.id, leiter.name])) + return (

{de.roleAdmin.heading}

@@ -39,7 +47,7 @@ export default async function RoleAdminPage() { levels={levels} mappings={mappings.map((m) => ({ label: m.label, - clearanceLevel: m.clearanceLevel, + clearanceLevel: aufgeloest.get(m.discordRoleId)?.clearanceLevel ?? m.clearanceLevel, }))} /> @@ -63,6 +71,15 @@ export default async function RoleAdminPage() { category: role.category, permissions: role.permissions, isPresent: role.isPresent, + inherited: [ + ...(aufgeloest.get(role.discordRoleId)?.inherited ?? []), + ].sort(), + ladderName: role.ladderId + ? (ladderName.get(role.ladderId) ?? null) + : null, + effectiveClearanceLevel: + aufgeloest.get(role.discordRoleId)?.clearanceLevel ?? + role.clearanceLevel, }} levels={levels.map((l) => ({ level: l.level, name: l.name }))} /> diff --git a/web/app/(app)/admin/sections.ts b/web/app/(app)/admin/sections.ts index b079383..6bf28f9 100644 --- a/web/app/(app)/admin/sections.ts +++ b/web/app/(app)/admin/sections.ts @@ -26,6 +26,12 @@ export function adminSections(p: EffectivePermissions): AdminSection[] { const alle: (AdminSection & { sichtbar: boolean })[] = [ { key: 'roles', href: '/admin/rollen', sichtbar: darf('roles.manage'), ...text('roles') }, + { + key: 'ladders', + href: '/admin/ranggefuege', + sichtbar: darf('roles.manage'), + ...text('ladders'), + }, { key: 'departments', href: '/admin/abteilungen', diff --git a/web/lib/auth/effective.ts b/web/lib/auth/effective.ts index 81ca77b..e0b28c2 100644 --- a/web/lib/auth/effective.ts +++ b/web/lib/auth/effective.ts @@ -7,6 +7,7 @@ import { type EffectivePermissions, type Permission, } from '@/lib/auth/permissions' +import { highestRungPerLadder } from '@/lib/auth/ladders' /** Höchste Freigabestufe. */ const MAX_CLEARANCE = 5 @@ -41,6 +42,11 @@ function bootstrapAdmin(userId: string): EffectivePermissions { * Löst die Rechte eines Nutzers aus `UserRole` × `RoleMapping` auf: * höchste Stufe aller Rollen, Vereinigung aller Rechte. * + * Steht eine Rolle in einem Ranggefüge, zählt zusätzlich alles, was die + * Sprossen unter ihr gewähren. Das trägt den Fall, dass beim Befördern in + * Discord die alte Rangrolle abgenommen wird: die neue allein muss dann schon + * genügen. Siehe lib/auth/ladders.ts. + * * `hasMembership` ist eine Ergänzung gegenüber docs/03-auth-und-rechte.md und * bildet den Türsteher ab: auf dem AfiS-Discord sind auch Gäste, die keine * Akte sehen dürfen. Siehe docs/13-entscheidungen.md. @@ -73,6 +79,30 @@ export async function resolvePermissions(userId: string): Promise ({ + ladderId, + ladderRank: { lte: rang }, + })), + }, select: { clearanceLevel: true, permissions: true, grantsMembership: true }, }) @@ -80,7 +110,7 @@ export async function resolvePermissions(userId: string): Promise() - for (const mapping of mappings) { + for (const mapping of [...mappings, ...geerbt]) { if (mapping.clearanceLevel > clearanceLevel) clearanceLevel = mapping.clearanceLevel if (mapping.grantsMembership) hasMembership = true for (const value of mapping.permissions) { diff --git a/web/lib/auth/ladders.ts b/web/lib/auth/ladders.ts new file mode 100644 index 0000000..a6ffe32 --- /dev/null +++ b/web/lib/auth/ladders.ts @@ -0,0 +1,123 @@ +import { isPermission, type Permission } from '@/lib/auth/permissions' + +/** + * Ranggefüge: Rollen, die aufeinander aufbauen. + * + * Eine Leiter ist eine benannte, geordnete Folge von Rollen, in der jede + * Sprosse alles kann, was jede Sprosse unter ihr kann. Der Grund ist die + * Pflege: ohne das trägt jede höhere Rangrolle die Rechte aller niedrigeren + * noch einmal als Kopie, und jede spätere Änderung ist an mehreren Stellen + * nachzuziehen - oder eben nicht, und dann laufen sie auseinander. + * + * Geerbt wird **alles**, was eine Rolle gewährt, nicht nur die Kästchen: + * Rechte, Freigabestufe und der Türsteher. Eine höhere Sprosse darf nie + * weniger wert sein als eine niedrigere, sonst wäre eine Beförderung unter + * Umständen eine Herabstufung - und genau das passiert, wenn beim Befördern in + * Discord die alte Rangrolle abgenommen wird, was dort üblich ist. + * + * Die Vererbung ist bewusst eine **Ordnung** und kein Zeiger je Rolle: über + * ganzen Zahlen gibt es keinen Zyklus, den man abfangen müsste. + */ + +/** Das, was eine Rolle gewährt - so viel, wie für die Rechnung nötig ist. */ +export type LadderRung = { + discordRoleId: string + clearanceLevel: number + permissions: readonly string[] + grantsMembership: boolean + ladderId: string | null + ladderRank: number | null +} + +/** Dasselbe, nachdem die Sprossen darunter eingerechnet sind. */ +export type ResolvedRung = { + clearanceLevel: number + permissions: Set + grantsMembership: boolean + /** Nur das Geerbte, für die Anzeige im Adminbereich. */ + inherited: Set + /** Die Stufe der eigenen Zeile, falls die geerbte höher liegt. */ + ownClearanceLevel: number +} + +function leer(): ResolvedRung { + return { + clearanceLevel: 0, + permissions: new Set(), + grantsMembership: false, + inherited: new Set(), + ownClearanceLevel: 0, + } +} + +/** + * Was eine einzelne Rolle gewährt, einschliesslich aller Sprossen unter ihr. + * + * `alle` muss nur die Rollen enthalten, die für diese Rechnung infrage kommen - + * die Leiter selbst genügt. Rollen ohne Leiter erben nichts und werden von + * keiner Leiter berührt. + */ +export function withInherited(rolle: LadderRung, alle: readonly LadderRung[]): ResolvedRung { + const ergebnis = leer() + + ergebnis.clearanceLevel = rolle.clearanceLevel + ergebnis.ownClearanceLevel = rolle.clearanceLevel + ergebnis.grantsMembership = rolle.grantsMembership + for (const wert of rolle.permissions) { + if (isPermission(wert)) ergebnis.permissions.add(wert) + } + + if (rolle.ladderId === null || rolle.ladderRank === null) return ergebnis + + for (const andere of alle) { + if (andere.ladderId !== rolle.ladderId) continue + if (andere.ladderRank === null || andere.ladderRank > rolle.ladderRank) continue + if (andere.discordRoleId === rolle.discordRoleId) continue + + if (andere.clearanceLevel > ergebnis.clearanceLevel) { + ergebnis.clearanceLevel = andere.clearanceLevel + } + if (andere.grantsMembership) ergebnis.grantsMembership = true + for (const wert of andere.permissions) { + if (!isPermission(wert)) continue + if (!ergebnis.permissions.has(wert)) ergebnis.inherited.add(wert) + ergebnis.permissions.add(wert) + } + } + + return ergebnis +} + +/** + * Dasselbe für einen ganzen Satz Rollen auf einmal, als Tabelle nach + * Rollen-ID. Für die Adminansicht, die jede Rolle mit ihrem tatsächlichen + * Ergebnis zeigen muss - sonst steht in der Vorschau etwas anderes, als der + * Nutzer später bekommt. + */ +export function resolveAll(alle: readonly LadderRung[]): Map { + const tabelle = new Map() + for (const rolle of alle) { + tabelle.set(rolle.discordRoleId, withInherited(rolle, alle)) + } + return tabelle +} + +/** + * Die höchste Sprosse je Leiter, die jemand mit diesen Rollen trägt. + * + * Damit lässt sich das Geerbte in **einer** weiteren Abfrage nachladen, statt + * die ganze Zuordnungstabelle zu holen und in JavaScript zu filtern. + */ +export function highestRungPerLadder( + rollen: readonly Pick[], +): Map { + const hoechste = new Map() + for (const rolle of rollen) { + if (rolle.ladderId === null || rolle.ladderRank === null) continue + const bisher = hoechste.get(rolle.ladderId) + if (bisher === undefined || rolle.ladderRank > bisher) { + hoechste.set(rolle.ladderId, rolle.ladderRank) + } + } + return hoechste +} diff --git a/web/lib/i18n/admin.ts b/web/lib/i18n/admin.ts index 5d4b4e4..cd2357d 100644 --- a/web/lib/i18n/admin.ts +++ b/web/lib/i18n/admin.ts @@ -88,6 +88,7 @@ export const adminText = { intro: 'Rollen, Vorlagen, Regeln und das Protokoll der Abteilung.', sections: { roles: 'Rollen', + ladders: 'Ranggefüge', departments: 'Abteilungen', templates: 'Vorlagen', policy: 'Änderungsrichtlinie', @@ -96,6 +97,7 @@ export const adminText = { } as Record, descriptions: { roles: 'Welche Discord-Rolle welche Freigabestufe und welche Rechte trägt.', + ladders: 'Rollen, die aufeinander aufbauen. Die obere kann alles, was die untere kann.', departments: 'Die Abteilungen der Foundation. Zugeordnet wird in der Personalakte.', templates: 'Aufbau der Fallakten und Dokumente, Bausteine und Fassungen.', policy: 'Ob und wie lange Verlaufseinträge nachträglich geändert werden dürfen.', @@ -143,6 +145,10 @@ export const roleAdminText = { } as Record, optIn: 'Dieses Recht ist im Auslieferungszustand keiner Rolle zugeordnet. Vergib es bewusst.', + inLadder: 'Im Gefüge', + inheritedFrom: 'Geerbt aus dem Gefüge', + inheritedNote: 'Kursiv gesetzte Rechte kommen aus dem Ranggefüge und werden dort verwaltet.', + clearanceRaised: 'Durch das Gefüge tatsächlich Stufe', preview: { heading: 'Vorschau', intro: 'Was jemand mit dieser Stufe sieht, wenn er keine weiteren Rollen hat.', @@ -213,3 +219,45 @@ export const vitalsText = { FCP: 'unter 1,0 s', } as Record, } as const + +export const ladderAdminText = { + heading: 'Ranggefüge', + intro: + 'Rollen, die aufeinander aufbauen. Jede Rolle in einem Gefüge kann alles, was die Rollen unter ihr können - Rechte, Freigabestufe und die Anmeldung. Bei einer höheren Rangrolle hakst du deshalb nur noch das ab, was neu dazukommt.', + why: 'Das trägt auch den üblichen Fall, dass beim Befördern die alte Rangrolle in Discord abgenommen wird: die neue allein genügt dann schon.', + empty: 'Noch kein Gefüge angelegt.', + noRoles: + 'Noch keine Rollen eingelesen. Der Worker spiegelt sie, sobald er am Discord-Gateway hängt.', + create: 'Gefüge anlegen', + creating: 'Wird angelegt', + name: 'Name', + namePlaceholder: 'Dienstgrade', + note: 'Notiz', + notePlaceholder: 'Wofür dieses Gefüge steht', + save: 'Reihenfolge speichern', + saving: 'Wird gespeichert', + saved: 'Gespeichert', + rename: 'Umbenennen', + remove: 'Gefüge auflösen', + removing: 'Wird aufgelöst', + confirmRemove: + 'Gefüge auflösen? Die Rollen bleiben bestehen, verlieren aber alles, was sie nur geerbt haben. Das wirkt sofort für alle.', + confirmSave: 'Reihenfolge ändern? Das ändert sofort, was die Träger dieser Rollen dürfen.', + addRole: 'Rolle aufnehmen', + none: '— keine —', + rungs: 'Sprossen, unten die niedrigste', + bottom: 'unterste Sprosse', + top: 'oberste Sprosse', + up: 'Höher', + down: 'Tiefer', + drop: 'Herausnehmen', + emptyLadder: 'Noch keine Rolle in diesem Gefüge.', + inherits: 'erbt', + inheritsNothing: 'erbt nichts, unterste Sprosse', + ownOnly: 'eigene Rechte', + effectiveClearance: 'Stufe wirksam', + raisedBy: 'angehoben durch das Gefüge', + nameInvalid: 'Name fehlt oder ist zu lang.', + nameTaken: 'Ein Gefüge mit diesem Namen gibt es schon.', + tooLong: 'Zu viele Sprossen für ein Gefüge.', +} as const diff --git a/web/lib/i18n/common.ts b/web/lib/i18n/common.ts index 3fb4e96..a73adfa 100644 --- a/web/lib/i18n/common.ts +++ b/web/lib/i18n/common.ts @@ -33,6 +33,9 @@ export const actionErrorsText = { 'department-name-missing': 'Die Abteilung braucht einen Namen.', 'clearance-too-low': 'Deine Freigabestufe reicht dafür nicht.', 'no-permission': 'Dir fehlt das nötige Recht.', + 'ladder-name-invalid': 'Das Gefüge braucht einen Namen, höchstens 80 Zeichen.', + 'ladder-name-taken': 'Ein Gefüge mit diesem Namen gibt es schon.', + 'ladder-too-long': 'Zu viele Sprossen für ein Gefüge.', 'read-only': 'Der Datensatz ist schreibgeschützt.', closed: 'Der Vorgang ist abgeschlossen. Änderungen brauchen ein eigenes Recht.', 'reason-required': 'Begründung nötig, mindestens 10 Zeichen.', diff --git a/web/lib/i18n/de.ts b/web/lib/i18n/de.ts index 0c9ec8d..14823a6 100644 --- a/web/lib/i18n/de.ts +++ b/web/lib/i18n/de.ts @@ -50,6 +50,7 @@ import { adminText, auditText, roleAdminText, + ladderAdminText, templateAdminText, settingsText, vitalsText, @@ -63,6 +64,7 @@ export const de = { admin: adminText, audit: auditText, roleAdmin: roleAdminText, + ladderAdmin: ladderAdminText, clearance: clearanceText, case: caseText, section: sectionText, diff --git a/web/lib/roles/ladders.ts b/web/lib/roles/ladders.ts new file mode 100644 index 0000000..ba9b719 --- /dev/null +++ b/web/lib/roles/ladders.ts @@ -0,0 +1,261 @@ +import type { Prisma } from '@afis/db' +import { db } from '@/lib/db' +import { AccessError } from '@/lib/errors' +import { writeAudit } from '@/lib/audit/write' +import { invalidateAll } from '@/lib/auth/effective' +import type { EffectivePermissions } from '@/lib/auth/permissions' +import { resolveAll, type LadderRung } from '@/lib/auth/ladders' + +/** + * Verwaltung der Ranggefüge. + * + * Wer Leitern ändert, ändert die Rechte jedes Trägers - deshalb steht hinter + * jeder Schreibfunktion ein Protokolleintrag (harte Regel 3) und `roles.manage` + * als Recht. Die Rechnung selbst steht in lib/auth/ladders.ts; hier steht nur, + * wie sie in die Datenbank kommt. + */ + +/** Höchstzahl Sprossen je Leiter. Achtzehn Rollen sind der ganze Bestand. */ +export const MAX_RUNGS = 30 + +const NAME_MAX = 80 +const NOTE_MAX = 300 + +function darf(p: EffectivePermissions): void { + if (!p.isAdmin && !p.permissions.has('roles.manage')) { + throw new AccessError('no-permission') + } +} + +function pruefeName(name: string): string { + const sauber = name.trim() + if (sauber.length === 0 || sauber.length > NAME_MAX) { + throw new AccessError('ladder-name-invalid') + } + return sauber +} + +export type LadderRoleView = { + discordRoleId: string + label: string + ladderRank: number + isPresent: boolean + /** Was in der eigenen Zeile steht. */ + ownPermissions: string[] + ownClearanceLevel: number + /** Was durch die Leiter dazukommt. */ + inheritedPermissions: string[] + effectiveClearanceLevel: number + effectiveGrantsMembership: boolean +} + +export type LadderView = { + id: string + name: string + note: string | null + roles: LadderRoleView[] +} + +/** + * Alle Leitern mit ihren Sprossen, von unten nach oben. + * + * Die Rollen einer Leiter werden vollständig geladen, weil die Vererbung nur + * mit allen Sprossen zu rechnen ist - es sind je Leiter eine Handvoll. + */ +export async function listLadders(p: EffectivePermissions): Promise { + darf(p) + + const leitern = await db.roleLadder.findMany({ + orderBy: { name: 'asc' }, + include: { + roles: { + orderBy: [{ ladderRank: 'asc' }, { label: 'asc' }], + select: { + discordRoleId: true, + label: true, + clearanceLevel: true, + permissions: true, + grantsMembership: true, + ladderId: true, + ladderRank: true, + isPresent: true, + }, + }, + }, + }) + + return leitern.map((leiter) => { + const aufgeloest = resolveAll(leiter.roles as LadderRung[]) + return { + id: leiter.id, + name: leiter.name, + note: leiter.note, + roles: leiter.roles.map((rolle) => { + const ergebnis = aufgeloest.get(rolle.discordRoleId) + return { + discordRoleId: rolle.discordRoleId, + label: rolle.label, + ladderRank: rolle.ladderRank ?? 0, + isPresent: rolle.isPresent, + ownPermissions: rolle.permissions, + ownClearanceLevel: rolle.clearanceLevel, + inheritedPermissions: [...(ergebnis?.inherited ?? [])].sort(), + effectiveClearanceLevel: ergebnis?.clearanceLevel ?? rolle.clearanceLevel, + effectiveGrantsMembership: ergebnis?.grantsMembership ?? rolle.grantsMembership, + } + }), + } + }) +} + +async function protokoll( + client: Pick, + p: EffectivePermissions, + ladderId: string, + meta: Prisma.InputJsonValue, +): Promise { + await writeAudit(client, { + actorId: p.userId, + action: 'roles.update', + targetType: 'RoleLadder', + targetId: ladderId, + clearanceAtTime: p.clearanceLevel, + meta, + }) +} + +export async function createLadder( + p: EffectivePermissions, + input: { name: string; note?: string }, +): Promise { + darf(p) + const name = pruefeName(input.name) + const note = input.note?.trim().slice(0, NOTE_MAX) || null + + const vorhanden = await db.roleLadder.findUnique({ where: { name }, select: { id: true } }) + if (vorhanden) throw new AccessError('ladder-name-taken') + + const leiter = await db.$transaction(async (tx) => { + const angelegt = await tx.roleLadder.create({ + data: { name, note, createdById: p.userId }, + select: { id: true }, + }) + await protokoll(tx, p, angelegt.id, { created: name }) + return angelegt + }) + + invalidateAll() + return leiter.id +} + +export async function updateLadder( + p: EffectivePermissions, + input: { id: string; name: string; note?: string }, +): Promise { + darf(p) + const name = pruefeName(input.name) + const note = input.note?.trim().slice(0, NOTE_MAX) || null + + const vorhanden = await db.roleLadder.findUnique({ + where: { name }, + select: { id: true }, + }) + if (vorhanden && vorhanden.id !== input.id) throw new AccessError('ladder-name-taken') + + await db.$transaction(async (tx) => { + const alt = await tx.roleLadder.findUnique({ + where: { id: input.id }, + select: { name: true }, + }) + if (!alt) throw new AccessError('not-found') + + await tx.roleLadder.update({ where: { id: input.id }, data: { name, note } }) + await protokoll(tx, p, input.id, { renamedFrom: alt.name, renamedTo: name }) + }) + + invalidateAll() +} + +/** + * Leiter auflösen. Die Rollen bleiben, sie stehen danach wieder für sich. + * + * Das ist der Grund für `ON DELETE SET NULL` statt eines Verbots: eine Leiter + * loszuwerden darf nicht daran scheitern, dass Rollen darin stehen. Dass die + * Träger dabei Rechte verlieren, ist gewollt und steht im Protokoll. + */ +export async function deleteLadder(p: EffectivePermissions, id: string): Promise { + darf(p) + + await db.$transaction(async (tx) => { + const leiter = await tx.roleLadder.findUnique({ + where: { id }, + select: { name: true, roles: { select: { discordRoleId: true } } }, + }) + if (!leiter) throw new AccessError('not-found') + + await tx.roleMapping.updateMany({ + where: { ladderId: id }, + data: { ladderId: null, ladderRank: null }, + }) + await protokoll(tx, p, id, { + deleted: leiter.name, + releasedRoles: leiter.roles.map((r) => r.discordRoleId), + }) + await tx.roleLadder.delete({ where: { id } }) + }) + + invalidateAll() +} + +/** + * Die Sprossen einer Leiter neu setzen: Zugehörigkeit und Reihenfolge in einem + * Zug, von unten nach oben. + * + * In **einem** Zug, weil jeder Zwischenzustand ein anderer Rechtestand wäre. + * Rollen, die vorher in dieser Leiter standen und jetzt nicht mehr in der Liste + * sind, fallen heraus - eine Rolle steht in höchstens einer Leiter. + */ +export async function setLadderRoles( + p: EffectivePermissions, + input: { id: string; roleIds: readonly string[] }, +): Promise { + darf(p) + + const reihenfolge = [...new Set(input.roleIds)] + if (reihenfolge.length > MAX_RUNGS) throw new AccessError('ladder-too-long') + + await db.$transaction(async (tx) => { + const leiter = await tx.roleLadder.findUnique({ + where: { id: input.id }, + select: { name: true }, + }) + if (!leiter) throw new AccessError('not-found') + + if (reihenfolge.length > 0) { + const bekannt = await tx.roleMapping.findMany({ + where: { discordRoleId: { in: reihenfolge } }, + select: { discordRoleId: true }, + }) + if (bekannt.length !== reihenfolge.length) throw new AccessError('not-found') + } + + // Erst alles lösen, dann neu setzen. Sonst müsste die Reihenfolge der + // Schreibvorgänge stimmen, damit keine Rolle zwischenzeitlich in zwei + // Leitern steht. + await tx.roleMapping.updateMany({ + where: { ladderId: input.id }, + data: { ladderId: null, ladderRank: null }, + }) + + for (const [rang, discordRoleId] of reihenfolge.entries()) { + await tx.roleMapping.update({ + where: { discordRoleId }, + data: { ladderId: input.id, ladderRank: rang }, + }) + } + + await protokoll(tx, p, input.id, { ladder: leiter.name, rungs: reihenfolge }) + }) + + invalidateAll() +} diff --git a/web/tests/ranggefuege.test.ts b/web/tests/ranggefuege.test.ts new file mode 100644 index 0000000..c0cddf6 --- /dev/null +++ b/web/tests/ranggefuege.test.ts @@ -0,0 +1,334 @@ +// @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) + }) +})