iam: self-service ops, optional workspace filters, Mux service routing (#855)

Three threads, all reinforcing the contract's system-level vs.
workspace-association distinction.

WS Mux service routing
- tg-show-flows (and any workspace-level service over the WS) was
  failing with "unknown service" because the post-refactor Mux
  unconditionally looked up flow-service:<kind>.  Now branches on
  the envelope's flow field: with flow → flow-service:<kind>;
  without flow → <kind>:<op> from the inner body; with bare op
  lookup for service=iam.  Resource and parameters come from the
  matched op's own extractors — same path the HTTP endpoints take.

Optional workspace on system-level user/key ops
- list-users returns the deployment-wide list when no workspace is
  supplied, filters when one is.  get-user, update-user,
  disable-user, enable-user, delete-user, reset-password,
  create-api-key, list-api-keys, revoke-api-key all treat workspace
  as an optional integrity check rather than a required argument.
- create-user keeps workspace required — there it's the new user's
  home-workspace binding, a parameter rather than an address.
- API keys reclassified as SYSTEM-level resources.  By the same
  reasoning that makes users system-level, an API key is a
  credential record on a deployment-wide registry; the workspace it
  authenticates to is a property, not a containment.

Self-service surface
- whoami: returns the caller's own user record.  AUTHENTICATED-only;
  no users:read capability required.  Foundation for UI affordances
  that depend on the caller's permissions.
- bootstrap-status: POST /api/v1/auth/bootstrap-status, PUBLIC,
  side-effect-free.  Returns {bootstrap_available: bool} so a
  first-run UI can decide whether to render setup without consuming
  the bootstrap op.
- Gateway now injects actor=identity.handle on every authenticated
  forward to iam-svc (IamEndpoint and WS Mux iam path), overwriting
  any caller-supplied value.  Underpins whoami, audit logging, and
  future regime-side decisions that need actor identity.
- tg-whoami and tg-update-user CLIs.

Spec polish
- iam-contract.md: actor-injection rule documented; whoami /
  bootstrap-status added to operations list; permission-scope
  framing tightened (workspace scope is a property of the grant,
  not the user or role).
- iam.md: self-service section; gateway flow gains the actor-
  injection step; role section reframed so iam-svc constraints
  don't leak into contract-level prose.
- iam-protocol.md: ops table updated for whoami, bootstrap-status,
  optional-workspace pattern; bootstrap_available added to the
  IamResponse listing.
This commit is contained in:
cybermaggedon 2026-04-28 22:13:12 +01:00 committed by GitHub
parent 6302eb8c97
commit 9fc1d4527b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 555 additions and 147 deletions

View file

@ -83,17 +83,16 @@ The four arguments separate concerns:
identifier. See *The Resource model* below.
- **`parameters`** — operation-specific data that the regime may
need to consider beyond the resource identifier. Used when a
decision depends on attributes the request supplies — e.g. an
admin scoped to one workspace creating a user *with workspace
association W*: the resource is the system-level user registry,
and W is a parameter the regime checks against the admin's
scope.
decision depends on attributes the request supplies — e.g.
creating a user *with workspace association W*: the resource is
the system-level user registry, and W is a parameter the regime
checks against the caller's permissions for `users:write`.
Different regimes use the four arguments differently — the OSS
regime checks role bundles against the capability and the role's
workspace scope against parameters; an SSO regime might consult an
upstream IdP's group memberships; an ABAC regime evaluates a
policy with all four as inputs. The contract is unchanged.
Different regimes use the four arguments differently — one regime
might evaluate role bundles whose grants carry workspace scope;
another might consult upstream IdP group memberships; an ABAC
regime evaluates a policy with all four as inputs. The contract
is unchanged.
### `authorise_many`
@ -129,14 +128,49 @@ most of them) but the operation set the gateway can forward is:
`revoke-api-key`, `change-password`, `reset-password`
- Workspace management: `create-workspace`, `list-workspaces`,
`get-workspace`, `update-workspace`, `disable-workspace`
- Session management: `login`
- Session management: `login`, `whoami`
- Key management: `get-signing-key-public`, `rotate-signing-key`
- Bootstrap: `bootstrap`
- Bootstrap: `bootstrap`, `bootstrap-status`
`whoami` is the self-read counterpart to `get-user`: any
authenticated caller can read their own identity record without
holding a user-management capability. It is the gating-free probe
a UI uses to render affordances appropriate to the caller's role.
`bootstrap-status` is a side-effect-free probe of whether an
unconsumed `bootstrap` call would currently succeed. It exists so
a first-run UI can decide whether to render setup without invoking
the consuming `bootstrap` op. Public — no authentication.
A regime that does not support one of these (e.g. an SSO regime
where users are managed in the IdP) returns a defined "not
supported" error; the gateway surfaces it as a 501.
### Actor injection
For any management operation forwarded by the gateway after
authentication, the gateway injects the authenticated caller's
`handle` as an `actor` field on the request. Regimes use `actor`
to identify *who is making the request* — distinct from the
operation's target (which lives in `user_id` / `key_id` /
`workspace_record` / etc.) — for purposes such as:
- Self-service operations (`whoami`, `change-password`) that
resolve "the caller" without taking a target argument.
- Audit logging, where the actor is recorded against the change.
- Decisions that depend on the resolved resource state. The
gateway authorises against the parameters on the request, but it
cannot know the resolved resource's actual properties (e.g. the
workspace association of a target user) before the regime loads
it. When that matters, the regime can re-decide using the
actor's permissions and the resolved record — closing a class
of cases the gateway-side check can't see.
Caller-supplied `actor` values on the request body are overwritten
by the gateway — the gateway is the only authority for actor
identity, and a regime that consults `actor` can rely on it being
authentic.
## The `Identity` surface
`Identity` is *mostly* opaque. The gateway holds the value as a
@ -327,13 +361,16 @@ contract via:
- Credentials are API keys (opaque) or JWTs (Ed25519, locally
validated by the gateway against the regime's published public
key).
- `authorise` reduces to a role-and-workspace-scope check against
the role table defined in [`capabilities.md`](capabilities.md).
- `authorise` reduces to a lookup against the role bundles in
[`capabilities.md`](capabilities.md), with each grant's workspace
scope checked against the operation's workspace component.
- Identity, user, and workspace records live in Cassandra.
The OSS regime is deliberately simple — three roles, single
home-workspace per user (a regime data-model decision, not a
contract assertion), no policy language.
The OSS regime is deliberately simple — three roles, a single
workspace association per user (a regime data-model decision, not
a contract assertion), no policy language. Other regimes can
grant the same user different permissions in different workspaces
without changing anything outside the regime.
### Future regimes

View file

@ -72,10 +72,16 @@ class IamRequest:
# login).
workspace: str = ""
# Acting user id, for audit. Set by the gateway to the
# authenticated caller's id on user-initiated operations.
# Empty for internal-origin (bootstrap, reconcilers) and for
# resolve-api-key / login (no actor yet).
# Acting user id. Set by the gateway to the authenticated
# caller's identity handle for every authenticated request
# (overwrites any caller-supplied value — the gateway is the
# only authority for actor identity, so handlers can rely on it
# being authentic). Used for audit logging, self-service ops
# like ``whoami`` that resolve "the caller", and future actor-
# scoped policy checks. Empty for unauthenticated ops
# (``login``, ``bootstrap``, ``bootstrap-status``,
# ``get-signing-key-public``, ``resolve-api-key``). See the
# actor-injection rule in the IAM contract spec.
actor: str = ""
# --- identity selectors ---
@ -135,6 +141,11 @@ class IamResponse:
bootstrap_admin_user_id: str = ""
bootstrap_admin_api_key: str = ""
# bootstrap-status: true iff an unconsumed ``bootstrap`` call
# would currently succeed. Always emitted by the response
# translator (the false case is meaningful for first-run UIs).
bootstrap_available: bool = False
# Present on any failed operation.
error: Error | None = None
```
@ -201,25 +212,29 @@ class ApiKeyRecord:
| Operation | Request fields | Response fields | Notes |
|---|---|---|---|
| `login` | `username`, `password`, `workspace` (optional) | `jwt`, `jwt_expires` | If `workspace` omitted, IAM resolves to the user's assigned workspace. |
| `whoami` | `actor` (gateway-injected) | `user` | Returns the calling user's own record. AUTHENTICATED-only; no `users:read` capability required. |
| `resolve-api-key` | `api_key` (plaintext) | `resolved_user_id`, `resolved_workspace`, `resolved_roles` | Gateway-internal. Service returns `auth-failed` for unknown / expired / revoked keys. |
| `change-password` | `user_id`, `password` (current), `new_password` | — | Self-service. IAM validates `password` against stored hash. |
| `reset-password` | `user_id` | `temporary_password` | Admin-initiated. IAM generates a random password, sets `must_change_password=true` on the user, returns the plaintext once. |
| `create-user` | `workspace`, `user` | `user` | Admin-only. `user.password` is hashed and stored; `user.roles` must be subset of known roles. |
| `list-users` | `workspace` | `users` | |
| `get-user` | `workspace`, `user_id` | `user` | |
| `update-user` | `workspace`, `user_id`, `user` | `user` | `password` field on `user` is rejected; use `change-password` / `reset-password`. |
| `disable-user` | `workspace`, `user_id` | — | Soft-delete; sets `enabled=false`. Revokes all the user's API keys. |
| `reset-password` | `user_id`, `workspace` (optional integrity check) | `temporary_password` | Admin-initiated. IAM generates a random password, sets `must_change_password=true` on the user, returns the plaintext once. |
| `create-user` | `workspace`, `user` | `user` | `user.password` is hashed and stored; `user.roles` must be subset of known roles. `workspace` is the new user's home-workspace binding (a required *parameter*, not an address). |
| `list-users` | `workspace` (optional filter) | `users` | If `workspace` omitted, returns the deployment-wide list. |
| `get-user` | `user_id`, `workspace` (optional integrity check) | `user` | |
| `update-user` | `user_id`, `user`, `workspace` (optional integrity check) | `user` | `password` field on `user` is rejected; use `change-password` / `reset-password`. Username is immutable. |
| `disable-user` | `user_id`, `workspace` (optional integrity check) | — | Soft-delete; sets `enabled=false`. Revokes all the user's API keys. |
| `enable-user` | `user_id`, `workspace` (optional integrity check) | — | Re-enables a previously disabled user; does not restore API keys. |
| `delete-user` | `user_id`, `workspace` (optional integrity check) | — | Hard-delete; removes user record, username lookup, and all the user's API keys. |
| `create-workspace` | `workspace_record` | `workspace` | System-level. |
| `list-workspaces` | — | `workspaces` | System-level. |
| `get-workspace` | `workspace_record` (id only) | `workspace` | System-level. |
| `update-workspace` | `workspace_record` | `workspace` | System-level. |
| `disable-workspace` | `workspace_record` (id only) | — | System-level. Sets `enabled=false`; revokes all workspace API keys; disables all users in the workspace. |
| `create-api-key` | `workspace`, `key` | `api_key_plaintext`, `api_key` | Plaintext returned **once**; only hash stored. `key.name` required. |
| `list-api-keys` | `workspace`, `user_id` | `api_keys` | |
| `revoke-api-key` | `workspace`, `key_id` | — | Deletes the key record. |
| `create-api-key` | `key`, `workspace` (optional integrity check) | `api_key_plaintext`, `api_key` | Plaintext returned **once**; only hash stored. `key.name` required. |
| `list-api-keys` | `user_id`, `workspace` (optional integrity check) | `api_keys` | |
| `revoke-api-key` | `key_id`, `workspace` (optional integrity check) | — | Deletes the key record. |
| `get-signing-key-public` | — | `signing_key_public` | Gateway fetches this at startup. |
| `rotate-signing-key` | — | — | System-level. Introduces a new signing key; old key continues to validate JWTs for a grace period (implementation-defined, minimum 1h). |
| `bootstrap` | — | `bootstrap_admin_user_id`, `bootstrap_admin_api_key` | If IAM tables are empty, creates the initial `default` workspace, an `admin` user, an initial API key, and an initial signing key; returns them once. No-op on subsequent calls (returns empty fields). |
| `bootstrap` | — | `bootstrap_admin_user_id`, `bootstrap_admin_api_key` | If IAM tables are empty and the service is in `bootstrap` mode, creates the initial `default` workspace, an `admin` user, an initial API key, and an initial signing key; returns them once. Otherwise returns a masked auth failure. |
| `bootstrap-status` | — | `bootstrap_available` | Side-effect-free probe; `true` iff iam-svc is in `bootstrap` mode and tables are empty. Intended for first-run UX. |
## Error taxonomy

View file

@ -268,6 +268,26 @@ The gateway forwards this to the IAM service, which validates
credentials and returns a signed JWT. The gateway returns the JWT to
the caller.
#### Self-service: `whoami` and `bootstrap-status`
Two side-effect-free probes that exist to support UI affordances
without giving the caller broad read access:
- `POST /api/v1/iam` with `{"operation": "whoami"}` — authenticated
only. Returns the caller's own user record (id, username, name,
email, workspace, roles, enabled, must_change_password,
created). No `users:read` capability is required, because every
authenticated caller can read themselves. The gateway populates
`actor` on the request from the authenticated identity, so the
regime resolves "the caller" without taking a target argument.
- `POST /api/v1/auth/bootstrap-status` — public, side-effect-free.
Returns `{"bootstrap_available": true|false}`. `true` iff
iam-svc is in `bootstrap` mode and its tables are empty (i.e. an
unconsumed `bootstrap` call would currently succeed). Exists so
a first-run UI can decide whether to render the setup flow
without invoking the consuming `bootstrap` op.
#### IAM service delegation
The gateway stays thin. Its authentication logic is:
@ -387,9 +407,10 @@ workspace; every `authorise` call sees a concrete value.
Whether the resolved workspace is permitted to be operated on by
this caller is an **IAM decision**, not a gateway one. The gateway
calls `authorise(identity, capability, {workspace: ..., ...})` and
relays the answer. In the OSS regime, the answer comes from the
caller's role × workspace-scope — see [`capabilities.md`](capabilities.md).
In other regimes it could come from group mappings, policies,
relays the answer. In the OSS regime, the regime checks whether
the caller's permission grants for `<capability>` include this
workspace — see [`capabilities.md`](capabilities.md). In other
regimes the decision could come from group mappings, policies,
relationship tuples, or anything else the regime models.
### Request anatomy
@ -500,8 +521,19 @@ The OSS regime ships three roles:
| `writer` | All reader capabilities, plus `graph:write`, `documents:write`, `rows:write`, `knowledge:write`, `collections:write`. |
| `admin` | All writer capabilities, plus `config:write`, `flows:write`, `users:read`, `users:write`, `users:admin`, `keys:admin`, `workspaces:admin`, `iam:admin`, `metrics:read`. |
Workspace scope: `reader` and `writer` are active only in the
caller's bound workspace; `admin` is active across all workspaces.
Workspace scope is a property of the *grant*, not of the user or
role. In the OSS regime each capability granted by `reader` /
`writer` is scoped to the workspace the user record is associated
with; capabilities granted by `admin` are scoped to `*` (every
workspace). A user is a system-level object — they don't "live
in" a workspace, they hold permissions whose scope happens to
reference one.
The OSS regime is deliberately limited to one workspace association
per user; future regimes are free to grant the same user different
permissions in different workspaces, or use a non-workspace scope
entirely. This is regime-internal — neither the contract nor the
gateway carries an assumption either way.
The gateway gates each endpoint by *capability*, not by role.
Capabilities are declared per operation in the gateway's operation
@ -647,6 +679,9 @@ For HTTP requests:
error, fail closed (401 / 503 per deployment).
8. Cache the decision per the contract's caching rules (clamped
above by a deployment-set ceiling).
9. For requests forwarded to iam-svc, set `actor` on the body
from `identity.handle`, overwriting any caller-supplied value.
See [`iam-contract.md`](iam-contract.md#actor-injection).
For WebSocket connections: