iam: self-service ops, optional workspace filters, Mux service routing

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:
Cyber MacGeddon 2026-04-28 21:16:14 +01:00
parent 6302eb8c97
commit 8eba112f88
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. identifier. See *The Resource model* below.
- **`parameters`** — operation-specific data that the regime may - **`parameters`** — operation-specific data that the regime may
need to consider beyond the resource identifier. Used when a need to consider beyond the resource identifier. Used when a
decision depends on attributes the request supplies — e.g. an decision depends on attributes the request supplies — e.g.
admin scoped to one workspace creating a user *with workspace creating a user *with workspace association W*: the resource is
association W*: the resource is the system-level user registry, the system-level user registry, and W is a parameter the regime
and W is a parameter the regime checks against the admin's checks against the caller's permissions for `users:write`.
scope.
Different regimes use the four arguments differently — the OSS Different regimes use the four arguments differently — one regime
regime checks role bundles against the capability and the role's might evaluate role bundles whose grants carry workspace scope;
workspace scope against parameters; an SSO regime might consult an another might consult upstream IdP group memberships; an ABAC
upstream IdP's group memberships; an ABAC regime evaluates a regime evaluates a policy with all four as inputs. The contract
policy with all four as inputs. The contract is unchanged. is unchanged.
### `authorise_many` ### `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` `revoke-api-key`, `change-password`, `reset-password`
- Workspace management: `create-workspace`, `list-workspaces`, - Workspace management: `create-workspace`, `list-workspaces`,
`get-workspace`, `update-workspace`, `disable-workspace` `get-workspace`, `update-workspace`, `disable-workspace`
- Session management: `login` - Session management: `login`, `whoami`
- Key management: `get-signing-key-public`, `rotate-signing-key` - 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 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 where users are managed in the IdP) returns a defined "not
supported" error; the gateway surfaces it as a 501. 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 ## The `Identity` surface
`Identity` is *mostly* opaque. The gateway holds the value as a `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 - Credentials are API keys (opaque) or JWTs (Ed25519, locally
validated by the gateway against the regime's published public validated by the gateway against the regime's published public
key). key).
- `authorise` reduces to a role-and-workspace-scope check against - `authorise` reduces to a lookup against the role bundles in
the role table defined in [`capabilities.md`](capabilities.md). [`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. - Identity, user, and workspace records live in Cassandra.
The OSS regime is deliberately simple — three roles, single The OSS regime is deliberately simple — three roles, a single
home-workspace per user (a regime data-model decision, not a workspace association per user (a regime data-model decision, not
contract assertion), no policy language. 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 ### Future regimes

View file

@ -72,10 +72,16 @@ class IamRequest:
# login). # login).
workspace: str = "" workspace: str = ""
# Acting user id, for audit. Set by the gateway to the # Acting user id. Set by the gateway to the authenticated
# authenticated caller's id on user-initiated operations. # caller's identity handle for every authenticated request
# Empty for internal-origin (bootstrap, reconcilers) and for # (overwrites any caller-supplied value — the gateway is the
# resolve-api-key / login (no actor yet). # 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 = "" actor: str = ""
# --- identity selectors --- # --- identity selectors ---
@ -135,6 +141,11 @@ class IamResponse:
bootstrap_admin_user_id: str = "" bootstrap_admin_user_id: str = ""
bootstrap_admin_api_key: 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. # Present on any failed operation.
error: Error | None = None error: Error | None = None
``` ```
@ -201,25 +212,29 @@ class ApiKeyRecord:
| Operation | Request fields | Response fields | Notes | | 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. | | `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. | | `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. | | `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. | | `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` | Admin-only. `user.password` is hashed and stored; `user.roles` must be subset of known roles. | | `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` | `users` | | | `list-users` | `workspace` (optional filter) | `users` | If `workspace` omitted, returns the deployment-wide list. |
| `get-user` | `workspace`, `user_id` | `user` | | | `get-user` | `user_id`, `workspace` (optional integrity check) | `user` | |
| `update-user` | `workspace`, `user_id`, `user` | `user` | `password` field on `user` is rejected; use `change-password` / `reset-password`. | | `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` | `workspace`, `user_id` | — | Soft-delete; sets `enabled=false`. Revokes all the user's API keys. | | `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. | | `create-workspace` | `workspace_record` | `workspace` | System-level. |
| `list-workspaces` | — | `workspaces` | System-level. | | `list-workspaces` | — | `workspaces` | System-level. |
| `get-workspace` | `workspace_record` (id only) | `workspace` | System-level. | | `get-workspace` | `workspace_record` (id only) | `workspace` | System-level. |
| `update-workspace` | `workspace_record` | `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. | | `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. | | `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` | `workspace`, `user_id` | `api_keys` | | | `list-api-keys` | `user_id`, `workspace` (optional integrity check) | `api_keys` | |
| `revoke-api-key` | `workspace`, `key_id` | — | Deletes the key record. | | `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. | | `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). | | `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 ## 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 credentials and returns a signed JWT. The gateway returns the JWT to
the caller. 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 #### IAM service delegation
The gateway stays thin. Its authentication logic is: 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 Whether the resolved workspace is permitted to be operated on by
this caller is an **IAM decision**, not a gateway one. The gateway this caller is an **IAM decision**, not a gateway one. The gateway
calls `authorise(identity, capability, {workspace: ..., ...})` and calls `authorise(identity, capability, {workspace: ..., ...})` and
relays the answer. In the OSS regime, the answer comes from the relays the answer. In the OSS regime, the regime checks whether
caller's role × workspace-scope — see [`capabilities.md`](capabilities.md). the caller's permission grants for `<capability>` include this
In other regimes it could come from group mappings, policies, 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. relationship tuples, or anything else the regime models.
### Request anatomy ### 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`. | | `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`. | | `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 Workspace scope is a property of the *grant*, not of the user or
caller's bound workspace; `admin` is active across all workspaces. 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. The gateway gates each endpoint by *capability*, not by role.
Capabilities are declared per operation in the gateway's operation Capabilities are declared per operation in the gateway's operation
@ -647,6 +679,9 @@ For HTTP requests:
error, fail closed (401 / 503 per deployment). error, fail closed (401 / 503 per deployment).
8. Cache the decision per the contract's caching rules (clamped 8. Cache the decision per the contract's caching rules (clamped
above by a deployment-set ceiling). 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: For WebSocket connections:

View file

@ -41,6 +41,27 @@ class IamClient(RequestResponse):
) )
return resp.bootstrap_admin_user_id, resp.bootstrap_admin_api_key return resp.bootstrap_admin_user_id, resp.bootstrap_admin_api_key
async def bootstrap_status(self, timeout=IAM_TIMEOUT):
"""Returns whether an unconsumed ``bootstrap`` call would
currently succeed (i.e. iam-svc is in ``bootstrap`` mode and
its tables are empty). Side-effect-free; intended for first-
run UX so a UI can decide whether to render setup."""
resp = await self._request(
operation="bootstrap-status", timeout=timeout,
)
return resp.bootstrap_available
async def whoami(self, actor, timeout=IAM_TIMEOUT):
"""Return the user record for ``actor`` (the authenticated
caller's handle). AUTHENTICATED-only; no capability check —
every authenticated user can read themselves."""
resp = await self._request(
operation="whoami",
actor=actor,
timeout=timeout,
)
return resp.user
async def resolve_api_key(self, api_key, timeout=IAM_TIMEOUT): async def resolve_api_key(self, api_key, timeout=IAM_TIMEOUT):
"""Resolve a plaintext API key to its identity triple. """Resolve a plaintext API key to its identity triple.

View file

@ -185,6 +185,10 @@ class IamResponseTranslator(MessageTranslator):
result["bootstrap_admin_user_id"] = obj.bootstrap_admin_user_id result["bootstrap_admin_user_id"] = obj.bootstrap_admin_user_id
if obj.bootstrap_admin_api_key: if obj.bootstrap_admin_api_key:
result["bootstrap_admin_api_key"] = obj.bootstrap_admin_api_key result["bootstrap_admin_api_key"] = obj.bootstrap_admin_api_key
# bootstrap-status: emit unconditionally — the false case is
# meaningful for UIs deciding whether to render first-run
# setup, so it can't be dropped by a truthy-only filter.
result["bootstrap_available"] = bool(obj.bootstrap_available)
return result return result

View file

@ -148,6 +148,10 @@ class IamResponse:
bootstrap_admin_user_id: str = "" bootstrap_admin_user_id: str = ""
bootstrap_admin_api_key: str = "" bootstrap_admin_api_key: str = ""
# bootstrap-status — true iff iam-svc is in 'bootstrap' mode with
# empty tables, i.e. an unconsumed bootstrap call would succeed.
bootstrap_available: bool = False
# ---- authorise / authorise-many outputs ---- # ---- authorise / authorise-many outputs ----
# authorise: the regime's allow / deny verdict. # authorise: the regime's allow / deny verdict.
decision_allow: bool = False decision_allow: bool = False

View file

@ -44,6 +44,8 @@ tg-bootstrap-iam = "trustgraph.cli.bootstrap_iam:main"
tg-login = "trustgraph.cli.login:main" tg-login = "trustgraph.cli.login:main"
tg-create-user = "trustgraph.cli.create_user:main" tg-create-user = "trustgraph.cli.create_user:main"
tg-list-users = "trustgraph.cli.list_users:main" tg-list-users = "trustgraph.cli.list_users:main"
tg-whoami = "trustgraph.cli.whoami:main"
tg-update-user = "trustgraph.cli.update_user:main"
tg-disable-user = "trustgraph.cli.disable_user:main" tg-disable-user = "trustgraph.cli.disable_user:main"
tg-enable-user = "trustgraph.cli.enable_user:main" tg-enable-user = "trustgraph.cli.enable_user:main"
tg-delete-user = "trustgraph.cli.delete_user:main" tg-delete-user = "trustgraph.cli.delete_user:main"

View file

@ -0,0 +1,125 @@
"""
Update a user's profile fields: name, email, roles, enabled flag,
must-change-password flag.
Username is immutable create a new user and disable the old one
to effect a username change. Password changes go through
``tg-change-password`` (self-service) or ``tg-reset-password``
(admin-driven).
Only the fields you supply are changed; omitted fields are left
untouched on the user record. An empty ``--roles`` is rejected by
iam-svc (a user must have at least one role); to demote a user use
``tg-disable-user``.
"""
import argparse
import sys
from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main
def _parse_bool(s):
if s is None:
return None
s = s.strip().lower()
if s in ("yes", "y", "true", "t", "1"):
return True
if s in ("no", "n", "false", "f", "0"):
return False
raise argparse.ArgumentTypeError(
f"expected yes/no, got {s!r}"
)
def do_update_user(args):
user = {}
if args.name is not None:
user["name"] = args.name
if args.email is not None:
user["email"] = args.email
if args.roles is not None:
user["roles"] = args.roles
if args.enabled is not None:
user["enabled"] = args.enabled
if args.must_change_password is not None:
user["must_change_password"] = args.must_change_password
if not user:
print(
"tg-update-user: nothing to change — supply at least "
"one of --name / --email / --roles / --enabled / "
"--must-change-password",
file=sys.stderr,
)
sys.exit(2)
req = {
"operation": "update-user",
"user_id": args.user_id,
"user": user,
}
if args.workspace:
req["workspace"] = args.workspace
resp = call_iam(args.api_url, args.token, req)
rec = resp.get("user", {})
print(f"id : {rec.get('id', '')}")
print(f"username : {rec.get('username', '')}")
print(f"name : {rec.get('name', '')}")
print(f"email : {rec.get('email', '')}")
print(f"workspace : {rec.get('workspace', '')}")
print(f"roles : {', '.join(rec.get('roles', []))}")
print(f"enabled : {'yes' if rec.get('enabled') else 'no'}")
print(
f"must-change-pw: "
f"{'yes' if rec.get('must_change_password') else 'no'}"
)
def main():
parser = argparse.ArgumentParser(
prog="tg-update-user", description=__doc__,
)
parser.add_argument(
"-u", "--api-url", default=DEFAULT_URL,
help=f"API URL (default: {DEFAULT_URL})",
)
parser.add_argument(
"-t", "--token", default=DEFAULT_TOKEN,
help="Auth token (default: $TRUSTGRAPH_TOKEN)",
)
parser.add_argument(
"--user-id", required=True, help="Target user id",
)
parser.add_argument(
"--name", default=None, help="New display name",
)
parser.add_argument(
"--email", default=None, help="New email",
)
parser.add_argument(
"--roles", nargs="+", default=None,
help="Replacement role list (e.g. --roles reader writer)",
)
parser.add_argument(
"--enabled", type=_parse_bool, default=None,
help="Set enabled flag (yes/no)",
)
parser.add_argument(
"--must-change-password", type=_parse_bool, default=None,
help="Set must-change-password flag (yes/no)",
)
parser.add_argument(
"-w", "--workspace", default=None,
help=(
"Optional workspace integrity check — when supplied, "
"iam-svc verifies the target user's home workspace "
"matches"
),
)
run_main(do_update_user, parser)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,52 @@
"""
Show the authenticated caller's own user record.
"""
import argparse
import tabulate
from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main
def do_whoami(args):
resp = call_iam(args.api_url, args.token, {"operation": "whoami"})
user = resp.get("user")
if not user:
print("(no user record returned)")
return
rows = [
["id", user.get("id", "")],
["username", user.get("username", "")],
["name", user.get("name", "")],
["email", user.get("email", "")],
["workspace", user.get("workspace", "")],
["roles", ", ".join(user.get("roles", []))],
["enabled", "yes" if user.get("enabled") else "no"],
[
"must change password",
"yes" if user.get("must_change_password") else "no",
],
["created", user.get("created", "")],
]
print(tabulate.tabulate(rows, tablefmt="plain"))
def main():
parser = argparse.ArgumentParser(
prog="tg-whoami", description=__doc__,
)
parser.add_argument(
"-u", "--api-url", default=DEFAULT_URL,
help=f"API URL (default: {DEFAULT_URL})",
)
parser.add_argument(
"-t", "--token", default=DEFAULT_TOKEN,
help="Auth token (default: $TRUSTGRAPH_TOKEN)",
)
run_main(do_whoami, parser)
if __name__ == "__main__":
main()

View file

@ -123,14 +123,31 @@ class Mux:
# Per-service capability gating. Resolved through the # Per-service capability gating. Resolved through the
# operation registry so the WS path matches what HTTP # operation registry so the WS path matches what HTTP
# callers see — same authority, same caps. Service # callers see — same authority, same caps.
# kinds that aren't registered are refused. #
# Lookup mirrors the HTTP routing decision in
# ``request_task``: presence of ``flow`` on the envelope
# means a flow-level data-plane service (graph-rag,
# agent, …); absence means a workspace-level service
# (config, flow management, librarian, …) whose specific
# operation is in the inner request body. ``iam`` is
# treated as workspace-level too — its operations are
# registered with bare names, no kind prefix.
from ..registry import lookup as _registry_lookup from ..registry import lookup as _registry_lookup
from ..capabilities import enforce_workspace from ..capabilities import enforce_workspace
from aiohttp import web as _web from aiohttp import web as _web
service = data.get("service", "") service = data.get("service", "")
op = _registry_lookup(f"flow-service:{service}") inner = data.get("request") or {}
inner_op = inner.get("operation", "") if isinstance(inner, dict) else ""
if data.get("flow"):
op = _registry_lookup(f"flow-service:{service}")
elif service == "iam":
op = _registry_lookup(inner_op) if inner_op else None
else:
op = _registry_lookup(f"{service}:{inner_op}") if inner_op else None
if op is None: if op is None:
await self.ws.send_json({ await self.ws.send_json({
"id": request_id, "id": request_id,
@ -142,23 +159,36 @@ class Mux:
}) })
return return
# Workspace + flow form the resource address for a # Resolve workspace first (default-fill from the caller's
# flow-level service call. Resolve workspace first # bound workspace), then ask the regime to authorise the
# (default-fill from the caller's bound workspace), # service-level capability against the matched
# then ask the regime to authorise the service-level # operation's resource shape.
# capability against that {workspace, flow} resource.
try: try:
await enforce_workspace(data, self.identity, self.auth) await enforce_workspace(data, self.identity, self.auth)
inner = data.get("request")
if isinstance(inner, dict): if isinstance(inner, dict):
await enforce_workspace(inner, self.identity, self.auth) await enforce_workspace(inner, self.identity, self.auth)
resource = { if data.get("flow"):
"workspace": data.get("workspace", ""), resource = {
"flow": data.get("flow", ""), "workspace": data.get("workspace", ""),
} "flow": data.get("flow", ""),
}
parameters = {}
else:
# Build a minimal RequestContext so the matched
# operation's own extractors decide resource and
# parameters — same path the HTTP endpoints take.
from ..registry import RequestContext
ctx = RequestContext(
body=inner if isinstance(inner, dict) else {},
match_info={},
identity=self.identity,
)
resource = op.extract_resource(ctx)
parameters = op.extract_parameters(ctx)
await self.auth.authorise( await self.auth.authorise(
self.identity, op.capability, resource, {}, self.identity, op.capability, resource, parameters,
) )
except _web.HTTPForbidden: except _web.HTTPForbidden:
await self.ws.send_json({ await self.ws.send_json({
@ -183,6 +213,17 @@ class Mux:
workspace = data["workspace"] workspace = data["workspace"]
# Plumb authenticated caller's handle as ``actor`` so
# iam-svc handlers (whoami, future actor-scoped checks)
# know who is calling. Overwrite any caller-supplied
# value so it can't be spoofed over the WS.
if (
service == "iam"
and isinstance(data.get("request"), dict)
and self.identity is not None
):
data["request"]["actor"] = self.identity.handle
await self.q.put(( await self.q.put((
data["id"], data["id"],
workspace, workspace,

View file

@ -36,6 +36,10 @@ class AuthEndpoints:
app.add_routes([ app.add_routes([
web.post("/api/v1/auth/login", self.login), web.post("/api/v1/auth/login", self.login),
web.post("/api/v1/auth/bootstrap", self.bootstrap), web.post("/api/v1/auth/bootstrap", self.bootstrap),
web.post(
"/api/v1/auth/bootstrap-status",
self.bootstrap_status,
),
web.post( web.post(
"/api/v1/auth/change-password", "/api/v1/auth/change-password",
self.change_password, self.change_password,
@ -83,6 +87,18 @@ class AuthEndpoints:
) )
return web.json_response(resp) return web.json_response(resp)
async def bootstrap_status(self, request):
"""Public, side-effect-free. Returns ``{"bootstrap_available":
bool}`` so a UI can decide whether to render first-run setup
without invoking the consuming ``bootstrap`` op."""
await enforce(request, self.auth, PUBLIC)
resp = await self._forward({"operation": "bootstrap-status"})
if "error" in resp:
return web.json_response(
{"error": "auth failure"}, status=401,
)
return web.json_response(resp)
async def change_password(self, request): async def change_password(self, request):
"""Authenticated (any role). Accepts {current_password, """Authenticated (any role). Accepts {current_password,
new_password}; user_id is taken from the authenticated new_password}; user_id is taken from the authenticated

View file

@ -92,6 +92,14 @@ class IamEndpoint:
identity, op.capability, resource, parameters, identity, op.capability, resource, parameters,
) )
# Plumb the authenticated caller's handle through as ``actor``
# so iam-svc handlers (e.g. whoami, future actor-scoped
# checks) know who is making the request. The gateway is
# the only authority for this — body-supplied ``actor``
# values are overwritten so callers can't impersonate.
if identity is not None:
body["actor"] = identity.handle
async def responder(x, fin): async def responder(x, fin):
pass pass

View file

@ -271,27 +271,31 @@ register(Operation(
)) ))
# API keys: workspace-level resource — keys live within a workspace. # API keys: SYSTEM-level resource — like users, a key record exists
# in the deployment-wide keys registry. The workspace the key
# authenticates to is a property of the record, not a containment;
# it appears as a parameter so the regime can scope the admin's
# authority to issue / list / revoke against it.
register(Operation( register(Operation(
name="create-api-key", name="create-api-key",
capability="keys:admin", capability="keys:admin",
resource_level=ResourceLevel.WORKSPACE, resource_level=ResourceLevel.SYSTEM,
extract_resource=_workspace_from_body, extract_resource=_empty_resource,
extract_parameters=_no_parameters, extract_parameters=_workspace_param_only,
)) ))
register(Operation( register(Operation(
name="list-api-keys", name="list-api-keys",
capability="keys:admin", capability="keys:admin",
resource_level=ResourceLevel.WORKSPACE, resource_level=ResourceLevel.SYSTEM,
extract_resource=_workspace_from_body, extract_resource=_empty_resource,
extract_parameters=_no_parameters, extract_parameters=_workspace_param_only,
)) ))
register(Operation( register(Operation(
name="revoke-api-key", name="revoke-api-key",
capability="keys:admin", capability="keys:admin",
resource_level=ResourceLevel.WORKSPACE, resource_level=ResourceLevel.SYSTEM,
extract_resource=_workspace_from_body, extract_resource=_empty_resource,
extract_parameters=_no_parameters, extract_parameters=_workspace_param_only,
)) ))
@ -370,6 +374,13 @@ register(Operation(
extract_resource=_empty_resource, extract_resource=_empty_resource,
extract_parameters=_no_parameters, extract_parameters=_no_parameters,
)) ))
register(Operation(
name="bootstrap-status",
capability=PUBLIC,
resource_level=ResourceLevel.SYSTEM,
extract_resource=_empty_resource,
extract_parameters=_no_parameters,
))
register(Operation( register(Operation(
name="change-password", name="change-password",
capability=AUTHENTICATED, capability=AUTHENTICATED,
@ -377,6 +388,13 @@ register(Operation(
extract_resource=_empty_resource, extract_resource=_empty_resource,
extract_parameters=_no_parameters, extract_parameters=_no_parameters,
)) ))
register(Operation(
name="whoami",
capability=AUTHENTICATED,
resource_level=ResourceLevel.SYSTEM,
extract_resource=_empty_resource,
extract_parameters=_no_parameters,
))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -280,6 +280,10 @@ class IamService:
try: try:
if op == "bootstrap": if op == "bootstrap":
return await self.handle_bootstrap(v) return await self.handle_bootstrap(v)
if op == "bootstrap-status":
return await self.handle_bootstrap_status(v)
if op == "whoami":
return await self.handle_whoami(v)
if op == "resolve-api-key": if op == "resolve-api-key":
return await self.handle_resolve_api_key(v) return await self.handle_resolve_api_key(v)
if op == "create-user": if op == "create-user":
@ -483,6 +487,39 @@ class IamService:
bootstrap_admin_api_key=plaintext, bootstrap_admin_api_key=plaintext,
) )
async def handle_whoami(self, v):
"""Return the caller's own user record. ``v.actor`` is the
authenticated identity's handle (the gateway populates it
from ``identity.handle``). No ``users:read`` capability
required every authenticated user can read themselves."""
if not v.actor:
return _err(
"invalid-argument",
"actor required (gateway should populate this)",
)
user_row = await self.table_store.get_user(v.actor)
if user_row is None:
return _err("not-found", "user not found")
return IamResponse(user=self._row_to_user_record(user_row))
async def handle_bootstrap_status(self, v):
"""Probe op: returns whether the deployment is currently in
the unconsumed-bootstrap state (i.e. ``bootstrap`` mode with
empty tables, where an explicit ``bootstrap`` call would
succeed). PUBLIC so a UI can decide whether to render the
first-run setup flow without invoking the side-effectful
``bootstrap`` op.
The information leaked is intentionally narrow: an empty
deployment in bootstrap mode is already inferable (no users,
no logins succeed); this just makes the answer explicit
instead of forcing callers to probe the masked-failure path."""
available = (
self.bootstrap_mode == "bootstrap"
and not await self.table_store.any_workspace_exists()
)
return IamResponse(bootstrap_available=available)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Signing key helpers # Signing key helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -612,15 +649,22 @@ class IamService:
created=_iso(created), created=_iso(created),
) )
async def _user_in_workspace(self, user_id, workspace): async def _resolve_user(self, user_id, workspace=None):
"""Return (user_row, error_response_or_None). Loads the user """Return (user_row, error_response_or_None). Loads the user
record, verifies it exists, is enabled, and belongs to record by id and (when ``workspace`` is supplied) verifies the
``workspace``. The workspace scope check rejects cross- record's home workspace matches.
workspace admin attempts."""
Workspace is an *optional integrity check* the user record
is system-level, identified by id alone. If the caller asserts
a workspace, we verify; if they omit it, we just return the
record. Authorisation (whether the caller is permitted to
operate on this user) is the gateway's responsibility via the
contract's ``authorise`` call before the handler is reached.
"""
user_row = await self.table_store.get_user(user_id) user_row = await self.table_store.get_user(user_id)
if user_row is None: if user_row is None:
return None, _err("not-found", "user not found") return None, _err("not-found", "user not found")
if user_row[1] != workspace: if workspace and user_row[1] != workspace:
return None, _err( return None, _err(
"operation-not-permitted", "operation-not-permitted",
"user is in a different workspace", "user is in a different workspace",
@ -665,15 +709,10 @@ class IamService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def handle_reset_password(self, v): async def handle_reset_password(self, v):
if not v.workspace:
return _err(
"invalid-argument",
"workspace required for reset-password",
)
if not v.user_id: if not v.user_id:
return _err("invalid-argument", "user_id required") return _err("invalid-argument", "user_id required")
_, err = await self._user_in_workspace(v.user_id, v.workspace) _, err = await self._resolve_user(v.user_id, v.workspace or None)
if err is not None: if err is not None:
return err return err
@ -690,13 +729,11 @@ class IamService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def handle_get_user(self, v): async def handle_get_user(self, v):
if not v.workspace:
return _err("invalid-argument", "workspace required")
if not v.user_id: if not v.user_id:
return _err("invalid-argument", "user_id required") return _err("invalid-argument", "user_id required")
user_row, err = await self._user_in_workspace( user_row, err = await self._resolve_user(
v.user_id, v.workspace, v.user_id, v.workspace or None,
) )
if err is not None: if err is not None:
return err return err
@ -707,8 +744,6 @@ class IamService:
must_change_password. Username is immutable change it by must_change_password. Username is immutable change it by
creating a new user and disabling the old one. Password creating a new user and disabling the old one. Password
changes go through change-password / reset-password.""" changes go through change-password / reset-password."""
if not v.workspace:
return _err("invalid-argument", "workspace required")
if not v.user_id: if not v.user_id:
return _err("invalid-argument", "user_id required") return _err("invalid-argument", "user_id required")
if v.user is None: if v.user is None:
@ -719,25 +754,17 @@ class IamService:
"password cannot be changed via update-user; " "password cannot be changed via update-user; "
"use change-password or reset-password", "use change-password or reset-password",
) )
if v.user.username and v.user.username != "":
# Compare to existing. Username-change not allowed. existing, err = await self._resolve_user(
existing, err = await self._user_in_workspace( v.user_id, v.workspace or None,
v.user_id, v.workspace, )
if err is not None:
return err
if v.user.username and v.user.username != existing[2]:
return _err(
"invalid-argument",
"username is immutable; create a new user instead",
) )
if err is not None:
return err
if v.user.username != existing[2]:
return _err(
"invalid-argument",
"username is immutable; create a new user "
"instead",
)
else:
existing, err = await self._user_in_workspace(
v.user_id, v.workspace,
)
if err is not None:
return err
# Carry forward fields the caller didn't provide. # Carry forward fields the caller didn't provide.
( (
@ -774,12 +801,10 @@ class IamService:
async def handle_disable_user(self, v): async def handle_disable_user(self, v):
"""Soft-delete: set enabled=false and revoke every API key """Soft-delete: set enabled=false and revoke every API key
belonging to the user.""" belonging to the user."""
if not v.workspace:
return _err("invalid-argument", "workspace required")
if not v.user_id: if not v.user_id:
return _err("invalid-argument", "user_id required") return _err("invalid-argument", "user_id required")
_, err = await self._user_in_workspace(v.user_id, v.workspace) _, err = await self._resolve_user(v.user_id, v.workspace or None)
if err is not None: if err is not None:
return err return err
@ -797,12 +822,10 @@ class IamService:
async def handle_enable_user(self, v): async def handle_enable_user(self, v):
"""Re-enable a previously disabled user. Does not restore """Re-enable a previously disabled user. Does not restore
API keys those have to be re-issued by the admin.""" API keys those have to be re-issued by the admin."""
if not v.workspace:
return _err("invalid-argument", "workspace required")
if not v.user_id: if not v.user_id:
return _err("invalid-argument", "user_id required") return _err("invalid-argument", "user_id required")
_, err = await self._user_in_workspace(v.user_id, v.workspace) _, err = await self._resolve_user(v.user_id, v.workspace or None)
if err is not None: if err is not None:
return err return err
@ -821,29 +844,30 @@ class IamService:
cover GDPR erasure-style requirements). When audit logging cover GDPR erasure-style requirements). When audit logging
lands, the decision to delete vs. anonymise referenced audit lands, the decision to delete vs. anonymise referenced audit
rows will need to be revisited.""" rows will need to be revisited."""
if not v.workspace:
return _err("invalid-argument", "workspace required")
if not v.user_id: if not v.user_id:
return _err("invalid-argument", "user_id required") return _err("invalid-argument", "user_id required")
user_row, err = await self._user_in_workspace( user_row, err = await self._resolve_user(
v.user_id, v.workspace, v.user_id, v.workspace or None,
) )
if err is not None: if err is not None:
return err return err
# user_row indices match get_user columns. Username is [2]. # user_row indices match get_user columns. Username is [2].
username = user_row[2] username = user_row[2]
record_workspace = user_row[1]
# Revoke all API keys. # Revoke all API keys.
key_rows = await self.table_store.list_api_keys_by_user(v.user_id) key_rows = await self.table_store.list_api_keys_by_user(v.user_id)
for kr in key_rows: for kr in key_rows:
await self.table_store.delete_api_key(kr[0]) await self.table_store.delete_api_key(kr[0])
# Remove username lookup. # Remove username lookup — keyed on (workspace, username),
# so use the resolved workspace from the user record rather
# than relying on the caller-supplied filter.
if username: if username:
await self.table_store.delete_username_lookup( await self.table_store.delete_username_lookup(
v.workspace, username, record_workspace, username,
) )
# Remove user record. # Remove user record.
@ -1098,12 +1122,15 @@ class IamService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def handle_list_users(self, v): async def handle_list_users(self, v):
if not v.workspace: # System-level operation: workspace, when supplied, is a
return _err( # filter on the user record's home-workspace association.
"invalid-argument", "workspace required for list-users", # Empty workspace returns the deployment-wide list — the
) # gateway has already authorised the caller's authority to
# see that scope.
rows = await self.table_store.list_users_by_workspace(v.workspace) if v.workspace:
rows = await self.table_store.list_users_by_workspace(v.workspace)
else:
rows = await self.table_store.list_users()
return IamResponse( return IamResponse(
users=[self._row_to_user_record(r) for r in rows], users=[self._row_to_user_record(r) for r in rows],
) )
@ -1113,24 +1140,21 @@ class IamService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def handle_create_api_key(self, v): async def handle_create_api_key(self, v):
if not v.workspace:
return _err(
"invalid-argument", "workspace required for create-api-key",
)
if v.key is None or not v.key.user_id: if v.key is None or not v.key.user_id:
return _err("invalid-argument", "key.user_id required") return _err("invalid-argument", "key.user_id required")
if not v.key.name: if not v.key.name:
return _err("invalid-argument", "key.name required") return _err("invalid-argument", "key.name required")
# Target user must exist and belong to the caller's workspace. # API keys are system-level records with a workspace
user_row = await self.table_store.get_user(v.key.user_id) # association (the user's home workspace). Workspace is an
if user_row is None: # optional integrity check on the caller's request — when
return _err("not-found", "user not found") # supplied it must match the target user's home workspace;
if user_row[1] != v.workspace: # when omitted, the user's home workspace is used.
return _err( user_row, err = await self._resolve_user(
"operation-not-permitted", v.key.user_id, v.workspace or None,
"target user is in a different workspace", )
) if err is not None:
return err
plaintext = _generate_api_key() plaintext = _generate_api_key()
key_id = str(uuid.uuid4()) key_id = str(uuid.uuid4())
@ -1161,20 +1185,15 @@ class IamService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def handle_list_api_keys(self, v): async def handle_list_api_keys(self, v):
if not v.workspace:
return _err(
"invalid-argument",
"workspace required for list-api-keys",
)
if not v.user_id: if not v.user_id:
return _err( return _err(
"invalid-argument", "user_id required for list-api-keys", "invalid-argument", "user_id required for list-api-keys",
) )
# Workspace-scope check: user must live in this workspace. # Workspace is an optional integrity check.
user_row = await self.table_store.get_user(v.user_id) _, err = await self._resolve_user(v.user_id, v.workspace or None)
if user_row is None or user_row[1] != v.workspace: if err is not None:
return _err("not-found", "user not found in workspace") return err
rows = await self.table_store.list_api_keys_by_user(v.user_id) rows = await self.table_store.list_api_keys_by_user(v.user_id)
return IamResponse( return IamResponse(
@ -1186,11 +1205,6 @@ class IamService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def handle_revoke_api_key(self, v): async def handle_revoke_api_key(self, v):
if not v.workspace:
return _err(
"invalid-argument",
"workspace required for revoke-api-key",
)
if not v.key_id: if not v.key_id:
return _err("invalid-argument", "key_id required") return _err("invalid-argument", "key_id required")
@ -1199,13 +1213,15 @@ class IamService:
return _err("not-found", "api key not found") return _err("not-found", "api key not found")
key_hash, _id, user_id, _name, _prefix, _expires, _c, _lu = row key_hash, _id, user_id, _name, _prefix, _expires, _c, _lu = row
# Workspace-scope check via the owning user.
user_row = await self.table_store.get_user(user_id) # Workspace is an optional integrity check via the owning user.
if user_row is None or user_row[1] != v.workspace: if v.workspace:
return _err( user_row = await self.table_store.get_user(user_id)
"operation-not-permitted", if user_row is None or user_row[1] != v.workspace:
"key belongs to a different workspace", return _err(
) "operation-not-permitted",
"key belongs to a different workspace",
)
await self.table_store.delete_api_key(key_hash) await self.table_store.delete_api_key(key_hash)
return IamResponse() return IamResponse()

View file

@ -167,6 +167,11 @@ class IamTableStore:
roles, enabled, must_change_password, created roles, enabled, must_change_password, created
FROM iam_users WHERE workspace = ? ALLOW FILTERING FROM iam_users WHERE workspace = ? ALLOW FILTERING
""") """)
self.list_users_stmt = c.prepare("""
SELECT id, workspace, username, name, email, password_hash,
roles, enabled, must_change_password, created
FROM iam_users
""")
self.put_username_lookup_stmt = c.prepare(""" self.put_username_lookup_stmt = c.prepare("""
INSERT INTO iam_users_by_username (workspace, username, user_id) INSERT INTO iam_users_by_username (workspace, username, user_id)
@ -304,6 +309,15 @@ class IamTableStore:
self.cassandra, self.list_users_by_workspace_stmt, (workspace,), self.cassandra, self.list_users_by_workspace_stmt, (workspace,),
) )
async def list_users(self):
"""List every user across the deployment. Used by the
system-level list-users handler when no workspace filter is
supplied; the gateway has already authorised the call against
the caller's authority."""
return await async_execute(
self.cassandra, self.list_users_stmt, (),
)
async def delete_user(self, id): async def delete_user(self, id):
await async_execute( await async_execute(
self.cassandra, self.delete_user_stmt, (id,), self.cassandra, self.delete_user_stmt, (id,),