From 351772604b599640c8106fdadfea3d93c0fd5b73 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 15 Aug 2026 07:34:56 +0200 Subject: [PATCH 1/3] fix(positions): add the missing PositionActions SignalR hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pinia store has subscribed with enableSignalR: true since MG-FT-01, but no matching hub ever existed. Every admin session therefore logged "Method 'PositionActions.Subscribe' not found!" on connect (and again on every reconnect), and the Positions grid never updated live — a create in one tab stayed invisible in another until a manual reload. The hub mirrors ServiceAccountHub: realm-scoped stream of the DataEventDispatcher notifications with subject "Position", gated by position:read. It additionally returns an empty stream while the PositionTerminals flag is off, matching the defense-in-depth the REST surface already applies. Pre-existing since MG-FT-01; the rename in #198 only changed the name in the error message from FunctionActions to PositionActions. Co-Authored-By: Claude Fable 5 --- .../Features/Positions/PositionHub.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/dotnet/Modgud.Api/Features/Positions/PositionHub.cs diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionHub.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionHub.cs new file mode 100644 index 00000000..c425e03f --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Positions/PositionHub.cs @@ -0,0 +1,42 @@ +using System.Reactive.Linq; +using BuildingBlocks.EventDispatcher; +using Cocoar.SignalARRR.Common.Attributes; +using Cocoar.SignalARRR.Server; +using Microsoft.AspNetCore.SignalR; +using Modgud.Api.Realtime; + +namespace Modgud.Api.Features.Positions; + +/// +/// Per-entity SignalR pipe for the admin Positions grid — the counterpart of +/// ServiceAccountHub. The endpoint layer pushes Created/Updated/Deleted +/// through with subject "Position"; this hub +/// forwards them to the connected admin SPA clients. +/// +/// Missing since MG-FT-01: the Pinia store has always subscribed with +/// enableSignalR: true, so every admin session logged +/// "Method 'PositionActions.Subscribe' not found!" and the grid never updated +/// live. +/// +[MessageName("PositionActions")] +public class PositionHub(DataEventDispatcher eventDispatcher, AppSettings settings) + : ServerMethods +{ + public IObservable Subscribe() + { + // Defense in depth like the REST surface: while the feature is dark + // there is nothing to stream (and nothing dispatches "Position" either). + if (!settings.Features.PositionTerminals) return Observable.Empty(); + + // Scope to this connection's realm (resolved at connect by + // RealmMiddleware). Untagged events never match → no cross-realm leak. + var http = Context.GetHttpContext(); + var realm = HubAuthorization.CallerRealm(http); + + var source = eventDispatcher.Notifications + .Where(ev => ev.Subject == "Position" && ev.Tenant == realm); + + // Per-method permission gate, matching the REST list endpoint. + return HubAuthorization.AuthorizedRealmStream(http, realm, "position:read", source); + } +} From 86999f174f7058337f2ff8ecd10258a940064b25 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 15 Aug 2026 07:46:22 +0200 Subject: [PATCH 2/3] fix(i18n): add the missing common.remove key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staged terminal-slot row in the position modal falls back to the English "Remove" because common.remove never existed — the German "Entfernen" at line 322 sits in the passkey block. Verified in the running container before and after. Co-Authored-By: Claude Fable 5 --- src/frontend-vue/public/i18n/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index eeb3ae53..9178ec8f 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -12,6 +12,7 @@ "ok": "OK", "apply": "Übernehmen", "delete": "Löschen", + "remove": "Entfernen", "open": "Öffnen", "clone": "Klonen", "close": "Schließen", From d319978224f20841a3f4f0004385e0868b877068 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 15 Aug 2026 08:11:51 +0200 Subject: [PATCH 3/3] fix(positions): deleting a position revokes its terminal slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A soft-deleted position left its slots Pending/Active and their managed OAuth clients fully live (IsDeleted=false) — orphans whose LinkedPositionPrincipalId pointed at a principal that no longer existed. The per-slot revoke path always cleaned up correctly; only the position-level delete never cascaded. The delete now appends TerminalEnrollmentRevoked per slot and stages the client deletion through StageDeleteTerminalClientAsync into the same unit of work as the delete event. After the commit each slot's device is cut off immediately (RevokeTokensByApplicationIdAsync — the clients hold reference tokens for exactly this) and consumers get a PositionTerminalStatusChanged with status Revoked. Found by clicking through the running container, not by a test. Co-Authored-By: Claude Fable 5 --- .../Positions/PositionCrudTests.cs | 41 +++++++++++++++++++ .../Features/Positions/PositionsEndpoints.cs | 31 ++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs index 8ada0350..ca8385df 100644 --- a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs @@ -269,6 +269,47 @@ public async Task Create_sets_up_staged_terminal_slots_in_the_same_save() } } + /// + /// §15.4 — deleting a position takes its terminal slots with it. Without the + /// cascade a soft-deleted position left its slots Pending/Active and their + /// managed OAuth clients registered, pointing at a principal that no longer + /// exists (found by clicking through the running container). + /// + [Fact] + public async Task Deleting_a_position_revokes_its_slots_and_deletes_their_clients() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + + var resp = await Client.PostAsJsonAsync("/api/position", new + { + AccountName = "portier.cascade", + TerminalPolicy = new { Enabled = true }, + Terminals = new[] { new { DisplayName = "Terminal links", WebAuthnRpId = "alerthub.example.com" } }, + }, JsonOptions, ct); + Assert.True(resp.IsSuccessStatusCode, $"create failed: {await resp.Content.ReadAsStringAsync(ct)}"); + var created = (await resp.Content.ReadFromJsonAsync(JsonOptions, ct))!; + + var slot = (await Client.GetFromJsonAsync>( + $"/api/position/{created.Id}/terminals", JsonOptions, ct))!.Single(); + + var delete = await Client.DeleteAsync($"/api/position/{created.Id}", ct); + Assert.Equal(HttpStatusCode.OK, delete.StatusCode); + + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + + var enrollment = await session.LoadAsync(new ShortGuid(slot.Id).Guid, ct); + Assert.NotNull(enrollment); + Assert.Equal(TerminalEnrollmentStatus.Revoked, enrollment!.Status); + + // Same shape as the per-slot revoke: the client document stays for audit + // but is soft-deleted, so nothing can authenticate with it any more. + var client = (await session.Query() + .Where(c => c.ClientId == slot.ClientId).ToListAsync(ct)).Single(); + Assert.True(client.IsDeleted); + } + /// Plan §4.1 holds at create time too: slots need terminal use. /// The rejection is all-or-nothing — no position, no orphaned client. [Fact] diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs index 3b1bef3e..6eb9b0fc 100644 --- a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs @@ -256,9 +256,12 @@ await staffingRevoker.EndAllForPositionAsync( ShortGuid id, AppSettings settings, IDocumentSession session, + OAuthAdminService oauth, DataEventDispatcher dispatcher, IOAuthGrantRevoker revoker, IStaffingRevoker staffingRevoker, + Wolverine.IMessageBus bus, + HttpContext httpContext, CancellationToken ct) => { if (!settings.Features.PositionTerminals) return Results.NotFound(); @@ -266,6 +269,24 @@ await staffingRevoker.EndAllForPositionAsync( var fn = await session.LoadAsync(id.Guid, ct); if (fn is null || fn.IsDeleted) return Results.NotFound(); + var deleteActor = PositionGrantsEndpoints.RequireActor(httpContext); + var deletedAt = DateTimeOffset.UtcNow; + + // §15.4 — the slots go with the position. Without this a deleted + // position left its terminal slots Pending/Active and their + // managed OAuth clients registered: orphans pointing at a + // principal that no longer exists. Same steps the per-slot + // revoke takes, staged into this delete's unit of work. + var slots = await session.Query() + .Where(t => t.PositionPrincipalId == id.Guid && t.Status != TerminalEnrollmentStatus.Revoked) + .ToListAsync(ct); + foreach (var slot in slots) + { + session.Events.Append(slot.Id, new TerminalEnrollmentRevoked(slot.Id, deleteActor, deletedAt)); + if (await oauth.StageDeleteTerminalClientAsync(slot.OAuthApplicationId, ct) is { } slotError) + return Results.BadRequest(new { Error = slotError.Code, Message = slotError.Description }); + } + // Soft delete via the stream: the projection flips IsDeleted (and // IsActive) so audit / group-membership references stay resolvable. session.Events.Append(id.Guid, new PositionPrincipalDeletedEvent(id.Guid)); @@ -280,6 +301,16 @@ await staffingRevoker.EndAllForPositionAsync( await revoker.RevokeTokensBySubjectAsync(subject, ct); await revoker.RevokeAuthorizationsBySubjectAsync(subject, ct); + // Each revoked slot's device is cut off now, not at token expiry, + // and consumers hear about it (MG-FT-09 §17). + foreach (var slot in slots) + { + await revoker.RevokeTokensByApplicationIdAsync(slot.OAuthApplicationId.ToString(), ct); + dispatcher.DispatchDeletedEvent("Terminal", new ShortGuid(slot.Id).ToString(), session.TenantId); + await bus.PublishAsync(new Modgud.Domain.PositionTerminals.Contracts.V1.PositionTerminalStatusChanged( + fn.Id, slot.Id, TerminalEnrollmentStatus.Revoked, deletedAt)); + } + dispatcher.DispatchDeletedEvent("Position", new ShortGuid(fn.Id).ToString(), session.TenantId); return Results.Ok(); })