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:
co-authored by
Claude Opus 5
parent
d5e21d663f
commit
a7a4419626
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user