Adminrecht geben und nehmen — und einen, dem es niemand nimmt

Ein Konto liess sich anlegen und danach nie wieder ändern: kein Weg zum
Administrator, keiner zurück, keiner zum Löschen. Mit Einladungslinks
füllte sich der Server also in genau eine Richtung.

**Warum es einen Besitzer braucht.** Sobald Administratoren einander das
Recht entziehen können, gibt es einen Zustand ohne jeden Administrator —
und aus dem führt kein Weg zurück ausser dem an der Datenbank. Ein Konto,
das ausgenommen ist, schliesst ihn aus. Es ist das erste, also das, mit
dem eingerichtet wurde; die Migration trägt das auf bestehenden Servern
nach, und weil die Kennungen zeitlich geordnet vergeben werden, ist der
Älteste schlicht der mit der kleinsten.

Weitergeben lässt er sich: vom Besitzer, an einen Administrator, mit
Rückfrage. Nur an einen Administrator, sonst wäre der Server einen Klick
von einem Besitzer entfernt, der die Verwaltung gar nicht sieht.

**Drei Türen sind zugemauert**, und jede hat ihren Test: dem Besitzer das
Recht nehmen, den Besitzer löschen, sich selbst das eigene Recht nehmen.
Das letzte ist die unauffälligste — wer sich im Eifer entmachtet, steht
vor einer Verwaltung, die er nicht mehr öffnen kann, und das Zurücknehmen
braucht genau die.

Gemessen und als Test festgehalten: ein frisch vergebenes Adminrecht gilt
**sofort**, ohne neue Anmeldung. Die Rolle wird je Anfrage aus der
Datenbank gelesen und nicht aus dem Token — ich hatte das Gegenteil
befürchtet.

Löschen nimmt mit, was am Konto hing: Geräte, Sehstände, Sehprotokoll,
Meldungen, offene Einladungen. Alles hängt mit `Cascade` daran. Zahlen
über jemanden zu führen, den es nicht mehr gibt, wäre die schlechtere
Hälfte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-07 18:31:05 +02:00
co-authored by Claude Opus 5
parent d5e21d663f
commit a7a4419626
12 changed files with 1576 additions and 15 deletions
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Flaro.Api.Auth;
using Flaro.Core.Libraries;
using Flaro.Core.Library;
@@ -9,6 +10,9 @@ namespace Flaro.Api.Endpoints;
public static class ManagementEndpoints
{
private static Guid? Kennung(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
public static void MapManagement(this IEndpointRouteBuilder app)
{
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
@@ -16,9 +20,79 @@ public static class ManagementEndpoints
benutzer.MapGet("/", async (FlaroContext db) =>
Results.Ok(await db.Users
.OrderBy(u => u.UserName)
.Select(u => new AuthenticatedUser(u.Id, u.UserName, u.IsAdministrator))
.Select(u => new AuthenticatedUser(
u.Id, u.UserName, u.IsAdministrator, u.Language, u.AvatarImage, u.IsOwner))
.ToListAsync()));
/* ---------- Rechte, Besitz, Löschen ---------- */
// **Warum es einen Besitzer gibt.** Sobald Administratoren einander
// das Recht entziehen können, gibt es einen Zustand ohne jeden
// Administrator — und aus dem führt kein Weg zurück ausser dem an der
// Datenbank. Ein Konto, das ausgenommen ist, schliesst ihn aus.
benutzer.MapPut("/{id:guid}/administrator", async (
Guid id, RechtRequest anfrage, ClaimsPrincipal wer,
FlaroContext db, CancellationToken ct) =>
{
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
if (ziel.IsOwner && !anfrage.IsAdministrator)
return Einwand.Bitte("Dem Besitzer lässt sich das Adminrecht nicht nehmen.");
// **Nicht an sich selbst.** Wer sich im Eifer das eigene Recht
// nimmt, steht vor einer Verwaltung, die er nicht mehr öffnen
// kann — und das Zurücknehmen braucht genau die.
if (Kennung(wer) == id && !anfrage.IsAdministrator)
return Einwand.Bitte("Das eigene Adminrecht kannst du dir nicht selbst nehmen.");
ziel.IsAdministrator = anfrage.IsAdministrator;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
// **Nur der Besitzer gibt den Besitz weiter**, und nur an jemanden,
// der schon Administrator ist: sonst wäre der Server einen Klick von
// einem Besitzer entfernt, der die Verwaltung gar nicht sieht.
benutzer.MapPost("/{id:guid}/owner", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
var bisher = await db.Users.FirstOrDefaultAsync(u => u.Id == ich, ct);
if (bisher is not { IsOwner: true })
return Einwand.Bitte("Nur der Besitzer kann den Besitz weitergeben.");
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
if (!ziel.IsAdministrator)
return Einwand.Bitte("Der Besitz geht nur an einen Administrator.");
bisher.IsOwner = false;
ziel.IsOwner = true;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
benutzer.MapDelete("/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (ziel is null) return Results.NoContent();
if (ziel.IsOwner) return Einwand.Bitte("Den Besitzer kann man nicht löschen.");
if (Kennung(wer) == id)
return Einwand.Bitte("Das eigene Konto kannst du hier nicht löschen.");
// Geräte, Sehstände, Sehprotokoll, Meldungen und offene
// Einladungen hängen mit `Cascade` daran — siehe `FlaroContext`.
// Ein Konto zu löschen und seine Spuren zu lassen hiesse, Zahlen
// über jemanden zu führen, den es nicht mehr gibt.
db.Users.Remove(ziel);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
{
var name = anfrage.UserName.Trim();