Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Agent Safety Rules

## Stable Docker environment and PostgreSQL boundary (non-negotiable)

- The Docker containers `modgud` and `postgres` form a stable, shared integration environment used by other applications and agents. Never treat them as disposable or exclusively owned by the current task.
- Read-only diagnosis of the stable environment is allowed. This includes `docker ps`, `docker inspect`, `docker logs`, HTTP health checks, and provably read-only SQL queries.
- Without explicit user authorization in the current conversation, never write to PostgreSQL on host port `5432`. This prohibition includes migrations, schema changes, seeds, DML, cleanup, restores, and starting any alternate or locally built Modgud process that connects to it.
- Without explicit user authorization, never stop, start, restart, remove, recreate, replace, reconfigure, or rebuild the stable `modgud` or `postgres` containers. Do not change their images, networks, ports, volumes, environment, or connection strings.
- Never bind a development process to the stable container's port or otherwise route development traffic in a way that replaces or masks the stable instance.
- Do not copy, derive, or reuse a connection string from the running `modgud` container for development or testing.
- All local development, UI verification, manually started backend processes, migrations, and seeds must use the `postgres-dev` container on host port `5433` and a development database.
- Before starting a backend, verify from its resolved configuration that it targets `postgres-dev`/port `5433`. If this cannot be established, do not start it.
- If `postgres-dev` is unavailable or unsuitable, stop and ask the user. Never fall back to `postgres`/port `5432`.
- A running Docker application, known credentials, prior access, or a request to test the UI is not authorization to mutate the stable environment.
14 changes: 11 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

Pre-1.0 development. See the [Roadmap](./docs/roadmap.md) for current
status, shipped features, and what's intentionally out of scope.
Day-to-day commit history lives in `git log`.
### Added

- Feature-flagged Positions and shared terminals (MG-FT-FLEX), including
multi-position terminal enrollment, configurable activation proofs and
device bindings, realm security floors, staffing/refresh/step-up lifecycle,
activation-token administration, and the matching admin UI and consumer
contract. `PositionTerminals` remains off by default and is enabled with
`AppSettings__Features__PositionTerminals=true`.

See the [Roadmap](./docs/roadmap.md) for the full pre-1.0 product snapshot and
what remains intentionally out of scope. Day-to-day history lives in `git log`.
72 changes: 36 additions & 36 deletions docs/admin/positions-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,17 @@ 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. |
| ② Terminal ↔ Position | **Where** may this post be staffed? | An authorization assignment. One terminal may carry several positions, selected for each shift. |
| ③ Device ↔ Terminal | **Which hardware** actually stands there? | At installation, exactly once. DPoP pins a device key, client-secret identifies its holder, while `none` deliberately leaves this link unproven. |

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

The realm security floor decides how strong links ① and ③ must be. A weaker
position policy cannot silently undercut that floor.

> 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
Expand Down Expand Up @@ -152,13 +155,14 @@ in front of it.</p>

## A position never authenticates — it gets activated

A position owns no credentials of its own (that is the difference to a
A position has no login credential 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:
anywhere). Every position token starts with an allowed activation proof — a
person proving themselves or a position-owned hardware token — **at an enrolled
terminal**. The chain is strict:

```
Position → terminal slot → enrolled device → unlock by an authorized person → session
Position → terminal assignment → enrolled device → allowed activation proof → session
```

No slot → no device → no unlock → never a token. A position without terminals
Expand Down Expand Up @@ -193,9 +197,10 @@ 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.
Accountability is **session-level, not action-level**. For a critical action,
the consumer can request a fresh step-up proof. Modgud then returns a separate
access token valid for at most 60 seconds; it may be bound to an action and
consumer nonce and is intended to be consumed once by `jti`.

## Which principal for which job?

Expand All @@ -210,30 +215,25 @@ 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.
## Policy choices and guard rails

How people and devices prove themselves is a per-position policy. Multiple
activation classes can be enabled together; DPoP + personal passkey remains
the recommended default.

- **Activation proof:** personal passkey, personal password, personal e-mail
OTP, or a **position-owned activation token**. The token is a logical,
individually revocable object with an RP-bound WebAuthn credential; the audit
names the token rather than a person. `team-secret` is reserved for a future
feature and is deliberately unavailable today.
- **Device binding:** DPoP key, client secret, or none. Client-secret and none
still run the complete admin-approved Device Flow; `none` only removes a
cryptographic device identity and is appropriate only where the physical and
network controls justify it.
- **Realm guard rails:** the realm declares required proof and binding
capabilities. Tightening a floor first previews affected positions and, when
confirmed, immediately ends sessions that no longer comply.
- **Multi-position terminals:** one device may serve several positions
("reception" by day, "night gate" after hours). New assignments are fixed
before enrollment; adding one later requires a replacement slot and fresh
approval. Exactly one active shift still exists per terminal.
121 changes: 81 additions & 40 deletions docs/admin/positions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

A **position** is a business identity that changing people staff in shifts —
"gate porter for customer XY", "reception HQ". Unlike a user or a service
account, a position never owns credentials: its tokens are minted when an
authorized person taps their passkey on an **enrolled shared terminal**.
account, a position never signs in directly: its tokens are minted only after
an allowed activation proof succeeds on an **enrolled shared terminal**.
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.
Expand All @@ -27,6 +27,12 @@ events) lives under
namespace with user and service-account names.
- **Terminal use** — off by default. Terminal slots can only be created
and enrolled while this is on.
- **Activation proofs** — one or more of personal passkey, personal password,
personal e-mail OTP, or a position-owned activation token. Team secret is a
reserved wire ID and is not selectable yet.
- **Device bindings** — one or more of DPoP, client secret, or no binding.
DPoP is the recommended default; the weaker choices are explicit policy
decisions and may be forbidden by the realm security floor.
- **Staffing session (minutes)** — how long one shift lives (default
960 = 16 h). **Absolute maximum** — the hard ceiling no refresh can
extend past (default 1440 = 24 h). Access tokens stay short-lived
Expand All @@ -48,10 +54,11 @@ staff this position". One live grant per (position, user); grants are
suspend-/resume-/revocable, revoke is final (re-authorizing later creates
a fresh grant with its own audit trail).

Watch the **"No passkey" badge**: staffing happens by passkey tap, so a
grantee without a passkey under the terminals' RP-ID cannot actually
activate the position. Have them register a passkey in their account
settings first.
The **"No passkey" badge** matters when `personal-passkey` is enabled. A user
may still activate with password or e-mail OTP when the position permits that
method. Password and OTP failures are locked per grant as well as rate-limited
per source IP; changing/resetting the password or disabling e-mail OTP ends
sessions established with that proof.

Suspending or revoking a grant **immediately ends** that person's running
staffing sessions and revokes the session tokens.
Expand All @@ -60,57 +67,89 @@ staffing sessions and revokes the session tokens.

**Position detail → Terminals** (or the same tab while creating the
position). One slot per physical device.
Each slot atomically creates its own locked-down OAuth client (public,
no secret, DPoP mandatory, reference tokens — the generic OAuth admin
surface is read-only for it).
Each slot atomically creates its own locked-down OAuth client (reference
tokens; the generic OAuth admin surface is read-only for it). The selected
binding fixes the client profile:

| Binding | Client | Device identity |
|---|---|---|
| `dpop` | public, no secret | enrolled P-256 key; DPoP required |
| `client-secret` | confidential | one-time-displayed secret |
| `none` | public, no secret | no cryptographic device identity |

- **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. 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.
whoever installs the terminal device. For `client-secret`, copy the secret
immediately; it is never returned again.
- A new slot can be assigned to several compatible positions before
enrollment. One terminal may then staff any of them, but still runs only one
staffing session at a time. Removing an assignment is immediate. Adding an
assignment after enrollment is intentionally rejected: create a replacement
multi-position slot and run Device Flow again.

### 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`.
There are two equivalent UI entry points for creating a terminal slot:

- **Position detail → Terminals** starts with the business position and adds
one or more slots.
- **OAuth Clients → Create → staffing** starts with the technical client. As
with `client_credentials` and Service Accounts, you then choose an existing
Position or draft a new one in the same dialog.

Selecting `staffing` is a **terminal profile**, not a freely combinable grant.
The dialog replaces the grant selection with the fixed package `device_code +
refresh_token + staffing`; browser login, native-login and
`client_credentials` grants cannot be added. Position (if new), slot, and
client land in one atomic save. The server derives the remaining OAuth profile
from the chosen binding (reference tokens; public + DPoP, confidential + client
secret, or public + no binding) and generates the `client_id`
(`terminal.{suffix}`).

After creation, terminal clients stay visible in the **OAuth Clients grid** as
inventory. Their lifecycle is managed from the Position detail, so opening an
existing terminal client is read-only and links back to its slot — the same
ownership rule that SA-managed clients follow with the Service-Account editor.

For automation, the same contract is available through
`POST /api/admin/oauth/clients`: reference an existing position
(`LinkedPositionPrincipalId`) or inline-create one (`NewPosition`) — never
both. 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
**device-key fingerprint** (`XXXX-XXXX`). Open the verification link (or
enter the code at `/device`), and you'll see the terminal consent:
position, terminal, location, client, and the fingerprint of the key that
made the request.
Every binding uses the complete RFC 8628 Device Flow and explicit admin
approval. The device starts enrollment and shows a **user code**. Open the
verification link (or enter the code at `/device`) to see position(s), terminal,
location, client, and binding.

**Compare the fingerprint with what the device shows** — that is the
whole point of the ceremony: you are permanently binding THIS device's
key to the slot. Approving requires the `position-terminal:enroll`
For DPoP, also compare the **device-key fingerprint** (`XXXX-XXXX`) with the
device display before approving; the enrollment pins that key permanently.
For client-secret, the device authenticates with its one-time secret. With no
binding, approval is the sole issuance barrier and the consent highlights that
risk. Approving requires the `position-terminal:enroll`
permission (deliberately separate from `position:write` — registering a
physical device is a higher-trust act).

Enrollment is one-shot: an enrolled slot can never be re-enrolled with a
different key. Device replaced or key lost? Revoke the slot and create a
fresh one.
Enrollment is one-shot for every binding. Device replaced, key/secret lost,
or positions added? Revoke the slot and create a fresh one.

## 5. Position-owned activation tokens

**Position detail → Activation tokens.** A logical token can be assigned to
one or more positions, disabled/reactivated, or permanently revoked. Its
WebAuthn credential is registered from an enrolled terminal so browser origin
and terminal RP-ID match. The credential is therefore RP-bound; register the
same logical token separately for each consuming RP where it must work.

The staffing audit records the logical token and credential, not a person.
Unassigning or revoking it immediately ends every session established with it.

## 5. Monitor & intervene
## 6. Monitor & intervene

**Position detail → Staffing sessions** (requires
`staffing-session:read`): every shift with terminal, **who
Expand All @@ -126,7 +165,9 @@ events), start, absolute end, and the end reason.
also deletes the slot's OAuth client.
- Everything cascades automatically: deactivating the position, binning
the user, deleting the used passkey, or revoking the grant all end the
affected sessions immediately. Expired sessions are swept by the
affected sessions immediately. The same applies to password/OTP changes,
activation-token invalidation, policy tightening, or removing a terminal's
position assignment. Expired sessions are swept by the
`staffing-sweep` system job (every 5 minutes).

## Permissions reference
Expand Down
Loading
Loading