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:
+
+
+
+
+
+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.
+
+
+
+
+
+| 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
+
+
+
+
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 @@