diff --git a/docs/.vitepress/config-base.ts b/docs/.vitepress/config-base.ts index 701d1301..f301f947 100644 --- a/docs/.vitepress/config-base.ts +++ b/docs/.vitepress/config-base.ts @@ -199,6 +199,7 @@ export const baseConfig = { { text: 'Users', link: '/admin/users' }, { text: 'Service Accounts', link: '/admin/service-accounts' }, { text: 'Positions & Terminals', link: '/admin/positions' }, + { text: 'Positions — the Concepts', link: '/admin/positions-concepts' }, { text: 'Roles', link: '/admin/roles' }, { text: 'Groups', link: '/admin/groups' }, ], diff --git a/docs/admin/positions-concepts.md b/docs/admin/positions-concepts.md new file mode 100644 index 00000000..26a5b1ec --- /dev/null +++ b/docs/admin/positions-concepts.md @@ -0,0 +1,239 @@ +# Positions & terminals — the concepts + +> The [Positions & shared terminals](/admin/positions) page is the admin +> workflow (click here, enable that). This page explains the **model behind +> it** — what the building blocks are, how they connect, and how it feels in +> daily use. Protocol details live under +> [Integrate → Position terminals](/integrate/position-terminals). + + + +## 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 b56cc15c..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 @@ -63,10 +66,32 @@ 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. +### How terminal clients appear elsewhere + +The position modal is the **only UI** that creates and manages terminal +clients — deliberately: a terminal client is the technical footprint of a +slot, not a configurable OAuth client. In the **OAuth Clients grid** they +stay visible as inventory (the Terminal column names the owning position, +so the device fleet is countable at a glance), but they are read-only +there: opening one deep-links into the position modal instead — the same +rule SA-managed clients follow with the Service-Account editor. + +For automation, the admin **API** also accepts the client-side create +(`POST /api/admin/oauth/clients` with the staffing grant): reference an +existing position (`LinkedPositionPrincipalId`) or inline-create one +(`NewPosition`) — never both, mirroring the `client_credentials` ⇔ +service-account rule. Position (if new), slot, and client land in one +atomic save; the profile is **fixed server-side** (public, secretless, +DPoP mandatory, reference tokens, exactly device_code + refresh_token + +staffing), the `client_id` is generated (`{position}.terminal.{suffix}`), +and the call needs `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..335993b1 100644 --- a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs @@ -108,6 +108,13 @@ public record OAuthClientDto /// client_credentials, or unlinked + user-flow grants). /// public string? LinkedServiceAccountId { get; init; } + + /// + /// ShortGuid of the Position whose terminal slot this client serves, or + /// null for non-terminal clients. Drives the Terminal-badge in the admin + /// grid and the read-only modal that deep-links into the position editor. + /// + public string? LinkedPositionPrincipalId { get; init; } } public record OAuthClientClaimDto @@ -189,6 +196,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 +301,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/OAuthAdminMapping.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs index 6bf96620..582895c5 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs @@ -582,6 +582,9 @@ internal static OAuthClientDto MapClient(OAuthApplicationState s) LinkedServiceAccountId = s.LinkedServiceAccountId is null ? null : new ShortGuid(s.LinkedServiceAccountId.Value).ToString(), + LinkedPositionPrincipalId = s.LinkedPositionPrincipalId is null + ? null + : new ShortGuid(s.LinkedPositionPrincipalId.Value).ToString(), }; } 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..f6e7559a 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 @@ -92,6 +93,12 @@ export interface OAuthClientDto { * the read-only modal that deep-links to the SA editor. */ LinkedServiceAccountId?: string | null + /** + * ShortGuid of the Position whose terminal slot this client serves, or + * null for non-terminal clients. Drives the Terminal column in the admin + * grid and the deep-link into the position modal. + */ + LinkedPositionPrincipalId?: string | null } export interface CreateOAuthClientDto { @@ -141,6 +148,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 +223,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..4034a064 100644 --- a/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue +++ b/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue @@ -30,6 +30,7 @@ import { useServiceAccountStore } from '@/stores/serviceAccount.store' import { useClone, CLIENT_CLONE } from '@/composables/useClone' import { useModalOverlay } from '@/composables/useModalOverlay' import { MODAL_MD } from '@/router/modal-sizes' +import { useRouter } from 'vue-router' import type { OAuthClientDto, CreateOAuthClientDto, UpdateOAuthClientDto, AccessTokenType } from '@/models/oauth' import type { ServiceAccountCreateDto } from '@/models/serviceAccount' @@ -46,6 +47,7 @@ const applicationsStore = useApplicationsStore() const realmSettingsStore = useRealmSettingsStore() const serviceAccountStore = useServiceAccountStore() const modalOverlay = useModalOverlay() +const router = useRouter() const { consume } = useClone() const isCreate = computed(() => props.id === 'create' && !justCreated.value) // Genuinely-existing client opened from the list (drives the regenerate-secret @@ -141,11 +143,24 @@ const hasNativeGrantWithRealmOff = computed( () => !nativeGrantsEnabled.value && form.value.AllowedGrantTypes.some((g) => cocoarGrantValues.has(g))) +// MG-FT — the staffing grant marks a terminal-managed client of a Position. +// It is never OFFERED here (terminal clients are born via the position modal +// or the API, with a server-pinned profile); the option exists only so an +// existing terminal client's grant list renders instead of silently hiding +// the selection. +const STAFFING_GRANT = 'urn:cocoar:params:oauth:grant-type:staffing' +const staffingGrantTypeOptions = computed(() => [ + { value: STAFFING_GRANT, label: 'urn:cocoar:…:staffing', + subtitle: t('admin.oauthClients.grantTypes.staffingDescription', {}, 'Terminal-Client einer Position — Personal aktiviert per Passkey-Tap'), + icon: 'briefcase', group: t('admin.oauthClients.grantTypes.groupTerminal', {}, 'Terminal (Positionen)') }, +]) + const grantTypeOptions = computed(() => { const selected = new Set(form.value.AllowedGrantTypes) const cocoar = cocoarGrantTypeOptions.value.filter( (o) => nativeGrantsEnabled.value || selected.has(o.value)) - return [...standardGrantTypeOptions.value, ...cocoar] + const staffing = staffingGrantTypeOptions.value.filter((o) => selected.has(o.value)) + return [...standardGrantTypeOptions.value, ...cocoar, ...staffing] }) const scopeOptions = computed(() => { @@ -248,6 +263,8 @@ interface FormState { AppIds: string[] /** Required for a pure client_credentials client; immutable after creation. */ LinkedServiceAccountId: string + /** Set on terminal-managed clients (read-only viewer; editor = position modal). */ + LinkedPositionPrincipalId: string } const SCOPE_PERMISSION_PREFIX = 'scp:' @@ -290,6 +307,7 @@ function emptyForm(): FormState { WebAuthnRpId: '', AppIds: [], LinkedServiceAccountId: '', + LinkedPositionPrincipalId: '', } } @@ -345,6 +363,21 @@ function discardNewServiceAccountDraft() { newServiceAccountForm.value = { AccountName: '', Purpose: '', IsActive: true } } +// ── Terminal client (MG-FT) — terminal-managed clients are born via the +// position modal (or the API's staffing-grant path) and are read-only here: +// this modal degrades to a viewer with a deep-link to the owning position. +// The grid normally redirects before this modal even opens; the viewer +// covers direct fragment deep-links. +const isTerminalManaged = computed(() => !!form.value.LinkedPositionPrincipalId) + +function goToPosition() { + // Deliberately NOT props.close(): the routed-modal plumbing reacts to a + // resolved close by pushing the list route again, which would clobber this + // navigation. Changing the route unmounts the client list, which closes + // this modal itself, and the fragment opens the position modal over there. + void router.push(`/admin/positions#${form.value.LinkedPositionPrincipalId}`) +} + function fromDto(dto: OAuthClientDto): FormState { return { ClientId: dto.ClientId, @@ -371,6 +404,7 @@ function fromDto(dto: OAuthClientDto): FormState { WebAuthnRpId: dto.WebAuthnRpId ?? '', AppIds: [...(dto.AppIds ?? [])], LinkedServiceAccountId: dto.LinkedServiceAccountId ?? '', + LinkedPositionPrincipalId: dto.LinkedPositionPrincipalId ?? '', } } @@ -424,6 +458,17 @@ const footerButton = computed(() => { loading: false, onClick: () => props.close(), } + // Terminal-managed clients are a viewer here (modal-contract Viewer kind): + // every mutation path lives in the position modal, so there is nothing to + // save — only close. + if (isTerminalManaged.value) + return { + visible: true, + text: t('common.close', {}, 'Schließen'), + disabled: false, + loading: false, + onClick: () => props.close(), + } return { visible: true, text: isCreate.value ? t('common.create', {}, 'Create') : t('common.save', {}, 'Save'), @@ -614,6 +659,20 @@ async function copySecret() { {{ error }} + + +
+ + {{ t('admin.oauthClients.terminal.managedHint', {}, 'Terminal-Client einer Position — Verwaltung (deaktivieren, reaktivieren, widerrufen) erfolgt im Positions-Modal; diese Ansicht ist schreibgeschützt.') }} + + + {{ t('admin.oauthClients.terminal.goToPosition', {}, 'Zur Position') }} + +
+
+ diff --git a/src/frontend-vue/src/views/admin/oauth/ClientList.vue b/src/frontend-vue/src/views/admin/oauth/ClientList.vue index 11adcb57..ca0d9e24 100644 --- a/src/frontend-vue/src/views/admin/oauth/ClientList.vue +++ b/src/frontend-vue/src/views/admin/oauth/ClientList.vue @@ -14,6 +14,8 @@ import { useFragmentNavigation, useRoutedModals } from '@cocoar/vue-fragment-par import { useOAuthClientStore } from '@/stores/oauthClient.store' import { useAppContextStore } from '@/stores/appContext.store' import { useServiceAccountStore } from '@/stores/serviceAccount.store' +import { usePositionStore } from '@/stores/position.store' +import { useAppConfigStore } from '@/stores/appconfig.store' import { useUI } from '@/composables/useUI' import { useGridLocale } from '@/composables/useGridLocale' import { useClone, buildClonePrefill, CLIENT_CLONE } from '@/composables/useClone' @@ -29,6 +31,8 @@ const { stage } = useClone() const store = useOAuthClientStore() const appCtx = useAppContextStore() const saStore = useServiceAccountStore() +const positionStore = usePositionStore() +const appConfig = useAppConfigStore() const router = useRouter() // Resolve LinkedServiceAccountId → AccountName for the M2M column. Built @@ -47,6 +51,22 @@ function saNameFor(client: OAuthClientDto): string | null { return saNameById.value.get(client.LinkedServiceAccountId) ?? client.LinkedServiceAccountId } +// Resolve LinkedPositionPrincipalId → AccountName for the Terminal column — +// the position counterpart of the M2M column. Falls back to the raw id when +// the position list is not loaded (feature off / missing position:read). +const positionNameById = computed(() => { + const map = new Map() + for (const p of positionStore.entities) { + map.set(p.Id, p.AccountName) + } + return map +}) + +function positionNameFor(client: OAuthClientDto): string | null { + if (!client.LinkedPositionPrincipalId) return null + return positionNameById.value.get(client.LinkedPositionPrincipalId) ?? client.LinkedPositionPrincipalId +} + const ui = useUI() watch(language, () => ui.set((ctx) => { ctx.header.title = t('nav.administration', {}, 'Administration') @@ -93,6 +113,11 @@ const builder = applyListGridDefaults(CoarGridBuilder.create(), // clients are owned by a SA vs user-flow clients without opening each. (col) => col.field('LinkedServiceAccountId').header('M2M', 'admin.oauthClients.m2m').width(180) .option('valueGetter', (p: any) => p.data ? (saNameFor(p.data as OAuthClientDto) ?? '') : ''), + // Terminal column — the position counterpart of M2M: surfaces the owning + // position's AccountName for terminal-managed clients. The device fleet + // stays countable at a glance without opening each position. + (col) => col.field('LinkedPositionPrincipalId').header('Terminal', 'admin.oauthClients.terminal').width(180) + .option('valueGetter', (p: any) => p.data ? (positionNameFor(p.data as OAuthClientDto) ?? '') : ''), (col) => col.field('IsDynamicallyRegistered').header('DCR', 'admin.oauthClients.dcr').width(80) .option('valueGetter', (p: any) => p.data?.IsDynamicallyRegistered ? '●' : '') .option('cellStyle', { textAlign: 'center', color: 'var(--coar-accent-primary, #6366f1)' }), @@ -140,17 +165,28 @@ onMounted(async () => { await Promise.all([ store.initialize(), saStore.entities.length === 0 ? saStore.loadAll() : Promise.resolve(), + // Position names for the Terminal column — only while the feature is on + // (the endpoints 404 otherwise); a missing position:read just leaves the + // column showing raw ids. + appConfig.config.Features.PositionTerminals && positionStore.entities.length === 0 + ? positionStore.loadAll().catch(() => {}) + : Promise.resolve(), ]) }) // SA-managed clients are read-only from this grid — their authoritative // editor lives in the Service-Account modal. Deep-link there on -// double-click instead of opening ClientDetails. +// double-click instead of opening ClientDetails. Terminal-managed clients +// follow the same rule: their editor is the position modal. function openClient(client: OAuthClientDto) { if (client.LinkedServiceAccountId) { router.push(`/admin/service-accounts#${client.LinkedServiceAccountId}`) return } + if (client.LinkedPositionPrincipalId) { + router.push(`/admin/positions#${client.LinkedPositionPrincipalId}`) + return + } navigateToModal(client.Id) } diff --git a/src/frontend-vue/src/views/admin/position/PositionDetails.vue b/src/frontend-vue/src/views/admin/position/PositionDetails.vue index e1b48b97..1f39f10e 100644 --- a/src/frontend-vue/src/views/admin/position/PositionDetails.vue +++ b/src/frontend-vue/src/views/admin/position/PositionDetails.vue @@ -1,5 +1,5 @@