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:

View file

@ -41,6 +41,27 @@ class IamClient(RequestResponse):
)
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):
"""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
if 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

View file

@ -148,6 +148,10 @@ class IamResponse:
bootstrap_admin_user_id: 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: the regime's allow / deny verdict.
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-create-user = "trustgraph.cli.create_user: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-enable-user = "trustgraph.cli.enable_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
# operation registry so the WS path matches what HTTP
# callers see — same authority, same caps. Service
# kinds that aren't registered are refused.
# callers see — same authority, same caps.
#
# 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 ..capabilities import enforce_workspace
from aiohttp import web as _web
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:
await self.ws.send_json({
"id": request_id,
@ -142,23 +159,36 @@ class Mux:
})
return
# Workspace + flow form the resource address for a
# flow-level service call. Resolve workspace first
# (default-fill from the caller's bound workspace),
# then ask the regime to authorise the service-level
# capability against that {workspace, flow} resource.
# Resolve workspace first (default-fill from the caller's
# bound workspace), then ask the regime to authorise the
# service-level capability against the matched
# operation's resource shape.
try:
await enforce_workspace(data, self.identity, self.auth)
inner = data.get("request")
if isinstance(inner, dict):
await enforce_workspace(inner, self.identity, self.auth)
resource = {
"workspace": data.get("workspace", ""),
"flow": data.get("flow", ""),
}
if data.get("flow"):
resource = {
"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(
self.identity, op.capability, resource, {},
self.identity, op.capability, resource, parameters,
)
except _web.HTTPForbidden:
await self.ws.send_json({
@ -183,6 +213,17 @@ class Mux:
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((
data["id"],
workspace,

View file

@ -36,6 +36,10 @@ class AuthEndpoints:
app.add_routes([
web.post("/api/v1/auth/login", self.login),
web.post("/api/v1/auth/bootstrap", self.bootstrap),
web.post(
"/api/v1/auth/bootstrap-status",
self.bootstrap_status,
),
web.post(
"/api/v1/auth/change-password",
self.change_password,
@ -83,6 +87,18 @@ class AuthEndpoints:
)
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):
"""Authenticated (any role). Accepts {current_password,
new_password}; user_id is taken from the authenticated

View file

@ -92,6 +92,14 @@ class IamEndpoint:
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):
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(
name="create-api-key",
capability="keys:admin",
resource_level=ResourceLevel.WORKSPACE,
extract_resource=_workspace_from_body,
extract_parameters=_no_parameters,
resource_level=ResourceLevel.SYSTEM,
extract_resource=_empty_resource,
extract_parameters=_workspace_param_only,
))
register(Operation(
name="list-api-keys",
capability="keys:admin",
resource_level=ResourceLevel.WORKSPACE,
extract_resource=_workspace_from_body,
extract_parameters=_no_parameters,
resource_level=ResourceLevel.SYSTEM,
extract_resource=_empty_resource,
extract_parameters=_workspace_param_only,
))
register(Operation(
name="revoke-api-key",
capability="keys:admin",
resource_level=ResourceLevel.WORKSPACE,
extract_resource=_workspace_from_body,
extract_parameters=_no_parameters,
resource_level=ResourceLevel.SYSTEM,
extract_resource=_empty_resource,
extract_parameters=_workspace_param_only,
))
@ -370,6 +374,13 @@ register(Operation(
extract_resource=_empty_resource,
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(
name="change-password",
capability=AUTHENTICATED,
@ -377,6 +388,13 @@ register(Operation(
extract_resource=_empty_resource,
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:
if op == "bootstrap":
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":
return await self.handle_resolve_api_key(v)
if op == "create-user":
@ -483,6 +487,39 @@ class IamService:
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
# ------------------------------------------------------------------
@ -612,15 +649,22 @@ class IamService:
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
record, verifies it exists, is enabled, and belongs to
``workspace``. The workspace scope check rejects cross-
workspace admin attempts."""
record by id and (when ``workspace`` is supplied) verifies the
record's home workspace matches.
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)
if user_row is None:
return None, _err("not-found", "user not found")
if user_row[1] != workspace:
if workspace and user_row[1] != workspace:
return None, _err(
"operation-not-permitted",
"user is in a different workspace",
@ -665,15 +709,10 @@ class IamService:
# ------------------------------------------------------------------
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:
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:
return err
@ -690,13 +729,11 @@ class IamService:
# ------------------------------------------------------------------
async def handle_get_user(self, v):
if not v.workspace:
return _err("invalid-argument", "workspace required")
if not v.user_id:
return _err("invalid-argument", "user_id required")
user_row, err = await self._user_in_workspace(
v.user_id, v.workspace,
user_row, err = await self._resolve_user(
v.user_id, v.workspace or None,
)
if err is not None:
return err
@ -707,8 +744,6 @@ class IamService:
must_change_password. Username is immutable change it by
creating a new user and disabling the old one. Password
changes go through change-password / reset-password."""
if not v.workspace:
return _err("invalid-argument", "workspace required")
if not v.user_id:
return _err("invalid-argument", "user_id required")
if v.user is None:
@ -719,25 +754,17 @@ class IamService:
"password cannot be changed via update-user; "
"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._user_in_workspace(
v.user_id, v.workspace,
existing, err = await self._resolve_user(
v.user_id, v.workspace or None,
)
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.
(
@ -774,12 +801,10 @@ class IamService:
async def handle_disable_user(self, v):
"""Soft-delete: set enabled=false and revoke every API key
belonging to the user."""
if not v.workspace:
return _err("invalid-argument", "workspace required")
if not v.user_id:
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:
return err
@ -797,12 +822,10 @@ class IamService:
async def handle_enable_user(self, v):
"""Re-enable a previously disabled user. Does not restore
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:
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:
return err
@ -821,29 +844,30 @@ class IamService:
cover GDPR erasure-style requirements). When audit logging
lands, the decision to delete vs. anonymise referenced audit
rows will need to be revisited."""
if not v.workspace:
return _err("invalid-argument", "workspace required")
if not v.user_id:
return _err("invalid-argument", "user_id required")
user_row, err = await self._user_in_workspace(
v.user_id, v.workspace,
user_row, err = await self._resolve_user(
v.user_id, v.workspace or None,
)
if err is not None:
return err
# user_row indices match get_user columns. Username is [2].
username = user_row[2]
record_workspace = user_row[1]
# Revoke all API keys.
key_rows = await self.table_store.list_api_keys_by_user(v.user_id)
for kr in key_rows:
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:
await self.table_store.delete_username_lookup(
v.workspace, username,
record_workspace, username,
)
# Remove user record.
@ -1098,12 +1122,15 @@ class IamService:
# ------------------------------------------------------------------
async def handle_list_users(self, v):
if not v.workspace:
return _err(
"invalid-argument", "workspace required for list-users",
)
rows = await self.table_store.list_users_by_workspace(v.workspace)
# System-level operation: workspace, when supplied, is a
# filter on the user record's home-workspace association.
# Empty workspace returns the deployment-wide list — the
# gateway has already authorised the caller's authority to
# see that scope.
if v.workspace:
rows = await self.table_store.list_users_by_workspace(v.workspace)
else:
rows = await self.table_store.list_users()
return IamResponse(
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):
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:
return _err("invalid-argument", "key.user_id required")
if not v.key.name:
return _err("invalid-argument", "key.name required")
# Target user must exist and belong to the caller's workspace.
user_row = await self.table_store.get_user(v.key.user_id)
if user_row is None:
return _err("not-found", "user not found")
if user_row[1] != v.workspace:
return _err(
"operation-not-permitted",
"target user is in a different workspace",
)
# API keys are system-level records with a workspace
# association (the user's home workspace). Workspace is an
# optional integrity check on the caller's request — when
# supplied it must match the target user's home workspace;
# when omitted, the user's home workspace is used.
user_row, err = await self._resolve_user(
v.key.user_id, v.workspace or None,
)
if err is not None:
return err
plaintext = _generate_api_key()
key_id = str(uuid.uuid4())
@ -1161,20 +1185,15 @@ class IamService:
# ------------------------------------------------------------------
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:
return _err(
"invalid-argument", "user_id required for list-api-keys",
)
# Workspace-scope check: user must live in this workspace.
user_row = await self.table_store.get_user(v.user_id)
if user_row is None or user_row[1] != v.workspace:
return _err("not-found", "user not found in workspace")
# Workspace is an optional integrity check.
_, err = await self._resolve_user(v.user_id, v.workspace or None)
if err is not None:
return err
rows = await self.table_store.list_api_keys_by_user(v.user_id)
return IamResponse(
@ -1186,11 +1205,6 @@ class IamService:
# ------------------------------------------------------------------
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:
return _err("invalid-argument", "key_id required")
@ -1199,13 +1213,15 @@ class IamService:
return _err("not-found", "api key not found")
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)
if user_row is None or user_row[1] != v.workspace:
return _err(
"operation-not-permitted",
"key belongs to a different workspace",
)
# Workspace is an optional integrity check via the owning user.
if v.workspace:
user_row = await self.table_store.get_user(user_id)
if user_row is None or user_row[1] != v.workspace:
return _err(
"operation-not-permitted",
"key belongs to a different workspace",
)
await self.table_store.delete_api_key(key_hash)
return IamResponse()

View file

@ -167,6 +167,11 @@ class IamTableStore:
roles, enabled, must_change_password, created
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("""
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,),
)
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):
await async_execute(
self.cassandra, self.delete_user_stmt, (id,),