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(
|
return Results.Ok(new AuthenticatedUser(
|
||||||
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
||||||
benutzer.Language, benutzer.AvatarImage));
|
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
MapCodeFlow(app);
|
MapCodeFlow(app);
|
||||||
@@ -200,7 +200,7 @@ public static class AuthEndpoints
|
|||||||
|
|
||||||
return new LoginResponse(token, new AuthenticatedUser(
|
return new LoginResponse(token, new AuthenticatedUser(
|
||||||
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
||||||
benutzer.Language, benutzer.AvatarImage));
|
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
|
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
|
/// auf jeder Seite; es nach der Anmeldung noch einmal holen zu müssen
|
||||||
/// hiesse, dass der Buchstabenkreis kurz aufblitzt und dann wechselt.
|
/// hiesse, dass der Buchstabenkreis kurz aufblitzt und dann wechselt.
|
||||||
/// </remarks>
|
/// </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);
|
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);
|
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>
|
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Flaro.Api.Auth;
|
using Flaro.Api.Auth;
|
||||||
using Flaro.Core.Libraries;
|
using Flaro.Core.Libraries;
|
||||||
using Flaro.Core.Library;
|
using Flaro.Core.Library;
|
||||||
@@ -9,6 +10,9 @@ namespace Flaro.Api.Endpoints;
|
|||||||
|
|
||||||
public static class ManagementEndpoints
|
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)
|
public static void MapManagement(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
|
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
@@ -16,9 +20,79 @@ public static class ManagementEndpoints
|
|||||||
benutzer.MapGet("/", async (FlaroContext db) =>
|
benutzer.MapGet("/", async (FlaroContext db) =>
|
||||||
Results.Ok(await db.Users
|
Results.Ok(await db.Users
|
||||||
.OrderBy(u => u.UserName)
|
.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()));
|
.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) =>
|
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
var name = anfrage.UserName.Trim();
|
var name = anfrage.UserName.Trim();
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ public static class ProfilEndpoints
|
|||||||
? Results.Unauthorized()
|
? Results.Unauthorized()
|
||||||
: Results.Ok(new AuthenticatedUser(
|
: Results.Ok(new AuthenticatedUser(
|
||||||
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
||||||
benutzer.Language, benutzer.AvatarImage));
|
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
|
||||||
});
|
});
|
||||||
|
|
||||||
gruppe.MapPost("/avatar", async (
|
gruppe.MapPost("/avatar", async (
|
||||||
|
|||||||
@@ -208,6 +208,10 @@ public static class SystemEndpoints
|
|||||||
UserName = anfrage.UserName.Trim(),
|
UserName = anfrage.UserName.Trim(),
|
||||||
PasswordHash = PasswordHasher.Hash(anfrage.Password),
|
PasswordHash = PasswordHasher.Hash(anfrage.Password),
|
||||||
IsAdministrator = true,
|
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);
|
db.Users.Add(administrator);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ public sealed class User
|
|||||||
|
|
||||||
public bool IsAdministrator { get; set; }
|
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>
|
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <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")
|
b.Property<bool>("IsAdministrator")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsOwner")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Flaro.Api.Endpoints;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>Adminrecht geben und nehmen — und der eine, dem es niemand nimmt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Worum es hier wirklich geht: ein Server ohne jeden Administrator.</b>
|
||||||
|
/// Sobald Administratoren einander das Recht entziehen können, gibt es diesen
|
||||||
|
/// Zustand — und aus ihm führt kein Weg zurück ausser dem an der Datenbank.
|
||||||
|
/// Jeder Test hier prüft eine der Türen, durch die man hineingeraten könnte.
|
||||||
|
/// </remarks>
|
||||||
|
public class BesitzerTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task Wer_einrichtet_ist_Besitzer()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = await server.SetUpAndSignIn();
|
||||||
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
||||||
|
|
||||||
|
var ich = await alsAdmin.GetFromJsonAsync<AuthenticatedUser>("/api/v1/users/me");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(admin.User.IsOwner, Is.True, "Schon die Anmeldung muss es sagen.");
|
||||||
|
Assert.That(ich!.IsOwner, Is.True);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Aus_einem_Benutzer_wird_ein_Administrator_und_zurueck()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = await server.SetUpAndSignIn();
|
||||||
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
||||||
|
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||||
|
|
||||||
|
var vorher = await gast.GetAsync("/api/v1/users");
|
||||||
|
|
||||||
|
(await alsAdmin.PutAsJsonAsync($"/api/v1/users/{await KennungVon(alsAdmin, "gast")}/administrator",
|
||||||
|
new RechtRequest(true))).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// **Die Rolle wird je Anfrage frisch gelesen**, nicht aus dem Token —
|
||||||
|
// der Gast braucht sich also nicht neu anzumelden.
|
||||||
|
var danach = await gast.GetAsync("/api/v1/users");
|
||||||
|
|
||||||
|
(await alsAdmin.PutAsJsonAsync($"/api/v1/users/{await KennungVon(alsAdmin, "gast")}/administrator",
|
||||||
|
new RechtRequest(false))).EnsureSuccessStatusCode();
|
||||||
|
var wiederZu = await gast.GetAsync("/api/v1/users");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(vorher.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||||
|
"Das Recht muss sofort gelten, ohne neue Anmeldung.");
|
||||||
|
Assert.That(wiederZu.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Dem_Besitzer_nimmt_niemand_das_Adminrecht()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = await server.SetUpAndSignIn();
|
||||||
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
||||||
|
|
||||||
|
var zweiter = await server.NeuerBenutzer(alsAdmin, "zweiter", "zweiterpasswortlang");
|
||||||
|
var zweiteKennung = await KennungVon(alsAdmin, "zweiter");
|
||||||
|
await alsAdmin.PutAsJsonAsync($"/api/v1/users/{zweiteKennung}/administrator",
|
||||||
|
new RechtRequest(true));
|
||||||
|
|
||||||
|
// Auch ein zweiter Administrator kommt an den Besitzer nicht heran.
|
||||||
|
var versuch = await zweiter.PutAsJsonAsync(
|
||||||
|
$"/api/v1/users/{admin.User.Id}/administrator", new RechtRequest(false));
|
||||||
|
var geloescht = await zweiter.DeleteAsync($"/api/v1/users/{admin.User.Id}");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
Assert.That(geloescht.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
});
|
||||||
|
Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("Besitzer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Das_eigene_Adminrecht_nimmt_man_sich_nicht_selbst()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = await server.SetUpAndSignIn();
|
||||||
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
||||||
|
|
||||||
|
var zweiter = await server.NeuerBenutzer(alsAdmin, "zweiter", "zweiterpasswortlang");
|
||||||
|
var zweiteKennung = await KennungVon(alsAdmin, "zweiter");
|
||||||
|
await alsAdmin.PutAsJsonAsync($"/api/v1/users/{zweiteKennung}/administrator",
|
||||||
|
new RechtRequest(true));
|
||||||
|
|
||||||
|
var selbst = await zweiter.PutAsJsonAsync(
|
||||||
|
$"/api/v1/users/{zweiteKennung}/administrator", new RechtRequest(false));
|
||||||
|
var selbstWeg = await zweiter.DeleteAsync($"/api/v1/users/{zweiteKennung}");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(selbst.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
Assert.That(selbstWeg.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Der_Besitz_geht_nur_vom_Besitzer_und_nur_an_einen_Administrator()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = await server.SetUpAndSignIn();
|
||||||
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
||||||
|
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||||
|
var gastKennung = await KennungVon(alsAdmin, "gast");
|
||||||
|
|
||||||
|
// Noch kein Administrator — also noch kein Besitzer.
|
||||||
|
var zuFrueh = await alsAdmin.PostAsync($"/api/v1/users/{gastKennung}/owner", null);
|
||||||
|
|
||||||
|
await alsAdmin.PutAsJsonAsync($"/api/v1/users/{gastKennung}/administrator",
|
||||||
|
new RechtRequest(true));
|
||||||
|
|
||||||
|
// Der Beschenkte kann sich den Besitz nicht selbst nehmen.
|
||||||
|
var selbstGenommen = await gast.PostAsync($"/api/v1/users/{gastKennung}/owner", null);
|
||||||
|
|
||||||
|
var uebergeben = await alsAdmin.PostAsync($"/api/v1/users/{gastKennung}/owner", null);
|
||||||
|
var alle = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(zuFrueh.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
Assert.That(selbstGenommen.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
Assert.That(uebergeben.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
||||||
|
Assert.That(alle!.Count(u => u.IsOwner), Is.EqualTo(1), "Genau einer, immer.");
|
||||||
|
Assert.That(alle!.Single(u => u.IsOwner).UserName, Is.EqualTo("gast"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein gelöschtes Konto nimmt mit, was an ihm hing.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Ein_geloeschtes_Konto_laesst_nichts_zurueck()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = await server.SetUpAndSignIn();
|
||||||
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
||||||
|
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||||
|
var gastKennung = await KennungVon(alsAdmin, "gast");
|
||||||
|
|
||||||
|
// Etwas, das an ihm hängt.
|
||||||
|
await gast.PostAsJsonAsync($"/api/v1/items/{Guid.CreateVersion7()}/reports",
|
||||||
|
new MeldungRequest("bild", "Egal."));
|
||||||
|
|
||||||
|
(await alsAdmin.DeleteAsync($"/api/v1/users/{gastKennung}")).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var alle = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
|
||||||
|
// Das alte Token gilt nicht mehr: die Geräte hingen am Konto.
|
||||||
|
var mitAltemToken = await gast.GetAsync("/api/v1/users/me");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(alle!.Select(u => u.UserName), Does.Not.Contain("gast"));
|
||||||
|
Assert.That(mitAltemToken.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Rechte_vergeben_darf_nur_die_Verwaltung()
|
||||||
|
{
|
||||||
|
using var server = new FlaroServer();
|
||||||
|
var admin = await server.SetUpAndSignIn();
|
||||||
|
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
||||||
|
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||||
|
var gastKennung = await KennungVon(alsAdmin, "gast");
|
||||||
|
|
||||||
|
var versuch = await gast.PutAsJsonAsync(
|
||||||
|
$"/api/v1/users/{gastKennung}/administrator", new RechtRequest(true));
|
||||||
|
|
||||||
|
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> KennungVon(HttpClient alsAdmin, string name)
|
||||||
|
{
|
||||||
|
var alle = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
|
||||||
|
return alle!.Single(u => u.UserName == name).Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
export type Rolle = {
|
export type Rolle = {
|
||||||
id: string; userName: string; isAdministrator: boolean;
|
id: string; userName: string; isAdministrator: boolean;
|
||||||
|
/** Der Besitzer — ihm nimmt niemand das Adminrecht. */
|
||||||
|
isOwner?: boolean;
|
||||||
/** Die bevorzugte Sprache — `null` heisst: die des Servers. */
|
/** Die bevorzugte Sprache — `null` heisst: die des Servers. */
|
||||||
language?: string | null;
|
language?: string | null;
|
||||||
/** Das eigene Bild, als Schlüssel in die Bildablage. `null`: Buchstabenkreis. */
|
/** Das eigene Bild, als Schlüssel in die Bildablage. `null`: Buchstabenkreis. */
|
||||||
@@ -174,6 +176,8 @@ export type Systemauskunft = {
|
|||||||
build: string;
|
build: string;
|
||||||
serverName: string | null;
|
serverName: string | null;
|
||||||
needsSetup: boolean; attributions: Hinweis[];
|
needsSetup: boolean; attributions: Hinweis[];
|
||||||
|
/** Eine der vier Stufen des Aufmachers: niedrig, mittel, hoch, sehrhoch. */
|
||||||
|
heroHeight?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- Sitzung ---------- */
|
/* ---------- Sitzung ---------- */
|
||||||
@@ -403,6 +407,12 @@ export const bildAdresse = (schluessel: string | null): string | null =>
|
|||||||
export const systemauskunft = () =>
|
export const systemauskunft = () =>
|
||||||
anfrage<Systemauskunft>('/system/info');
|
anfrage<Systemauskunft>('/system/info');
|
||||||
|
|
||||||
|
/** Setzt die Hoehe des Aufmachers — serverweit, nur Admin. */
|
||||||
|
export const aufmacherhoeheSetzen = (stufe: string) =>
|
||||||
|
anfrage<void>('/system/hero-height', {
|
||||||
|
method: 'PUT', body: JSON.stringify({ height: stufe }),
|
||||||
|
});
|
||||||
|
|
||||||
export const einrichten = (benutzer: string, passwort: string) =>
|
export const einrichten = (benutzer: string, passwort: string) =>
|
||||||
anfrage<Rolle>('/system/setup', {
|
anfrage<Rolle>('/system/setup', {
|
||||||
method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }),
|
method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }),
|
||||||
@@ -1081,6 +1091,20 @@ export const benutzerAnlegen = (name: string, passwort: string, admin: boolean)
|
|||||||
body: JSON.stringify({ userName: name, password: passwort, isAdministrator: admin }),
|
body: JSON.stringify({ userName: name, password: passwort, isAdministrator: admin }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Gibt oder nimmt das Adminrecht. Dem Besitzer nimmt es niemand. */
|
||||||
|
export const adminrechtSetzen = (id: string, admin: boolean) =>
|
||||||
|
anfrage<void>(`/users/${id}/administrator`, {
|
||||||
|
method: 'PUT', body: JSON.stringify({ isAdministrator: admin }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Gibt den Besitz weiter — nur der Besitzer, nur an einen Administrator. */
|
||||||
|
export const besitzUebergeben = (id: string) =>
|
||||||
|
anfrage<void>(`/users/${id}/owner`, { method: 'POST' });
|
||||||
|
|
||||||
|
/** Loescht ein Konto samt allem, was daran haengt. */
|
||||||
|
export const benutzerLoeschen = (id: string) =>
|
||||||
|
anfrage<void>(`/users/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
/* ---------- Einladungen ---------- */
|
/* ---------- Einladungen ---------- */
|
||||||
|
|
||||||
export type Einladung = {
|
export type Einladung = {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
benutzer, benutzerAnlegen,
|
benutzer, benutzerAnlegen, adminrechtSetzen, besitzUebergeben, benutzerLoeschen,
|
||||||
einladungAnlegen, einladungen, einladungZurueckziehen, type Einladung,
|
einladungAnlegen, einladungen, einladungZurueckziehen, sitzung,
|
||||||
|
type Einladung, type Rolle,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util';
|
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util';
|
||||||
import { dialog } from '../../dialog';
|
import { dialog, nachfragen } from '../../dialog';
|
||||||
|
|
||||||
import { abschnitt } from './teile';
|
import { abschnitt } from './teile';
|
||||||
|
|
||||||
@@ -96,6 +97,74 @@ export function benutzerbereich(): HTMLElement {
|
|||||||
|
|
||||||
void laden();
|
void laden();
|
||||||
|
|
||||||
|
/** Ein Konto mit dem, was man daran tun darf.
|
||||||
|
*
|
||||||
|
* **Der Besitzer ist ausgenommen.** 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. Darum
|
||||||
|
* trägt ein Konto diese Marke, und an ihm sind beide Knöpfe aus. */
|
||||||
|
function kontozeile(b: Rolle): HTMLElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'einstellung';
|
||||||
|
|
||||||
|
const ich = b.id === sitzung.wer?.id;
|
||||||
|
const rolle = b.isOwner ? 'Besitzer'
|
||||||
|
: b.isAdministrator ? 'Administrator' : 'Benutzer';
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'zeile';
|
||||||
|
text.style.flex = '1';
|
||||||
|
text.innerHTML = `<strong>${esc(b.userName)}${ich ? ' · das bist du' : ''}</strong>`
|
||||||
|
+ `<span class="winzig">${esc(rolle)}</span>`;
|
||||||
|
|
||||||
|
const reihe = document.createElement('div');
|
||||||
|
reihe.className = 'knopfreihe';
|
||||||
|
reihe.appendChild(text);
|
||||||
|
|
||||||
|
// **Dem Besitzer nimmt niemand etwas, und sich selbst auch nicht.** 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 (!b.isOwner && !(ich && b.isAdministrator)) {
|
||||||
|
const recht = knopf(b.isAdministrator ? 'Adminrecht nehmen' : 'Zum Administrator', 'btn');
|
||||||
|
recht.onclick = async () => {
|
||||||
|
recht.disabled = true;
|
||||||
|
try { await adminrechtSetzen(b.id, !b.isAdministrator); await laden(); }
|
||||||
|
catch (fehler) { recht.disabled = false; d.appendChild(fehlerzeile(meldung(fehler))); }
|
||||||
|
};
|
||||||
|
reihe.appendChild(recht);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weitergeben darf nur der Besitzer, und nur an einen Administrator.
|
||||||
|
if (sitzung.wer?.isOwner && !b.isOwner && b.isAdministrator) {
|
||||||
|
const uebergeben = knopf('Besitz übergeben', 'btn');
|
||||||
|
uebergeben.onclick = () => nachfragen({
|
||||||
|
titel: 'Besitz übergeben',
|
||||||
|
text: `„${b.userName}“ wird Besitzer dieses Servers. Dir nimmt dann `
|
||||||
|
+ 'niemand mehr das Adminrecht ab — aber er kann es dir nehmen, '
|
||||||
|
+ 'und zurückholen kannst du den Besitz nicht.',
|
||||||
|
bestaetigen: 'Übergeben',
|
||||||
|
tun: async () => { await besitzUebergeben(b.id); location.reload(); },
|
||||||
|
});
|
||||||
|
reihe.appendChild(uebergeben);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!b.isOwner && !ich) {
|
||||||
|
const weg = knopf('Löschen', 'btn btn-gefahr');
|
||||||
|
weg.onclick = () => nachfragen({
|
||||||
|
titel: `„${b.userName}“ löschen`,
|
||||||
|
text: 'Das Konto und alles, was daran hängt: Geräte, Sehstände, das '
|
||||||
|
+ 'Sehprotokoll, Meldungen und offene Einladungen. Das lässt sich '
|
||||||
|
+ 'nicht zurückholen.',
|
||||||
|
bestaetigen: 'Löschen',
|
||||||
|
tun: async () => { await benutzerLoeschen(b.id); await laden(); },
|
||||||
|
});
|
||||||
|
reihe.appendChild(weg);
|
||||||
|
}
|
||||||
|
|
||||||
|
d.appendChild(reihe);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
/** Der fertige Link, genau einmal.
|
/** Der fertige Link, genau einmal.
|
||||||
*
|
*
|
||||||
* **Die Adresse baut der Client.** Der Server weiss nicht, unter welchem
|
* **Die Adresse baut der Client.** Der Server weiss nicht, unter welchem
|
||||||
@@ -173,13 +242,7 @@ export function benutzerbereich(): HTMLElement {
|
|||||||
try {
|
try {
|
||||||
const alle = await benutzer();
|
const alle = await benutzer();
|
||||||
liste.innerHTML = '';
|
liste.innerHTML = '';
|
||||||
alle.forEach(b => {
|
alle.forEach(b => liste.appendChild(kontozeile(b)));
|
||||||
const d = document.createElement('div');
|
|
||||||
d.className = 'einstellung';
|
|
||||||
d.innerHTML = `<div class="zeile"><strong>${esc(b.userName)}</strong>`
|
|
||||||
+ `<span class="winzig">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
|
|
||||||
liste.appendChild(d);
|
|
||||||
});
|
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
liste.innerHTML = '';
|
liste.innerHTML = '';
|
||||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||||
|
|||||||
Reference in New Issue
Block a user