Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.
Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().
- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
gedruckter Code mehr scannen.
Zwei Fehler dabei gefunden und behoben:
- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
Erkennung laeuft jetzt ueber eine Eigenschaft.
Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import "server-only";
|
||||
|
||||
import type { Role } from "@/generated/prisma/client";
|
||||
import type { Actor } from "@/lib/auth/actor";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
/**
|
||||
* Sämtliche Berechtigungsregeln der Anwendung — an einer Stelle.
|
||||
*
|
||||
* Jeder schreibende oder lesende Zugriff geht durch `can` bzw. `require`.
|
||||
* Die Oberfläche blendet Knöpfe zwar zusätzlich aus, aber das ist nur
|
||||
* Bequemlichkeit: verlassen wird sich ausschließlich auf diese Prüfungen.
|
||||
*/
|
||||
|
||||
export type Action =
|
||||
/** Codes einer Gruppe sehen und Bilder herunterladen. */
|
||||
| "codes:read"
|
||||
/** Dynamische Codes anlegen. */
|
||||
| "codes:create"
|
||||
/** Ziel, Name oder Gestaltung ändern. */
|
||||
| "codes:update"
|
||||
/** Codes löschen. */
|
||||
| "codes:delete"
|
||||
/** Mitglieder und Rollen einer Gruppe verwalten. */
|
||||
| "group:manage"
|
||||
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
|
||||
| "instance:manage";
|
||||
|
||||
export type Scope = { groupId?: string };
|
||||
|
||||
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
|
||||
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
ADMIN: "Administrator",
|
||||
EDITOR: "Bearbeiter",
|
||||
VIEWER: "Betrachter",
|
||||
};
|
||||
|
||||
export const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||
ADMIN: "Darf alles in der Gruppe, einschließlich Mitglieder und Rollen.",
|
||||
EDITOR: "Darf Codes anlegen, ändern und löschen.",
|
||||
VIEWER: "Darf Codes ansehen und herunterladen, aber nichts ändern.",
|
||||
};
|
||||
|
||||
/** Rolle des Actors in einer bestimmten Gruppe, falls vorhanden. */
|
||||
export function roleInGroup(actor: Actor, groupId: string): Role | null {
|
||||
if (actor.kind !== "user") return null;
|
||||
return (
|
||||
actor.user.memberships.find((m) => m.groupId === groupId)?.role ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Alle Gruppen, die der Actor sehen darf. `null` heißt: alle. */
|
||||
export function visibleGroupIds(actor: Actor): string[] | null {
|
||||
if (actor.kind === "system") return null;
|
||||
if (actor.kind === "anonymous") return [];
|
||||
if (actor.user.isInstanceAdmin) return null;
|
||||
return actor.user.memberships.map((m) => m.groupId);
|
||||
}
|
||||
|
||||
/** Gruppen, in denen der Actor Codes anlegen darf. */
|
||||
export function writableGroupIds(actor: Actor): string[] | null {
|
||||
if (actor.kind === "system") return null;
|
||||
if (actor.kind === "anonymous") return [];
|
||||
if (actor.user.isInstanceAdmin) return null;
|
||||
return actor.user.memberships
|
||||
.filter((m) => ROLE_RANK[m.role] >= ROLE_RANK.EDITOR)
|
||||
.map((m) => m.groupId);
|
||||
}
|
||||
|
||||
export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
|
||||
if (actor.kind === "system") return true;
|
||||
if (actor.kind === "anonymous") return false;
|
||||
|
||||
// Der Instanz-Administrator darf alles — er verwaltet die Installation.
|
||||
if (actor.user.isInstanceAdmin) return true;
|
||||
|
||||
if (action === "instance:manage") return false;
|
||||
|
||||
// Alle übrigen Aktionen beziehen sich auf eine Gruppe.
|
||||
if (!scope.groupId) return false;
|
||||
const role = roleInGroup(actor, scope.groupId);
|
||||
if (!role) return false;
|
||||
|
||||
switch (action) {
|
||||
case "codes:read":
|
||||
return true; // jede Rolle in der Gruppe darf lesen
|
||||
case "codes:create":
|
||||
case "codes:update":
|
||||
case "codes:delete":
|
||||
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
|
||||
case "group:manage":
|
||||
return role === "ADMIN";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const DENIED_MESSAGES: Record<Action, string> = {
|
||||
"codes:read": "Du hast keinen Zugriff auf die Codes dieser Gruppe.",
|
||||
"codes:create":
|
||||
"Zum Anlegen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"codes:update":
|
||||
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"codes:delete":
|
||||
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"group:manage":
|
||||
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
|
||||
"instance:manage":
|
||||
"Diesen Bereich darf nur ein Administrator der Installation verwalten.",
|
||||
};
|
||||
|
||||
/** Wie `can`, wirft aber mit passendem Status und deutscher Meldung. */
|
||||
export function requirePermission(
|
||||
actor: Actor,
|
||||
action: Action,
|
||||
scope: Scope = {},
|
||||
): void {
|
||||
if (can(actor, action, scope)) return;
|
||||
|
||||
if (actor.kind === "anonymous") {
|
||||
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||
}
|
||||
throw new QrRequestError(DENIED_MESSAGES[action], 403);
|
||||
}
|
||||
Reference in New Issue
Block a user