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
10 changes: 7 additions & 3 deletions docs/admin/positions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ classes, wire formats, integration events) lives under
960 = 16 h). **Absolute maximum** — the hard ceiling no refresh can
extend past (default 1440 = 24 h). Access tokens stay short-lived
(10 min) independently of these.
- **Authorized users** can be staged right in the create dialog — the
position and its grants are created in one atomic save.
- **Authorized users** and **terminal slots** can be staged right in the
create dialog, on their own tabs — the position, its grants and its slots
are created in one atomic save. Nothing forces you to create the position
first and come back for the rest. (Enrolling a device stays a later step:
that is a ceremony on the device, not a setting.)

Like every principal, the position receives roles/permissions through the
normal groups & roles machinery — that is what ends up in its staffing
Expand All @@ -52,7 +55,8 @@ staffing sessions and revokes the session tokens.

## 3. Create terminal slots

**Position detail → Shared terminals.** One slot per physical device.
**Position detail → Terminals** (or the same tab while creating the
position). One slot per physical device.
Each slot atomically creates its own locked-down OAuth client (public,
no secret, DPoP mandatory, reference tokens — the generic OAuth admin
surface is read-only for it).
Expand Down
78 changes: 78 additions & 0 deletions src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
using BuildingBlocks.Helper;
using Marten;
using Modgud.Domain.PositionTerminals;
using Modgud.Domain.OAuth.Applications;
using System.Net;
using System.Net.Http.Json;
using Modgud.Api.Tests.Infrastructure;
Expand Down Expand Up @@ -217,6 +221,80 @@ public async Task Positions_are_event_sourced_one_event_per_mutation()
Assert.Contains(stream, e => e.Data is Modgud.Authorization.Events.PositionPrincipalDeletedEvent);
}

/// <summary>
/// Modal contract rule 5 — a position is creatable as a whole: staged
/// terminal slots travel in the create body and commit with the position,
/// exactly like the service account's initial credential. Each slot brings
/// its managed OAuth client along in that same unit of work.
/// </summary>
[Fact]
public async Task Create_sets_up_staged_terminal_slots_in_the_same_save()
{
var ct = TestContext.Current.CancellationToken;
SetFeatureFlag(true);

var resp = await Client.PostAsJsonAsync("/api/position", new
{
AccountName = "portier.staged",
TerminalPolicy = new { Enabled = true },
Terminals = new[]
{
new { DisplayName = "Terminal links", Location = "Tor 3", WebAuthnRpId = "alerthub.example.com" },
new { DisplayName = "Terminal rechts", Location = (string?)null, WebAuthnRpId = "alerthub.example.com" },
},
}, JsonOptions, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
Assert.True(resp.IsSuccessStatusCode, $"create failed ({(int)resp.StatusCode}): {body}");

var created = (await resp.Content.ReadFromJsonAsync<PositionPrincipalDto>(JsonOptions, ct))!;
Assert.True(created.TerminalPolicy.Enabled);

var slots = await Client.GetFromJsonAsync<List<TerminalDto>>(
$"/api/position/{created.Id}/terminals", JsonOptions, ct);
Assert.NotNull(slots);
Assert.Equal(2, slots!.Count);
Assert.All(slots, s => Assert.Equal(TerminalEnrollmentStatus.Pending, s.Status));
Assert.All(slots, s => Assert.StartsWith("portier.staged.terminal.", s.ClientId));
Assert.Equal("Tor 3", slots.Single(s => s.DisplayName == "Terminal links").Location);

// Every slot's managed client committed with it — no half-created pair.
using var scope = Factory.Services.CreateScope();
var session = scope.ServiceProvider.GetRequiredService<IQuerySession>();
foreach (var slot in slots)
{
var client = (await session.Query<OAuthApplicationState>()
.Where(c => c.ClientId == slot.ClientId).ToListAsync(ct)).Single();
Assert.Equal(new ShortGuid(created.Id).Guid, client.LinkedPositionPrincipalId);
Assert.Equal(new ShortGuid(slot.Id).Guid, client.ManagedTerminalEnrollmentId);
}
}

/// <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]
public async Task Create_rejects_staged_slots_while_terminal_use_stays_off()
{
var ct = TestContext.Current.CancellationToken;
SetFeatureFlag(true);

var resp = await Client.PostAsJsonAsync("/api/position", new
{
AccountName = "portier.noterminals",
Terminals = new[] { new { DisplayName = "Terminal links", WebAuthnRpId = "alerthub.example.com" } },
}, JsonOptions, ct);

Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
Assert.Contains("TerminalPolicyDisabled", await resp.Content.ReadAsStringAsync(ct));

var all = await Client.GetFromJsonAsync<List<PositionPrincipalDto>>("/api/position", JsonOptions, ct);
Assert.DoesNotContain(all!, p => p.AccountName == "portier.noterminals");

using var scope = Factory.Services.CreateScope();
var session = scope.ServiceProvider.GetRequiredService<IQuerySession>();
Assert.Empty(await session.Query<OAuthApplicationState>()
.Where(c => c.ClientId.StartsWith("portier.noterminals.")).ToListAsync(ct));
}

private async Task<PositionPrincipalDto> CreatePositionAsync(string accountName, CancellationToken ct)
{
var resp = await Client.PostAsJsonAsync("/api/position", new { AccountName = accountName }, JsonOptions, ct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ await bus.PublishAsync(new Modgud.Domain.PositionTerminals.Contracts.V1.Position
return terminal is null || terminal.PositionPrincipalId != positionId ? null : terminal;
}

private static async Task<TerminalDto> LoadDtoAsync(IDocumentSession session, Guid terminalId, CancellationToken ct)
internal static async Task<TerminalDto> LoadDtoAsync(IDocumentSession session, Guid terminalId, CancellationToken ct)
=> ToDto((await session.LoadAsync<TerminalEnrollment>(terminalId, ct))!);

private static TerminalDto ToDto(TerminalEnrollment t) => new()
Expand Down
43 changes: 43 additions & 0 deletions src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using BuildingBlocks.EventDispatcher;
using BuildingBlocks.Helper;
using Modgud.Application.DTOs.Positions;
using Modgud.Application.Services;
using Modgud.Authorization.AspNetCore;
using Modgud.Authorization.Events;
using Modgud.Authorization.Principals;
Expand Down Expand Up @@ -61,6 +62,7 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati
PositionCreateDto dto,
AppSettings settings,
IDocumentSession session,
OAuthAdminService oauth,
DataEventDispatcher dispatcher,
HttpContext httpContext,
CancellationToken ct) =>
Expand All @@ -77,6 +79,18 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati
var policy = ApplyPolicy(PositionTerminalPolicy.Disabled, dto.TerminalPolicy, out var policyError);
if (policyError is not null) return policyError;

// Staged terminal slots — same up-front validation as the grants
// below. Plan §4.1 still holds: slots exist only while the
// position is opted into terminal use, so the staged policy has
// to enable it in this very save.
var stagedTerminals = dto.Terminals ?? [];
if (stagedTerminals.Count > 0 && !policy.Enabled)
return Results.BadRequest(new { Error = "Terminal.TerminalPolicyDisabled",
Message = "Enable terminal use on the position before adding terminal slots." });
if (stagedTerminals.Any(t => string.IsNullOrWhiteSpace(t.DisplayName)))
return Results.BadRequest(new { Error = "Terminal.DisplayNameRequired",
Message = "A display name is required." });

// Staged grants (rule 5: the entity is creatable completely) —
// resolve and validate EVERY user before creating anything, so a
// malformed or inactive user can never leave a half-granted
Expand Down Expand Up @@ -123,10 +137,39 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati
grantId, new Modgud.Domain.PositionTerminals.PositionGrantIssued(
grantId, fn.Id, grantUserId, actor, now));
}

// ... and every staged slot with its terminal-managed client, in
// that same unit of work (mirrors the service-account initial
// credential). A rejected slot returns before SaveChanges, so
// the whole create — position, grants, slots — never happened.
var terminalIds = new List<Guid>();
foreach (var terminal in stagedTerminals)
{
var enrollmentId = Guid.NewGuid();
var applicationId = Guid.NewGuid();
var clientId = $"{fn.AccountName}.terminal.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}";

var clientError = oauth.StageCreateTerminalClient(
applicationId, clientId, $"{fn.DisplayName} — {terminal.DisplayName.Trim()}",
fn.Id, enrollmentId, terminal.WebAuthnRpId);
if (clientError is not null)
return Results.BadRequest(new { Error = clientError.Value.Code, Message = clientError.Value.Description });

session.Events.StartStream<TerminalEnrollment>(enrollmentId, new TerminalEnrollmentCreated(
enrollmentId, fn.Id, terminal.DisplayName.Trim(),
string.IsNullOrWhiteSpace(terminal.Location) ? null : terminal.Location.Trim(),
applicationId, clientId, terminal.WebAuthnRpId.Trim().ToLowerInvariant(),
actor, now));
terminalIds.Add(enrollmentId);
}

await session.SaveChangesAsync(ct);

var created = ToDto(fn);
dispatcher.DispatchCreatedEvent("Position", created, session.TenantId);
foreach (var terminalId in terminalIds)
dispatcher.DispatchCreatedEvent("Terminal",
await PositionTerminalsEndpoints.LoadDtoAsync(session, terminalId, ct), session.TenantId);
return Results.Ok(created);
})
.WithName("V2_Position_Create")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ public class PositionCreateDto
/// grant stream commit in one unit of work.
/// </summary>
public List<string>? GrantUserIds { get; set; }

/// <summary>
/// Terminal slots to set up in the same save (modal-contract rule 5 — like
/// the service account's initial credential). Requires
/// <see cref="TerminalPolicy"/> to enable terminal use. All-or-nothing: each
/// slot's OAuth client is staged into the same session as the position and
/// grant streams, so one rejected slot leaves nothing behind. Enrollment
/// stays a later step — that is a device ceremony, not a setting.
/// </summary>
public List<TerminalCreateDto>? Terminals { get; set; }
}

public class PositionUpdateDto
Expand Down
10 changes: 9 additions & 1 deletion src/frontend-vue/public/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,11 @@
"section.basics": "Basis",
"section.status": "Status",
"section.terminals": "Geteilte Terminals",
"tabs.general": "Allgemein",
"tabs.terminals": "Terminals",
"tabs.grants": "Berechtigte Benutzer",
"tabs.sessions": "Schichten",
"validation.incomplete": "Fehlende Angaben",
"terminalsEnabled": "Terminal-Nutzung",
"terminalsEnabledHint": "Standardmäßig aus. Terminal-Slots können nur angelegt und enrollt werden, solange dies aktiv ist; Personal besetzt die Position dann per Passkey-Tap.",
"sessionLifetime": "Besetzungs-Session (Minuten)",
Expand All @@ -493,8 +498,11 @@
"revokeConfirm": "Der Widerruf ist endgültig — eine spätere erneute Berechtigung erzeugt einen neuen Eintrag mit eigener Audit-Spur."
},
"positionTerminals": {
"createFirst": "Terminal-Slots lassen sich einrichten, sobald die Position angelegt ist.",
"enablePolicyFirst": "Terminal-Nutzung aktivieren und speichern, bevor Slots angelegt werden.",
"enablePolicyStaged": "Terminal-Nutzung einschalten, um Slots hinzuzufügen — sie werden gemeinsam mit der Position angelegt.",
"stagedNeedPolicy": "Terminal-Nutzung einschalten — die vorgemerkten Slots werden damit gespeichert.",
"emptyStaged": "Noch keine Slots vorgemerkt.",
"statusStaged": "Beim Speichern",
"name": "Terminal-Name",
"namePlaceholder": "Portier-Terminal links, …",
"location": "Standort",
Expand Down
8 changes: 8 additions & 0 deletions src/frontend-vue/src/models/position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export interface PositionCreateDto {
TerminalPolicy?: PositionTerminalPolicyUpdateDto
/** Users authorized in the same save (staged in create mode; all-or-nothing). */
GrantUserIds?: string[]
/** Terminal slots set up in the same save; requires TerminalPolicy.Enabled. */
Terminals?: TerminalCreateDto[]
}

export interface TerminalCreateDto {
DisplayName: string
Location?: string
WebAuthnRpId: string
}

export interface PositionUpdateDto {
Expand Down
Loading
Loading