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/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); + } +} 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(); }) 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",