From 429ac2c25dc692511510f488df4f6837db7f1e84 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 15 Aug 2026 11:37:35 +0200 Subject: [PATCH 1/4] feat(positions): create terminal clients from the client side The staffing counterpart of the client_credentials <-> ServiceAccount rule: a client carrying the staffing grant must reference an existing Position or inline-create one (never both). CreateClientAsync diverts terminal-intent requests to a dedicated branch that delegates the client build to StageCreateTerminalClient (the single producer of the fixed terminal profile), stages position / grants / enrollment stream, and commits everything in one SaveChanges. client_id follows the {position}.terminal.{suffix} convention; DCR and the generic PUT path are guarded against staffing clients. Frontend mirrors the ServiceAccount pattern in ClientDetails: staffing grant option (feature-gated), position picker or PositionDetails draft modal (new draftOnly mode), terminal slot fields, fixed-profile hints. RP-IDs are now inherited and locked once a position has slots - passkeys hang off the RP-ID, so every slot of a position shares it. Co-Authored-By: Claude Fable 5 --- docs/admin/positions.md | 23 +- docs/integrate/position-terminals.md | 6 +- .../TerminalClientFromClientSideTests.cs | 417 ++++++++++++++++++ .../Admin/OAuth/OAuthClientsEndpoints.cs | 24 +- .../Auth/OAuth/DcrRegistrationEndpoints.cs | 3 +- .../DTOs/OAuth/OAuthClientDtos.cs | 35 ++ .../Modgud.Application/Errors/OAuthErrors.cs | 36 ++ .../Services/OAuthAdminService.Terminals.cs | 218 +++++++++ .../Services/OAuthAdminService.cs | 21 + src/frontend-vue/src/models/oauth.ts | 21 + .../src/views/admin/oauth/ClientDetails.vue | 262 ++++++++++- .../views/admin/position/PositionDetails.vue | 80 +++- 12 files changed, 1114 insertions(+), 32 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs diff --git a/docs/admin/positions.md b/docs/admin/positions.md index b56cc15c..cee9337a 100644 --- a/docs/admin/positions.md +++ b/docs/admin/positions.md @@ -63,10 +63,31 @@ surface is read-only for it). - **WebAuthn RP ID** — the domain staff passkeys verify against. Use ONE RP-ID for all terminals of the consuming app, so a staff passkey works - on every terminal. + on every terminal. Once a position has a slot, further slots inherit its + RP-ID and the field locks — staff passkeys hang off the RP-ID, so only a + matching RP-ID lets the already-enrolled tokens unlock a new terminal. - The slot view shows the **`client_id`** and the slot id — hand both to whoever installs the terminal device. +### …or start from the OAuth-client side + +**Admin → OAuth Clients → Create** works too: pick the **staffing grant** +in the Flows tab and the terminal block appears — reference an existing +position or stage a **new position as a draft** (same pattern as creating a +service account inline with a `client_credentials` client). The rule +mirrors the M2M one: as a `client_credentials` client must be backed by a +service account, a staffing client must be backed by a position — referenced +or created inline, never both. Both paths meet in the same save: position +(if new), slot, and client land atomically. + +- The **`client_id` is generated** (`{position}.terminal.{suffix}`) so the + audit log reads the owning position straight off the identifier. +- The client profile is **fixed server-side** (public, secretless, DPoP + mandatory, reference tokens, exactly device_code + refresh_token + + staffing) — scopes, lifetimes and redirects from the client form do not + apply to terminal clients. +- Requires `position:write` **in addition to** `oauth-client:write`. + ## 4. Approve the enrollment The device starts its enrollment and shows a **user code** plus a diff --git a/docs/integrate/position-terminals.md b/docs/integrate/position-terminals.md index f5a6a43a..ebb147f1 100644 --- a/docs/integrate/position-terminals.md +++ b/docs/integrate/position-terminals.md @@ -89,8 +89,10 @@ terminals — key any projection by `StaffingSessionId`. ## Provisioning (what a terminal gets at install time) -A Modgud admin creates one slot per device and reads the terminal-app -configuration off the slot view: +A Modgud admin creates one slot per device — either in the position modal +or from the OAuth-client side (creating a client with the staffing grant +stages position link + slot + client in one save) — and reads the +terminal-app configuration off the slot view: | Parameter | Source | Notes | |---|---|---| diff --git a/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs new file mode 100644 index 00000000..526a12ee --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs @@ -0,0 +1,417 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using BuildingBlocks.Helper; +using Marten; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Positions; +using Modgud.Application.DTOs.User; +using Modgud.Domain.PositionTerminals; +using Modgud.Domain.OAuth.Applications; +using Modgud.Domain.OAuth.Common; +using Microsoft.Extensions.DependencyInjection; + +namespace Modgud.Api.Tests.Positions; + +/// +/// MG-FT — the client-side terminal create ("wie in Service Accounts"): the +/// generic admin client create diverts to the terminal path whenever the +/// staffing grant or a position link appears. The rule mirrors the +/// client_credentials ⇔ ServiceAccount coupling: a staffing client must +/// reference OR inline-create a Position, never both, and the fixed terminal +/// profile plus the slot land in the SAME save. +/// +[Collection(IntegrationTestCollection.Name)] +public class TerminalClientFromClientSideTests : IntegrationTestBase +{ + public TerminalClientFromClientSideTests(SharedPostgresFixture fixture) : base(fixture) { } + + private const string RpId = "alerthub.localhost"; + private const string StaffingGrant = "urn:cocoar:params:oauth:grant-type:staffing"; + + private void SetFeatureFlag(bool enabled) => + Factory.Services.GetRequiredService().Features.PositionTerminals = enabled; + + [Fact] + public async Task The_client_side_create_is_dark_while_the_feature_flag_is_off() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(false); + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant }, + NewPosition = new { AccountName = "tc-dark", TerminalPolicy = new { Enabled = true } }, + TerminalDisplayName = "Links", + WebAuthnRpId = RpId, + }, ct); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + [Fact] + public async Task A_staffing_client_with_a_linked_position_creates_the_slot_atomically() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var fn = await CreatePositionAsync("tc-linked", terminalEnabled: true, ct); + + var resp = await PostClientAsync(new + { + ClientId = "ignored-by-the-terminal-path", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant }, + LinkedPositionPrincipalId = fn, + TerminalDisplayName = "Terminal links", + TerminalLocation = "Tor 3", + WebAuthnRpId = RpId, + }, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + Assert.True(resp.IsSuccessStatusCode, $"create failed ({(int)resp.StatusCode}): {body}"); + var created = JsonSerializer.Deserialize(body); + + // ClientId follows the convention, never the caller's value. + var clientId = created.GetProperty("Client").GetProperty("ClientId").GetString()!; + Assert.StartsWith("tc-linked.terminal.", clientId); + // Nulls may be omitted from the payload entirely — assert "absent or null". + Assert.False(created.TryGetProperty("ClientSecret", out var secret) && secret.ValueKind is not JsonValueKind.Null); + Assert.False(created.TryGetProperty("CreatedPosition", out var inlinePosition) && inlinePosition.ValueKind is not JsonValueKind.Null); + var terminalId = created.GetProperty("CreatedTerminalId").GetString(); + Assert.False(string.IsNullOrEmpty(terminalId)); + + // The slot exists on the position, wired to this client. + var slots = await Client.GetFromJsonAsync>($"/api/position/{fn}/terminals", JsonOptions, ct); + var slot = Assert.Single(slots!); + Assert.Equal(terminalId, slot.Id); + Assert.Equal(clientId, slot.ClientId); + Assert.Equal("Terminal links", slot.DisplayName); + Assert.Equal("Tor 3", slot.Location); + Assert.Equal(RpId, slot.WebAuthnRpId); + Assert.Equal(TerminalEnrollmentStatus.Pending, slot.Status); + + // The fixed terminal profile — same shape the slot-side create pins. + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + var client = (await session.Query() + .Where(c => c.ClientId == clientId).ToListAsync(ct)).Single(); + Assert.Equal("public", client.ClientType); + Assert.Equal(new ShortGuid(fn).Guid, client.LinkedPositionPrincipalId); + Assert.Equal(new ShortGuid(terminalId!).Guid, client.ManagedTerminalEnrollmentId); + Assert.Null(client.LinkedServiceAccountId); + Assert.Equal(AccessTokenType.Reference.ToString(), client.Settings[OAuthApplicationSettingKeys.AccessTokenType]); + Assert.Equal(RpId, client.Settings[OAuthApplicationSettingKeys.WebAuthnRpId]); + Assert.True(ReadBoolProp(client.Properties[OAuthApplicationPropertyKeys.RequireDpop])); + Assert.False(ReadBoolProp(client.Properties[OAuthApplicationPropertyKeys.RequireClientSecret])); + Assert.Equal( + [ + "gt:refresh_token", + "gt:" + PositionGrantTypes.StaffingSession, + "gt:urn:ietf:params:oauth:grant-type:device_code", + ], client.Permissions.Where(p => p.StartsWith("gt:")).OrderBy(p => p, StringComparer.Ordinal).ToList()); + } + + [Fact] + public async Task A_staffing_client_with_an_inline_position_creates_position_slot_and_client_in_one_save() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant, "refresh_token" }, + NewPosition = new + { + AccountName = "tc-inline", + Purpose = "Pförtner Kunde XY", + TerminalPolicy = new { Enabled = true, StaffingSessionLifetimeMinutes = 480 }, + }, + TerminalDisplayName = "Empfang", + WebAuthnRpId = RpId, + }, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + Assert.True(resp.IsSuccessStatusCode, $"create failed ({(int)resp.StatusCode}): {body}"); + var created = JsonSerializer.Deserialize(body); + + var position = created.GetProperty("CreatedPosition"); + Assert.Equal(JsonValueKind.Object, position.ValueKind); + var positionId = position.GetProperty("Id").GetString()!; + Assert.Equal("tc-inline", position.GetProperty("AccountName").GetString()); + + // The position is real, terminal-enabled, and carries the slot. + var loaded = await Client.GetFromJsonAsync($"/api/position/{positionId}", JsonOptions, ct); + Assert.True(loaded!.TerminalPolicy.Enabled); + Assert.Equal(480, loaded.TerminalPolicy.StaffingSessionLifetimeMinutes); + Assert.Equal("Pförtner Kunde XY", loaded.Purpose); + + var slots = await Client.GetFromJsonAsync>($"/api/position/{positionId}/terminals", JsonOptions, ct); + var slot = Assert.Single(slots!); + Assert.Equal("Empfang", slot.DisplayName); + Assert.StartsWith("tc-inline.terminal.", slot.ClientId); + Assert.Equal(created.GetProperty("Client").GetProperty("ClientId").GetString(), slot.ClientId); + } + + [Fact] + public async Task An_inline_position_stages_grant_users_in_the_same_save() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var user = await CreateUserAsync("tcg", ct); + + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant }, + NewPosition = new + { + AccountName = "tc-grants", + TerminalPolicy = new { Enabled = true }, + GrantUserIds = new[] { user }, + }, + TerminalDisplayName = "Links", + WebAuthnRpId = RpId, + }, ct); + Assert.True(resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync(ct)); + var created = JsonSerializer.Deserialize(await resp.Content.ReadAsStringAsync(ct)); + var positionId = created.GetProperty("CreatedPosition").GetProperty("Id").GetString(); + + var grants = await Client.GetFromJsonAsync>($"/api/position/{positionId}/grants", JsonOptions, ct); + var grant = Assert.Single(grants!); + Assert.Equal(user, grant.UserId); + Assert.Equal(PositionGrantStatus.Active, grant.Status); + } + + [Fact] + public async Task A_rejected_inline_create_leaves_nothing_behind() + { + // All-or-nothing: the slot's RP-ID is missing, so position AND client + // must not exist afterwards. + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant }, + NewPosition = new { AccountName = "tc-atomic", TerminalPolicy = new { Enabled = true } }, + TerminalDisplayName = "Links", + WebAuthnRpId = "", + }, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("WebAuthn RP ID is required", await resp.Content.ReadAsStringAsync(ct)); + + // The account name is still free — the position was never committed. + var retry = await Client.PostAsJsonAsync("/api/position", + new { AccountName = "tc-atomic" }, JsonOptions, ct); + Assert.True(retry.IsSuccessStatusCode, await retry.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task Link_and_inline_position_together_are_rejected() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var fn = await CreatePositionAsync("tc-both", terminalEnabled: true, ct); + + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant }, + LinkedPositionPrincipalId = fn, + NewPosition = new { AccountName = "tc-both-b", TerminalPolicy = new { Enabled = true } }, + TerminalDisplayName = "Links", + WebAuthnRpId = RpId, + }, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("not both", await resp.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task The_staffing_grant_without_a_position_link_is_rejected() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant }, + TerminalDisplayName = "Links", + WebAuthnRpId = RpId, + }, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("must reference LinkedPositionPrincipalId", await resp.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task A_position_link_without_the_staffing_grant_is_rejected() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var fn = await CreatePositionAsync("tc-nogrant", terminalEnabled: true, ct); + + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { "urn:ietf:params:oauth:grant-type:device_code" }, + LinkedPositionPrincipalId = fn, + TerminalDisplayName = "Links", + WebAuthnRpId = RpId, + }, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("must carry the terminal grants", await resp.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task Grants_outside_the_terminal_profile_are_rejected() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var fn = await CreatePositionAsync("tc-foreign", terminalEnabled: true, ct); + + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant, "client_credentials" }, + LinkedPositionPrincipalId = fn, + TerminalDisplayName = "Links", + WebAuthnRpId = RpId, + }, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("allowed grants are exactly", await resp.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task A_position_with_terminal_use_off_is_rejected() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var fn = await CreatePositionAsync("tc-policy-off", terminalEnabled: false, ct); + + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant }, + LinkedPositionPrincipalId = fn, + TerminalDisplayName = "Links", + WebAuthnRpId = RpId, + }, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("terminal use switched off", await resp.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task The_terminal_display_name_is_required() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var fn = await CreatePositionAsync("tc-noname", terminalEnabled: true, ct); + + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant }, + LinkedPositionPrincipalId = fn, + WebAuthnRpId = RpId, + }, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("needs TerminalDisplayName", await resp.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task An_inline_position_must_not_stage_its_own_terminal_slots() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + + var resp = await PostClientAsync(new + { + ClientId = "", + ClientType = "public", + AllowedGrantTypes = new[] { StaffingGrant }, + NewPosition = new + { + AccountName = "tc-doubleslot", + TerminalPolicy = new { Enabled = true }, + Terminals = new[] { new { DisplayName = "Extra", WebAuthnRpId = RpId } }, + }, + TerminalDisplayName = "Links", + WebAuthnRpId = RpId, + }, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("cannot stage terminal slots", await resp.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task Adding_the_staffing_grant_via_put_is_rejected() + { + // The UpdateDto carries no position link, so a PUT that adds the + // staffing grant would mint a staffing client with no position — the + // same guard shape as client_credentials-via-PUT. + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + + var createResp = await PostClientAsync(new + { + ClientId = "tc-put-guard", + ClientType = "public", + AllowedGrantTypes = new[] { "authorization_code" }, + RedirectUris = new[] { "https://app.localhost/cb" }, + }, ct); + Assert.True(createResp.IsSuccessStatusCode, await createResp.Content.ReadAsStringAsync(ct)); + var created = JsonSerializer.Deserialize(await createResp.Content.ReadAsStringAsync(ct)); + var id = created.GetProperty("Client").GetProperty("Id").GetString(); + + var put = await Client.PutAsJsonAsync($"/api/admin/oauth/clients/{id}", + new { AllowedGrantTypes = new[] { "authorization_code", StaffingGrant } }, JsonOptions, ct); + Assert.Equal(HttpStatusCode.BadRequest, put.StatusCode); + Assert.Contains("must reference LinkedPositionPrincipalId", await put.Content.ReadAsStringAsync(ct)); + } + + // ─── helpers ────────────────────────────────────────────────────────── + + private Task PostClientAsync(object dto, CancellationToken ct) => + Client.PostAsJsonAsync("/api/admin/oauth/clients", dto, JsonOptions, ct); + + private async Task CreatePositionAsync(string accountName, bool terminalEnabled, CancellationToken ct) + { + var resp = await Client.PostAsJsonAsync("/api/position", new + { + AccountName = accountName, + TerminalPolicy = terminalEnabled ? new { Enabled = true } : null, + }, JsonOptions, ct); + Assert.True(resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync(ct)); + return (await resp.Content.ReadFromJsonAsync(JsonOptions, ct))!.Id; + } + + private async Task CreateUserAsync(string acronym, CancellationToken ct) + { + var resp = await Client.PostAsJsonAsync("/api/user", new UserCreateDto + { + Firstname = "Terminal", + Lastname = acronym.ToUpperInvariant(), + Acronym = acronym.ToUpperInvariant(), + Email = $"{acronym}@terminal.test", + IsActive = true, + }, JsonOptions, ct); + Assert.True(resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync(ct)); + return (await resp.Content.ReadFromJsonAsync(JsonOptions, ct))!.Id!; + } + + /// Marten hands persisted boolean properties back as a boxed bool + /// or a JsonElement depending on the serializer — accept both. + private static bool ReadBoolProp(object? raw) => raw switch + { + bool b => b, + JsonElement e => e.ValueKind is JsonValueKind.True, + _ => false, + }; +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs index 50351b48..9a08dacc 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs @@ -1,5 +1,7 @@ using BuildingBlocks.EventDispatcher; +using BuildingBlocks.Helper; using Marten; +using Modgud.Api.Features.Positions; using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; using Modgud.Authentication.ExtensionMethods; @@ -40,7 +42,7 @@ public static WebApplication MapOAuthClientsEndpoints(this WebApplication app, s .WithName("OAuth_Clients_Get") .RequiresPermission("oauth-client:read"); - group.MapPost("", async (CreateOAuthClientDto dto, HttpContext http, IPermissionService permissions, OAuthAdminService svc, IDocumentSession session, DataEventDispatcher dispatcher, CancellationToken ct) => + group.MapPost("", async (CreateOAuthClientDto dto, HttpContext http, AppSettings settings, IPermissionService permissions, OAuthAdminService svc, IDocumentSession session, DataEventDispatcher dispatcher, CancellationToken ct) => { if (dto.NewServiceAccount is not null) { @@ -50,13 +52,31 @@ public static WebApplication MapOAuthClientsEndpoints(this WebApplication app, s return Results.Forbid(); } - var result = await svc.CreateClientAsync(dto, ct); + // MG-FT — terminal-managed create: 404 while the feature flag is off + // (mirrors the position endpoints), and creating/linking a position's + // slot needs position:write on top of oauth-client:write. + if (OAuthAdminService.HasTerminalClientIntent(dto)) + { + if (!settings.Features.PositionTerminals) return Results.NotFound(); + var userId = http.GetUserId(); + if (userId is null || !await permissions.HasPermissionAsync( + userId.Value, AppSlugs.Modgud, "position:write", ct)) + return Results.Forbid(); + } + + var result = await svc.CreateClientAsync( + dto, dcrMetadata: null, enlistInTransaction: null, actorId: http.GetUserId(), ct); // Broadcast only the client view (never the one-time secret in the wrapper). if (!result.IsError) { dispatcher.DispatchCreatedEvent("OAuthClient", result.Value.Client, session.TenantId); if (result.Value.CreatedServiceAccount is { } serviceAccount) dispatcher.DispatchCreatedEvent("ServiceAccount", serviceAccount, session.TenantId); + if (result.Value.CreatedPosition is { } position) + dispatcher.DispatchCreatedEvent("Position", position, session.TenantId); + if (result.Value.CreatedTerminalId is { } terminalId && ShortGuid.TryParse(terminalId, out Guid terminalGuid)) + dispatcher.DispatchCreatedEvent("Terminal", + await PositionTerminalsEndpoints.LoadDtoAsync(session, terminalGuid, ct), session.TenantId); } return result.ToResult(created => Results.Created($"{path}/admin/oauth/clients/{created.Client.Id}", created)); }) diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs index dbe2cfde..4348b36f 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs @@ -138,7 +138,8 @@ private static async Task RegisterAsync( OutcomeCode = AuditOutcomes.Succeeded, OperationCode = "register", }), - ct); + actorId: null, + ct: ct); if (createResult.IsError) { // Should never happen for DCR-validated input — the validator diff --git a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs index 29041eaa..afe12544 100644 --- a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs @@ -189,6 +189,30 @@ public record CreateOAuthClientDto /// when client validation or persistence fails. /// public ServiceAccountCreateDto? NewServiceAccount { get; init; } + + /// + /// Optional ShortGuid of a Position this terminal client serves — the + /// terminal counterpart of . Required + /// when includes the staffing grant, and + /// forbidden without it. Creating the client creates the position's + /// terminal slot in the same save. + /// + public string? LinkedPositionPrincipalId { get; init; } + + /// + /// Optional Position to create atomically with this terminal client. + /// Mutually exclusive with — same + /// shape as , so first-time terminal setup + /// fits in one save without leaving an orphaned principal behind. + /// + public Positions.PositionCreateDto? NewPosition { get; init; } + + /// Display name of the terminal slot this client serves + /// ("Gate terminal left"). Required alongside a position link. + public string? TerminalDisplayName { get; init; } + + /// Optional physical location of that slot ("Gate 3"). + public string? TerminalLocation { get; init; } } public record UpdateOAuthClientDto @@ -270,4 +294,15 @@ public record OAuthClientCreatedDto public required OAuthClientDto Client { get; init; } public string? ClientSecret { get; init; } public ServiceAccountDto? CreatedServiceAccount { get; init; } + + /// + /// The Position created inline via — + /// the terminal counterpart of . Null when the + /// client referenced an existing position or is not terminal-managed. + /// + public Positions.PositionPrincipalDto? CreatedPosition { get; init; } + + /// ShortGuid of the terminal slot created alongside a + /// terminal-managed client. Null for non-terminal clients. + public string? CreatedTerminalId { get; init; } } diff --git a/src/dotnet/Modgud.Application/Errors/OAuthErrors.cs b/src/dotnet/Modgud.Application/Errors/OAuthErrors.cs index dc9a08b5..a1a37926 100644 --- a/src/dotnet/Modgud.Application/Errors/OAuthErrors.cs +++ b/src/dotnet/Modgud.Application/Errors/OAuthErrors.cs @@ -84,6 +84,42 @@ public static Error ServiceAccountNameAlreadyExists(string accountName) => Error code: "OAuth.ClientCredentialsRequiresServiceAccountLink", description: "A client with the 'client_credentials' grant must reference LinkedServiceAccountId or include NewServiceAccount."); + public static Error InvalidPositionId(string id) => Error.Validation( + code: "OAuth.InvalidPositionId", + description: $"LinkedPositionPrincipalId '{id}' is not a valid Guid or ShortGuid."); + + public static Error PositionNotFound(string id) => Error.Validation( + code: "OAuth.PositionNotFound", + description: $"Position '{id}' not found or deleted."); + + public static Error InvalidNewPositionName => Error.Validation( + code: "OAuth.InvalidNewPositionName", + description: "The new Position account name must be 2-64 characters and contain only lowercase letters, digits, dots, hyphens, or underscores."); + + public static Error PositionNameAlreadyExists(string accountName) => Error.Conflict( + code: "OAuth.PositionNameAlreadyExists", + description: $"Account name '{accountName}' is already used by a person, ServiceAccount, or Position."); + + public static Error PositionLinkModesAreMutuallyExclusive => Error.Validation( + code: "OAuth.PositionLinkModesAreMutuallyExclusive", + description: "Provide either LinkedPositionPrincipalId or NewPosition, not both."); + + public static Error StaffingGrantRequiresPositionLink => Error.Validation( + code: "OAuth.StaffingGrantRequiresPositionLink", + description: "A client with the staffing grant must reference LinkedPositionPrincipalId or include NewPosition — the terminal counterpart of the client_credentials rule."); + + public static Error PositionLinkRequiresStaffingGrant => Error.Validation( + code: "OAuth.PositionLinkRequiresStaffingGrant", + description: "A position-linked client must carry the terminal grants (device_code + refresh_token + staffing); it is a shared-terminal client, not a general-purpose one."); + + public static Error TerminalDisplayNameRequired => Error.Validation( + code: "OAuth.TerminalDisplayNameRequired", + description: "A position-linked client needs TerminalDisplayName — it names the slot the device serves."); + + public static Error PositionTerminalsDisabled(string accountName) => Error.Validation( + code: "OAuth.PositionTerminalsDisabled", + description: $"Position '{accountName}' has terminal use switched off. Enable it before attaching terminal clients."); + public static Error ServiceAccountLinkRequiresClientCredentialsOnly => Error.Validation( code: "OAuth.ServiceAccountLinkRequiresClientCredentialsOnly", description: "A ServiceAccount-linked client must use only the 'client_credentials' grant. User-flow grants (authorization_code, refresh_token, device_code) are forbidden — strict separation between user-flow and machine-to-machine clients."); diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs index 898d570e..a5d20a47 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs @@ -1,5 +1,11 @@ +using BuildingBlocks.Helper; using ErrorOr; +using Marten; +using Modgud.Application.DTOs.OAuth; +using Modgud.Application.DTOs.Positions; using Modgud.Application.Errors; +using Modgud.Authorization.Events; +using Modgud.Authorization.Principals; using Modgud.Domain.PositionTerminals; using Modgud.Domain.OAuth.Applications; using Modgud.Domain.OAuth.Common; @@ -16,6 +22,218 @@ namespace Modgud.Application.Services; /// public partial class OAuthAdminService { + /// + /// True when a create request means a terminal-managed client: it names the + /// staffing grant or any of the position/terminal link fields. Mirrors the + /// client_credentials ⇔ ServiceAccount coupling — a staffing client must be + /// backed by a position, and position fields without the staffing grant are + /// a contradiction the terminal path rejects loudly instead of dropping. + /// + public static bool HasTerminalClientIntent(CreateOAuthClientDto dto) => + dto.AllowedGrantTypes.Contains(PositionGrantTypes.StaffingSession, StringComparer.Ordinal) + || !string.IsNullOrWhiteSpace(dto.LinkedPositionPrincipalId) + || dto.NewPosition is not null + || !string.IsNullOrWhiteSpace(dto.TerminalDisplayName) + || !string.IsNullOrWhiteSpace(dto.TerminalLocation); + + /// + /// Client-side terminal create ("wie in Service Accounts"): the generic + /// admin create diverted here because the request carries terminal intent. + /// Resolves or inline-creates the position, then delegates the client build + /// to — the single producer of the + /// fixed terminal profile — and starts the enrollment stream, all committed + /// in ONE SaveChanges: position, grants, slot, and client exist together or + /// not at all. + /// + private async Task> CreateTerminalClientAsync( + CreateOAuthClientDto dto, bool isDcr, Guid? actorId, CancellationToken ct) + { + if (isDcr) + return OAuthErrors.InvalidPositionTerminalClient( + "a terminal client cannot be created via dynamic client registration."); + + if (actorId is null) + return OAuthErrors.InvalidPositionTerminalClient( + "creating a terminal client requires an authenticated admin actor."); + + var hasLinked = !string.IsNullOrWhiteSpace(dto.LinkedPositionPrincipalId); + if (hasLinked && dto.NewPosition is not null) + return OAuthErrors.PositionLinkModesAreMutuallyExclusive; + + if (!dto.AllowedGrantTypes.Contains(PositionGrantTypes.StaffingSession, StringComparer.Ordinal)) + return OAuthErrors.PositionLinkRequiresStaffingGrant; + + if (!hasLinked && dto.NewPosition is null) + return OAuthErrors.StaffingGrantRequiresPositionLink; + + // The profile is fixed; requested grants may only be (a subset of) it. + // Anything else — client_credentials, the web code flow — is a + // different kind of client and gets rejected, not silently dropped. + if (dto.AllowedGrantTypes.Any(g => !TerminalGrantTypes.Contains(g))) + return OAuthErrors.InvalidPositionTerminalClient( + "allowed grants are exactly device_code, refresh_token, and the position staffing grant."); + + if (!string.Equals(dto.ClientType, OAuthClientTypes.Public, StringComparison.Ordinal)) + return OAuthErrors.InvalidPositionTerminalClient("the client must be public."); + + var terminalDisplayName = (dto.TerminalDisplayName ?? string.Empty).Trim(); + if (terminalDisplayName.Length == 0) + return OAuthErrors.TerminalDisplayNameRequired; + + // ── Resolve or inline-create the position ───────────────────────── + PositionPrincipal position; + PositionPrincipalDto? createdPosition = null; + var stagedGrantUserIds = new List(); + + if (hasLinked) + { + if (!ShortGuid.TryParse(dto.LinkedPositionPrincipalId!, out Guid parsedPosition)) + return OAuthErrors.InvalidPositionId(dto.LinkedPositionPrincipalId!); + var existing = await _session.LoadAsync(parsedPosition, ct); + if (existing is null || existing.IsDeleted) + return OAuthErrors.PositionNotFound(dto.LinkedPositionPrincipalId!); + position = existing; + } + else + { + var newPosition = dto.NewPosition!; + var accountName = (newPosition.AccountName ?? string.Empty).Trim().ToLowerInvariant(); + if (!ServiceAccountNamePattern.IsMatch(accountName)) + return OAuthErrors.InvalidNewPositionName; + + // Positions share the account-name space with persons AND service + // accounts (mirrors the position endpoint's create). + var personTaken = await _session.Query() + .AnyAsync(p => !p.IsDeleted && p.AccountName == accountName, ct); + var serviceAccountTaken = await _session.Query() + .AnyAsync(sa => !sa.IsDeleted && sa.AccountName == accountName, ct); + var positionTaken = await _session.Query() + .AnyAsync(f => !f.IsDeleted && f.AccountName == accountName, ct); + if (personTaken || serviceAccountTaken || positionTaken) + return OAuthErrors.PositionNameAlreadyExists(accountName); + + // This client IS the position's first slot — staged slots inside the + // draft would race the same save with a second producer. + if (newPosition.Terminals is { Count: > 0 }) + return OAuthErrors.InvalidPositionTerminalClient( + "NewPosition cannot stage terminal slots — this client is the slot; add further slots via the position modal."); + + var policy = PositionTerminalPolicy.Disabled; + if (newPosition.TerminalPolicy is { } policyUpdate) + { + policy = policy with + { + Enabled = policyUpdate.Enabled ?? policy.Enabled, + StaffingSessionLifetime = policyUpdate.StaffingSessionLifetimeMinutes is { } sessionMinutes + ? TimeSpan.FromMinutes(sessionMinutes) + : policy.StaffingSessionLifetime, + MaximumStaffingSessionLifetime = policyUpdate.MaximumStaffingSessionLifetimeMinutes is { } maximumMinutes + ? TimeSpan.FromMinutes(maximumMinutes) + : policy.MaximumStaffingSessionLifetime, + }; + if (policy.StaffingSessionLifetime <= TimeSpan.Zero || policy.MaximumStaffingSessionLifetime <= TimeSpan.Zero) + return Error.Validation("Position.InvalidTerminalPolicy", + "Staffing session lifetimes must be positive."); + if (policy.StaffingSessionLifetime > policy.MaximumStaffingSessionLifetime) + return Error.Validation("Position.InvalidTerminalPolicy", + "The staffing session lifetime must not exceed the absolute maximum lifetime."); + } + + // Staged grants — resolve and validate EVERY user before creating + // anything (mirrors the position endpoint's all-or-nothing rule). + foreach (var rawUserId in newPosition.GrantUserIds?.Distinct() ?? []) + { + if (!ShortGuid.TryParse(rawUserId, out Guid grantUserId)) + return Error.Validation("PositionGrant.InvalidUserId", + $"Grant user id '{rawUserId}' is invalid."); + var person = await _session.LoadAsync(grantUserId, ct); + if (person is null || person.IsDeleted) + return Error.Validation("PositionGrant.UserNotFound", + $"Grant user '{rawUserId}' does not exist."); + if (!person.IsActive) + return Error.Validation("PositionGrant.UserInactive", + $"Grant user '{rawUserId}' is inactive."); + stagedGrantUserIds.Add(grantUserId); + } + + position = new PositionPrincipal + { + Id = Guid.NewGuid(), + AccountName = accountName, + Purpose = string.IsNullOrWhiteSpace(newPosition.Purpose) ? null : newPosition.Purpose.Trim(), + IsActive = newPosition.IsActive, + TerminalPolicy = policy, + }; + _session.Events.StartStream(position.Id, new PositionPrincipalCreatedEvent( + position.Id, position.AccountName, position.Purpose, position.IsActive, position.TerminalPolicy)); + + createdPosition = new PositionPrincipalDto + { + Id = new ShortGuid(position.Id).ToString(), + AccountName = position.AccountName, + Purpose = position.Purpose, + IsActive = position.IsActive, + TerminalPolicy = new PositionTerminalPolicyDto + { + Enabled = position.TerminalPolicy.Enabled, + StaffingSessionLifetimeMinutes = (int)position.TerminalPolicy.StaffingSessionLifetime.TotalMinutes, + MaximumStaffingSessionLifetimeMinutes = (int)position.TerminalPolicy.MaximumStaffingSessionLifetime.TotalMinutes, + }, + }; + } + + if (!position.TerminalPolicy.Enabled) + return OAuthErrors.PositionTerminalsDisabled(position.AccountName); + + // ── ClientId per convention (dto.ClientId is deliberately ignored — + // the audit log reads the owning position off the generated id) ────── + var clientId = string.Empty; + for (var attempt = 0; attempt < 8; attempt++) + { + var candidate = $"{position.AccountName}.terminal.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}"; + var clash = await _session.Query() + .AnyAsync(x => !x.IsDeleted && x.ClientId == candidate, ct); + if (!clash) { clientId = candidate; break; } + } + if (clientId.Length == 0) + return Error.Conflict("OAuth.ClientIdAutoGenerationFailed", + "Could not generate a unique client_id for the terminal client after 8 attempts."); + + // ── Stage client + enrollment, commit once ───────────────────────── + var enrollmentId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + var clientError = StageCreateTerminalClient( + applicationId, clientId, $"{position.DisplayName} — {terminalDisplayName}", + position.Id, enrollmentId, dto.WebAuthnRpId ?? string.Empty); + if (clientError is not null) + return clientError.Value; + + var now = DateTimeOffset.UtcNow; + foreach (var grantUserId in stagedGrantUserIds) + { + var grantId = Guid.NewGuid(); + _session.Events.StartStream(grantId, new PositionGrantIssued( + grantId, position.Id, grantUserId, actorId.Value, now)); + } + + _session.Events.StartStream(enrollmentId, new TerminalEnrollmentCreated( + enrollmentId, position.Id, terminalDisplayName, + string.IsNullOrWhiteSpace(dto.TerminalLocation) ? null : dto.TerminalLocation.Trim(), + applicationId, clientId, dto.WebAuthnRpId!.Trim().ToLowerInvariant(), + actorId.Value, now)); + + await _session.SaveChangesAsync(ct); + + var state = await _session.LoadAsync(applicationId, ct); + return new OAuthClientCreatedDto + { + Client = MapClient(state!), + ClientSecret = null, + CreatedPosition = createdPosition, + CreatedTerminalId = new ShortGuid(enrollmentId).ToString(), + }; + } + /// /// Stages the terminal-managed public client for one slot. The profile is /// FIXED (plan §6.4): public, secretless, DPoP-mandatory, reference tokens, diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs index f9e3cc67..056c63df 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs @@ -88,6 +88,7 @@ public async Task> CreateClientAsync( dto, dcrMetadata, enlistInTransaction: null, + actorId: null, ct); /// @@ -95,16 +96,28 @@ public async Task> CreateClientAsync( /// The API layer uses this to add its infrastructure-owned required audit /// document before this service commits, without introducing an /// Application-to-Infrastructure dependency. + /// is the authenticated admin creating the + /// client — required only for terminal-managed creates, whose enrollment + /// stream records the acting admin. /// public async Task> CreateClientAsync( CreateOAuthClientDto dto, DcrMetadataInput? dcrMetadata, Action? enlistInTransaction, + Guid? actorId = null, CancellationToken ct = default) { if (dto.ClientType is not (OAuthClientTypes.Public or OAuthClientTypes.Confidential)) return OAuthErrors.InvalidClientType(dto.ClientType); + // MG-FT — a client that names the staffing grant or any position/terminal + // field is a terminal-managed client and takes its own create path: the + // fixed profile in StageCreateTerminalClient, never the generic build + // below (same divert-don't-drift reasoning as the SA-scoped credential + // issue path). + if (HasTerminalClientIntent(dto)) + return await CreateTerminalClientAsync(dto, isDcr: dcrMetadata is not null, actorId, ct); + if (dto.ConsentType is not (OAuthConsentTypes.Explicit or OAuthConsentTypes.Implicit or OAuthConsentTypes.External)) return OAuthErrors.InvalidConsentType(dto.ConsentType); @@ -360,6 +373,14 @@ public async Task> UpdateClientAsync( ValidateServiceAccountLinkInvariant(dto.AllowedGrantTypes, linkedServiceAccountId: null) is { } updLinkErr) return updLinkErr; + // MG-FT — same guard for the staffing grant: the UpdateDto carries no + // position link (terminal clients are born via their own create path), + // so adding the staffing grant here would mint a staffing client with + // no position behind it. + if (dto.AllowedGrantTypes is not null && + dto.AllowedGrantTypes.Contains(Modgud.Domain.PositionTerminals.PositionGrantTypes.StaffingSession, StringComparer.Ordinal)) + return OAuthErrors.StaffingGrantRequiresPositionLink; + // Reject unsupported / removed grant types (implicit, password, typos) // on update too — the guard is only meaningful if it can't be bypassed // by editing an existing client. diff --git a/src/frontend-vue/src/models/oauth.ts b/src/frontend-vue/src/models/oauth.ts index afd105c9..d99c8724 100644 --- a/src/frontend-vue/src/models/oauth.ts +++ b/src/frontend-vue/src/models/oauth.ts @@ -3,6 +3,7 @@ // Backend serializes with PropertyNamingPolicy=null, so PascalCase is required. import type { ServiceAccountCreateDto, ServiceAccountDto } from './serviceAccount' +import type { PositionCreateDto, PositionPrincipalDto } from './position' export interface OAuthClientClaimDto { Type: string @@ -141,6 +142,22 @@ export interface CreateOAuthClientDto { * exclusive with LinkedServiceAccountId. */ NewServiceAccount?: ServiceAccountCreateDto | null + /** + * Existing Position this terminal client serves — the terminal counterpart + * of LinkedServiceAccountId. Required with the staffing grant; the client's + * terminal slot is created in the same save. + */ + LinkedPositionPrincipalId?: string | null + /** + * Position created atomically with this terminal client. Mutually exclusive + * with LinkedPositionPrincipalId. Must not stage terminal slots — this + * client IS the slot. + */ + NewPosition?: PositionCreateDto | null + /** Display name of the terminal slot ("Gate terminal left"). Required with a position link. */ + TerminalDisplayName?: string | null + /** Optional physical location of that slot ("Gate 3"). */ + TerminalLocation?: string | null } export interface UpdateOAuthClientDto { @@ -200,6 +217,10 @@ export interface OAuthClientCreatedDto { Client: OAuthClientDto ClientSecret?: string | null CreatedServiceAccount?: ServiceAccountDto | null + /** Position created inline via NewPosition — the terminal counterpart of CreatedServiceAccount. */ + CreatedPosition?: PositionPrincipalDto | null + /** ShortGuid of the terminal slot created alongside a terminal-managed client. */ + CreatedTerminalId?: string | null } export interface ClientSecretDto { diff --git a/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue b/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue index 3eafdfbe..b3db438d 100644 --- a/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue +++ b/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue @@ -1,5 +1,5 @@ diff --git a/src/frontend-vue/src/views/admin/position/PositionDetails.vue b/src/frontend-vue/src/views/admin/position/PositionDetails.vue index 2a8d97fa..46461217 100644 --- a/src/frontend-vue/src/views/admin/position/PositionDetails.vue +++ b/src/frontend-vue/src/views/admin/position/PositionDetails.vue @@ -30,15 +30,6 @@ const toast = useToast() const props = defineProps<{ id: string close: (result?: unknown) => void - /** - * Reuse the normal create dialog as an embedded draft editor (opened from - * ClientDetails for a terminal client). In this mode Save returns the - * validated PositionCreateDto to the parent without calling the API; the - * terminal slot itself is defined by the client form, so slot staging is - * absent and terminal use must stay enabled. - */ - draftOnly?: boolean - initial?: PositionCreateDto }>() const store = usePositionStore() @@ -51,15 +42,13 @@ const error = ref(null) const activeTab = ref<'general' | 'terminals' | 'grants' | 'sessions'>('general') const form = ref({ - AccountName: props.initial?.AccountName ?? '', - Purpose: props.initial?.Purpose ?? '', - IsActive: props.initial?.IsActive ?? true, - // A draft position backs the terminal client being created, so terminal use - // starts (and must stay) on; the plain create keeps the safe default off. - TerminalEnabled: props.initial?.TerminalPolicy?.Enabled ?? (props.draftOnly ?? false), + AccountName: '', + Purpose: '', + IsActive: true, + TerminalEnabled: false, // Plan defaults: a 16 h shift session under a 24 h absolute ceiling. - StaffingSessionLifetimeMinutes: props.initial?.TerminalPolicy?.StaffingSessionLifetimeMinutes ?? 16 * 60, - MaximumStaffingSessionLifetimeMinutes: props.initial?.TerminalPolicy?.MaximumStaffingSessionLifetimeMinutes ?? 24 * 60, + StaffingSessionLifetimeMinutes: 16 * 60, + MaximumStaffingSessionLifetimeMinutes: 24 * 60, }) const original = ref({ ...form.value }) const accountNamePattern = /^[a-z0-9][a-z0-9._-]{1,63}$/ @@ -94,9 +83,7 @@ const modalTitle = computed(() => { const footerButton = computed(() => ({ visible: true, - text: props.draftOnly - ? t('admin.positions.applyDraft', {}, 'Übernehmen') - : isCreate.value ? t('common.create', {}, 'Create') : t('common.save', {}, 'Save'), + text: isCreate.value ? t('common.create', {}, 'Create') : t('common.save', {}, 'Save'), disabled: !form.value.AccountName.trim() || generalIssues.value.length > 0 || terminalIssues.value.length > 0 || loading.value, onClick: save, @@ -114,7 +101,7 @@ const grantsHttp = computed(() => useHttpClient(`/api/position/${props.id}/grant // Create mode stages grants (rule 5: the entity is creatable completely — the // one Save commits position + grants atomically); edit mode operates on live // grants immediately (rule 2, they have their own lifecycle + audit identity). -const stagedGrantUserIds = ref(props.initial?.GrantUserIds ? [...props.initial.GrantUserIds] : []) +const stagedGrantUserIds = ref([]) function userLabel(userId: string): string { const u = userStore.entities.find((x) => x.Id === userId) @@ -224,9 +211,6 @@ const terminalIssues = computed(() => { if (stagedTerminals.value.length > 0 && !form.value.TerminalEnabled) issues.push(t('admin.positionTerminals.stagedNeedPolicy', {}, 'Turn terminal use on — the staged slots are saved with it.')) - if (props.draftOnly && !form.value.TerminalEnabled) - issues.push(t('admin.positions.draftNeedsTerminalUse', {}, - 'Terminal use must stay on — this position backs the terminal client being created.')) return issues }) @@ -373,20 +357,9 @@ async function save() { AccountName: form.value.AccountName.trim(), Purpose: form.value.Purpose.trim() || undefined, IsActive: form.value.IsActive, - // The draft travels through the client create, which merges the policy - // onto the disabled default — so it needs the FULL policy, not the - // diff (an untouched draft would otherwise arrive policy-less and be - // rejected as terminal-disabled). - TerminalPolicy: props.draftOnly - ? { - Enabled: form.value.TerminalEnabled, - StaffingSessionLifetimeMinutes: form.value.StaffingSessionLifetimeMinutes, - MaximumStaffingSessionLifetimeMinutes: form.value.MaximumStaffingSessionLifetimeMinutes, - } - : policyDiff(), + TerminalPolicy: policyDiff(), GrantUserIds: stagedGrantUserIds.value.length > 0 ? stagedGrantUserIds.value : undefined, - // draftOnly: the slot is defined by the client form, never staged here. - Terminals: !props.draftOnly && stagedTerminals.value.length > 0 + Terminals: stagedTerminals.value.length > 0 ? stagedTerminals.value.map((slot) => ({ DisplayName: slot.DisplayName, Location: slot.Location || undefined, @@ -394,10 +367,6 @@ async function save() { })) : undefined, } - if (props.draftOnly) { - props.close(createDto) - return - } await store.createEntity(createDto) } else { // Send only fields that actually changed. Empty Purpose = explicit clear @@ -535,16 +504,10 @@ async function save() { - - - {{ t('admin.positions.draftSlotHint', {}, 'The terminal slot is defined by the OAuth client being created and lands together with this position in one save. Additional slots can be added in the position modal afterwards.') }} - - -
+
Date: Sat, 15 Aug 2026 16:01:16 +0200 Subject: [PATCH 3/4] fix(positions): pin the position modal body height - no resize on tab switch The position modal is tabbed but ran on the cap-to-content MODAL_MD size, so switching between the short General tab and the list tabs resized the panel - against the modal size contract. Same fix as .user-edit-frame in UserDetails: the route stays cap-to-content, the component pins a 60vh body (create included, it is tabbed too); long grant/slot/session lists scroll inside the frame. Co-Authored-By: Claude Fable 5 --- .../src/views/admin/position/PositionDetails.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/frontend-vue/src/views/admin/position/PositionDetails.vue b/src/frontend-vue/src/views/admin/position/PositionDetails.vue index 46461217..1f39f10e 100644 --- a/src/frontend-vue/src/views/admin/position/PositionDetails.vue +++ b/src/frontend-vue/src/views/admin/position/PositionDetails.vue @@ -743,12 +743,19 @@ async function save() { + +## The four building blocks + +Two of them live in Modgud, two in the real world: + +
+ + + People + Anna, Ben, Carla … + ordinary user accounts + + Position + the post: "gate", + "control room", "reception" + + Terminal + the device slot at the + position: "left terminal" + + Device + the physical hardware + standing at the post + + real world + in Modgud + +
+ +The **position** is the star of the model: a business role staffed by +*changing* people. It receives rights through the ordinary groups & roles +machinery, but it never signs in — it gets **activated** (more on that below). +For downstream systems, *the gate* acts — never Anna or Ben. + +## Everything is a link + +The whole system is three links between those blocks. Each has its own +moment, its own flow — and answers a different question. + +
+ + + + + + + + Person + + Position + + Terminal + + Device + + + ① "may staff" + changeable any time, per person + + + ② "the position may run here" + created when you add the terminal + + + ③ "this device is it" + at installation, exactly once + +
+ +| Link | Question it answers | When & how | +|---|---|---| +| ① Person ↔ Position | **Who** may staff this post? | A simple list on the position ("authorized users"). Grant, suspend, revoke — takes effect immediately. | +| ② Terminal ↔ Position | **Where** may this post be staffed? | Created when you add a terminal to the position. At its core an authorization: "the gate may be activated on this slot." | +| ③ Device ↔ Terminal | **Which hardware** actually stands there? | At installation, exactly once. From then on exactly this device *is* "left terminal" — a replacement device needs a fresh slot. | + +::: tip Mnemonic +Link ① says *who*, ② says *where*, ③ says *with what*. The daily unlock is +not a fourth link — it is the moment all three are checked at once. +::: + +> For engineers: ① is the *grant*, ② is the *terminal slot* with its +> auto-created OAuth client, ③ is the *enrollment* (device key binding). The +> client appears in the OAuth grid as inventory only — everything is managed +> in the position. + +## A position is not a group + +The most tempting confusion — and the most important distinction in the model: + +::: info The one-liner +**A group distributes rights. A position acts.** +::: + +- **Group "porters" with Anna as member:** rights flow *to the person*. + **Anna** acts, under her own name, with the group's rights. The group itself + never appears at runtime — no tokens, no sessions. It is a distribution + mechanism. +- **Position "gate" with Anna authorized:** rights never flow to Anna! The + grant gives her **no right of the gate** — only the ability to **switch the + gate on**. Then *the gate* acts, with *its* rights. Anna's own permissions + are irrelevant during the shift. + +The authorized-users list looks like membership but is a **key cabinet**: +"these people may start the engine", not "these people are the engine". +And the two concepts stack instead of competing — the position receives its +own rights *through groups*, like any other principal. + +### Same person, two devices, two actors + +
+ + + Anna + one human + + Secured terminal + Anna unlocks → the actor is + "the gate" + ✓ acknowledge alarms + ✓ operate barriers + ✓ keep the watch log + rights of the POSITION + + Her PC, 1 m away + Anna signs in → the actor is + "Anna" + ✓ read e-mail + ✓ time tracking + ✗ no barriers, no alarms + rights of the PERSON + +

This is why the terminal is hardened more than the PC next to it: it is +the vessel for the post's rights, which can exceed those of any single person in +front of it. Security scales with the position's rights, not the person's. The inverse +exists too — a kiosk position can deliberately hold fewer rights than the human +in front of it.

+
+ +## A position never authenticates — it gets activated + +A position owns no credentials of its own (that is the difference to a +[service account](/admin/service-accounts), which identifies *itself*, from +anywhere). Every position token starts with someone — an authorized person — +proving themselves **at an enrolled terminal**. The chain is strict: + +``` +Position → terminal slot → enrolled device → unlock by an authorized person → session +``` + +No slot → no device → no unlock → never a token. A position without terminals +is valid, but dormant: configuration waiting for hardware. + +## A shift at the gate + +1. **06:02 — Anna taps.** Modgud checks all three links at once: is this the + real device (③)? may the gate run here (②)? may Anna staff the gate (①)? + → unlocked. From now on the terminal acts as *the gate*. +2. **Handover:** Ben taps → Anna's shift ends automatically, his begins. + Exactly **one** shift runs per terminal at any time. +3. **Locking:** at the device, or remotely by an admin (**force-lock**, + effective immediately — terminal tokens are revoked on the spot). +4. **Time limits:** every shift ends at the configured ceiling at the latest + (default 16 h, absolute maximum 24 h), even if nobody locks. +5. **Cascades:** deactivating Anna, revoking her grant, disabling the slot or + the position — each ends the affected running shift automatically. + +## What the audit attests — and what it doesn't + +The staffing audit attests **the unlock, not each action**: + +``` +06:02 gate / left terminal unlocked by Anna +07:15 alarm #4711 acknowledged by "the gate" +14:01 handover: Anna's shift ended, unlocked by Ben +17:40 force-lock by admin — terminal locked +``` + +Who *actually clicked* the alarm at 07:15 is not recorded — if Anna was on a +break and a colleague clicked, the log still shows Anna's shift. That is not a +gap; it is the nature of every shared device. What the model guarantees: +**only authorized people can unlock, and who unlocked is cleanly recorded.** +Accountability is **session-level, not action-level**. If a use case ever +needs per-action attribution, a step-up proof per critical action is the +designed extension point — not a new system. + +## Which principal for which job? + +| If … | … then | +|---|---| +| a **person** acts and must appear in the business data (receipt, ticket, signature) | ordinary **user login** — also on a shared device, with fast switching | +| a **post** acts that has to be activated (gate, control room, reception) | **position** + terminals — this model | +| a **machine** acts, with no human activation at all (sealed appliance, server job) | **service account** | + +The test question in one sentence: *"Who owns what the system does — the +person, the post, or the machine?"* One concept per answer, and no fourth is +needed. (A **group** is none of the three — it distributes rights, it never +acts.) + +## Where the model can go — design direction + +::: warning Roadmap, not current behavior +Today only the strictest configuration exists: personal passkey for the +unlock, cryptographic device binding (DPoP) for the terminal. Everything in +this section is the **accepted design direction** (ADR 0003) — implemented +when a concrete consumer needs it. +::: + +The flows above are normative; **how** person and device prove themselves is +planned to become per-position policy, chosen from a curated menu with the +current behavior as the recommended default — and every downgrade shown as an +explicit, informed operator decision: + +- **Unlock proof:** personal passkey *(default)* → personal PIN / password → + **position-owned tokens** (FIDO2 sticks registered on the *position*; the + customer hands them out, the audit says "unlocked with token #2", each + stick individually revocable) → shared team PIN *(weakest — the audit knows + no name)*. Multiple classes can be allowed at once on one position. +- **Device binding:** DPoP key *(default)* → client secret (for devices that + cannot do DPoP) → none *(only defensible behind physical access control or + in test realms — there is no device identity left)*. +- **Realm guard rails:** the realm sets minimum tiers ("production: nothing + below DPoP + personal proof"); a test realm may allow everything for POCs. +- **Multi-position terminals:** one device serving several positions + ("reception" by day, "night gate" after hours) — the assignment is an + authorization, so it can be a list; still one active shift per terminal. diff --git a/docs/admin/positions.md b/docs/admin/positions.md index 6e9a12ce..5b585295 100644 --- a/docs/admin/positions.md +++ b/docs/admin/positions.md @@ -11,8 +11,11 @@ Downstream systems then see the POSITION as the actor (`sub` = the position), never the person — who tapped stays visible only to you, in the staffing-session audit view. -This page is the admin workflow. The developer-facing contract (token -classes, wire formats, integration events) lives under +This page is the admin workflow. New to the model? Start with +[Positions — the concepts](/admin/positions-concepts) — the building +blocks, the three links, and why a position is not a group, with diagrams. +The developer-facing contract (token classes, wire formats, integration +events) lives under [Integrate → Position terminals](/integrate/position-terminals). ## 1. Create the position