diff --git a/docs/admin/positions.md b/docs/admin/positions.md index 32abbb2b..b56cc15c 100644 --- a/docs/admin/positions.md +++ b/docs/admin/positions.md @@ -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 @@ -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). diff --git a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs index 2722945c..8ada0350 100644 --- a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs @@ -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; @@ -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); } + /// + /// 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. + /// + [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(JsonOptions, ct))!; + Assert.True(created.TerminalPolicy.Enabled); + + var slots = await Client.GetFromJsonAsync>( + $"/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(); + foreach (var slot in slots) + { + var client = (await session.Query() + .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); + } + } + + /// Plan §4.1 holds at create time too: slots need terminal use. + /// The rejection is all-or-nothing — no position, no orphaned client. + [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>("/api/position", JsonOptions, ct); + Assert.DoesNotContain(all!, p => p.AccountName == "portier.noterminals"); + + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + Assert.Empty(await session.Query() + .Where(c => c.ClientId.StartsWith("portier.noterminals.")).ToListAsync(ct)); + } + private async Task CreatePositionAsync(string accountName, CancellationToken ct) { var resp = await Client.PostAsJsonAsync("/api/position", new { AccountName = accountName }, JsonOptions, ct); diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs index 17a50a10..041b6a16 100644 --- a/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs @@ -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 LoadDtoAsync(IDocumentSession session, Guid terminalId, CancellationToken ct) + internal static async Task LoadDtoAsync(IDocumentSession session, Guid terminalId, CancellationToken ct) => ToDto((await session.LoadAsync(terminalId, ct))!); private static TerminalDto ToDto(TerminalEnrollment t) => new() diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs index f9961b5e..3b1bef3e 100644 --- a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs @@ -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; @@ -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) => @@ -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 @@ -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(); + 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(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") diff --git a/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs b/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs index 0b86bb54..6aa8b34a 100644 --- a/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs @@ -49,6 +49,16 @@ public class PositionCreateDto /// grant stream commit in one unit of work. /// public List? GrantUserIds { get; set; } + + /// + /// Terminal slots to set up in the same save (modal-contract rule 5 — like + /// the service account's initial credential). Requires + /// 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. + /// + public List? Terminals { get; set; } } public class PositionUpdateDto diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index 48830cfa..eeb3ae53 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -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)", @@ -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", diff --git a/src/frontend-vue/src/models/position.ts b/src/frontend-vue/src/models/position.ts index 1857f126..1e40fd54 100644 --- a/src/frontend-vue/src/models/position.ts +++ b/src/frontend-vue/src/models/position.ts @@ -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 { diff --git a/src/frontend-vue/src/views/admin/position/PositionDetails.vue b/src/frontend-vue/src/views/admin/position/PositionDetails.vue index 866057ae..e1b48b97 100644 --- a/src/frontend-vue/src/views/admin/position/PositionDetails.vue +++ b/src/frontend-vue/src/views/admin/position/PositionDetails.vue @@ -12,6 +12,10 @@ import { CoarButton, CoarTag, CoarPopconfirm, + CoarTabGroup, + CoarTab, + CoarIcon, + CoarPopover, useToast, } from '@cocoar/vue-ui' import { useI18n } from '@cocoar/vue-localization' @@ -33,6 +37,9 @@ const userStore = useUserStore() const isCreate = computed(() => props.id === 'create') const loading = ref(false) const error = ref(null) +// Modal-contract rule 5: create and edit share the layout — the sessions tab +// is simply absent while the position does not exist yet. +const activeTab = ref<'general' | 'terminals' | 'grants' | 'sessions'>('general') const form = ref({ AccountName: '', @@ -77,7 +84,8 @@ const modalTitle = computed(() => { const footerButton = computed(() => ({ visible: true, text: isCreate.value ? t('common.create', {}, 'Create') : t('common.save', {}, 'Save'), - disabled: !form.value.AccountName.trim() || !!accountNameError.value || !!lifetimeError.value || loading.value, + disabled: !form.value.AccountName.trim() || generalIssues.value.length > 0 + || terminalIssues.value.length > 0 || loading.value, onClick: save, })) @@ -151,14 +159,47 @@ async function transitionGrant(grant: PositionGrantDto, action: 'suspend' | 'res } } -// ── Terminal slots (MG-FT-03) — edit-mode operations like grants. Slot -// creation requires the PERSISTED terminal policy to be enabled (the server -// enforces it); a staged-but-unsaved enable is not enough. +// ── Terminal slots (MG-FT-03). Create mode STAGES slots the same way it +// stages grants (rule 5: the entity is creatable completely — mirrors the +// service account's initial credential, which is staged into the same +// atomic create). Edit mode operates on live slots immediately (rule 2), +// where the PERSISTED policy has to allow them. const terminals = ref([]) const terminalsLoading = ref(false) const newTerminal = ref({ DisplayName: '', Location: '', WebAuthnRpId: '' }) const terminalsHttp = computed(() => useHttpClient(`/api/position/${props.id}/terminals`)) -const canCreateTerminals = computed(() => !isCreate.value && original.value.TerminalEnabled) +const stagedTerminals = ref<{ DisplayName: string; Location: string; WebAuthnRpId: string }[]>([]) +// In create the staged policy decides (it is committed in the same save); in +// edit only the persisted one does, because the server validates against it. +const canAddTerminal = computed(() => + isCreate.value ? form.value.TerminalEnabled : original.value.TerminalEnabled) + +function stageTerminal() { + if (!newTerminal.value.DisplayName.trim() || !newTerminal.value.WebAuthnRpId.trim()) return + stagedTerminals.value.push({ + DisplayName: newTerminal.value.DisplayName.trim(), + Location: newTerminal.value.Location.trim(), + WebAuthnRpId: newTerminal.value.WebAuthnRpId.trim(), + }) + // Keep the RP ID — every terminal of one consuming app shares it. + newTerminal.value = { DisplayName: '', Location: '', WebAuthnRpId: newTerminal.value.WebAuthnRpId } +} + +function unstageTerminal(index: number) { + stagedTerminals.value.splice(index, 1) +} + +// Tabs organize, they don't disclose (rule 1) — so a validation error on an +// inactive tab is flagged on its label, otherwise a disabled Save would have +// no visible cause. Same shape as GroupDetails. +const generalIssues = computed(() => [accountNameError.value].filter(Boolean) as string[]) +const terminalIssues = computed(() => { + const issues = [lifetimeError.value].filter(Boolean) as string[] + 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.')) + return issues +}) async function loadTerminals() { if (isCreate.value) return @@ -305,6 +346,13 @@ async function save() { IsActive: form.value.IsActive, TerminalPolicy: policyDiff(), GrantUserIds: stagedGrantUserIds.value.length > 0 ? stagedGrantUserIds.value : undefined, + Terminals: stagedTerminals.value.length > 0 + ? stagedTerminals.value.map((slot) => ({ + DisplayName: slot.DisplayName, + Location: slot.Location || undefined, + WebAuthnRpId: slot.WebAuthnRpId, + })) + : undefined, } await store.createEntity(createDto) } else { @@ -336,7 +384,50 @@ async function save() {