Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,47 @@ public async Task Create_sets_up_staged_terminal_slots_in_the_same_save()
}
}

/// <summary>
/// §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).
/// </summary>
[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<PositionPrincipalDto>(JsonOptions, ct))!;

var slot = (await Client.GetFromJsonAsync<List<TerminalDto>>(
$"/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<IQuerySession>();

var enrollment = await session.LoadAsync<TerminalEnrollment>(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<OAuthApplicationState>()
.Where(c => c.ClientId == slot.ClientId).ToListAsync(ct)).Single();
Assert.True(client.IsDeleted);
}

/// <summary>Plan §4.1 holds at create time too: slots need terminal use.
/// The rejection is all-or-nothing — no position, no orphaned client.</summary>
[Fact]
Expand Down
42 changes: 42 additions & 0 deletions src/dotnet/Modgud.Api/Features/Positions/PositionHub.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Per-entity SignalR pipe for the admin Positions grid — the counterpart of
/// <c>ServiceAccountHub</c>. The endpoint layer pushes Created/Updated/Deleted
/// through <see cref="DataEventDispatcher"/> 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
/// <c>enableSignalR: true</c>, so every admin session logged
/// "Method 'PositionActions.Subscribe' not found!" and the grid never updated
/// live.
/// </summary>
[MessageName("PositionActions")]
public class PositionHub(DataEventDispatcher eventDispatcher, AppSettings settings)
: ServerMethods<UIHub>
{
public IObservable<DataEvent> 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<DataEvent>();

// 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);
}
}
31 changes: 31 additions & 0 deletions src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,16 +256,37 @@ 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();

var fn = await session.LoadAsync<PositionPrincipal>(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<TerminalEnrollment>()
.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));
Expand All @@ -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();
})
Expand Down
1 change: 1 addition & 0 deletions src/frontend-vue/public/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"ok": "OK",
"apply": "Übernehmen",
"delete": "Löschen",
"remove": "Entfernen",
"open": "Öffnen",
"clone": "Klonen",
"close": "Schließen",
Expand Down
Loading