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
@@ -91,7 +91,7 @@ public static class AuthEndpoints
return Results.Ok(new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
benutzer.Language, benutzer.AvatarImage));
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
}).RequireAuthorization();
MapCodeFlow(app);
@@ -200,7 +200,7 @@ public static class AuthEndpoints
return new LoginResponse(token, new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
benutzer.Language, benutzer.AvatarImage));
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
}
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
+13 -1
View File
@@ -91,7 +91,16 @@ public sealed record AuthenticatedUser(
/// auf jeder Seite; es nach der Anmeldung noch einmal holen zu müssen
/// hiesse, dass der Buchstabenkreis kurz aufblitzt und dann wechselt.
/// </remarks>
string? AvatarImage = null);
string? AvatarImage = null,
/// <summary>Der Besitzer — ihm nimmt niemand das Adminrecht.</summary>
///
/// <remarks>
/// Hinten angehängt, wie jedes neue Feld in dieser Datei: in der Mitte
/// eingeschoben bricht es jede Aufrufstelle, die die Werte der Reihe nach
/// übergibt — und davon gibt es hier drei.
/// </remarks>
bool IsOwner = false);
public sealed record LoginResponse(string Token, AuthenticatedUser User);
@@ -109,6 +118,9 @@ public sealed record CodeState(string State, LoginResponse? Login);
public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator);
/// <summary>Adminrecht geben oder nehmen.</summary>
public sealed record RechtRequest(bool IsAdministrator);
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
///
/// <remarks>
@@ -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();
@@ -46,7 +46,7 @@ public static class ProfilEndpoints
? Results.Unauthorized()
: Results.Ok(new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
benutzer.Language, benutzer.AvatarImage));
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
});
gruppe.MapPost("/avatar", async (
@@ -208,6 +208,10 @@ public static class SystemEndpoints
UserName = anfrage.UserName.Trim(),
PasswordHash = PasswordHasher.Hash(anfrage.Password),
IsAdministrator = true,
// **Wer einrichtet, besitzt.** Ihm nimmt niemand das
// Adminrecht — sonst gäbe es einen Zustand ohne jeden
// Administrator, und aus dem führt kein Weg zurück.
IsOwner = true,
};
db.Users.Add(administrator);
await db.SaveChangesAsync();
+18
View File
@@ -19,6 +19,24 @@ public sealed class User
public bool IsAdministrator { get; set; }
/// <summary>Dem Besitzer kann niemand das Adminrecht nehmen.</summary>
///
/// <remarks>
/// <para>
/// <b>Damit sich ein Server nicht selbst aussperren kann.</b> 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
/// diesen Zustand aus.
/// </para>
/// <para>
/// <b>Es ist das erste Konto</b>, also das, mit dem der Server
/// eingerichtet wurde. Übertragen lässt es sich — an einen anderen
/// Administrator, und nur vom Besitzer selbst.
/// </para>
/// </remarks>
public bool IsOwner { get; set; }
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
///
/// <remarks>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Besitzer : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsOwner",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: false);
// **Der Älteste erbt.** Auf einem Server, den es schon gibt, ist
// der Besitzer das Konto, mit dem eingerichtet wurde — und das
// ist das mit der kleinsten Kennung, weil die zeitlich geordnet
// vergeben werden (`Guid.CreateVersion7`).
//
// Ohne diese Zeilen hätte ein bestehender Server gar keinen
// Besitzer, und das Adminrecht liesse sich bis zum letzten
// Administrator abräumen.
migrationBuilder.Sql(
"""
UPDATE Users SET IsOwner = 1
WHERE Id = (SELECT Id FROM Users ORDER BY Id LIMIT 1);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsOwner",
table: "Users");
}
}
}
@@ -929,6 +929,9 @@ namespace Flaro.Data.Migrations
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<bool>("IsOwner")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");