diff --git a/docs/tech-specs/iam-contract.md b/docs/tech-specs/iam-contract.md index 3289add1..da23fb31 100644 --- a/docs/tech-specs/iam-contract.md +++ b/docs/tech-specs/iam-contract.md @@ -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 diff --git a/docs/tech-specs/iam-protocol.md b/docs/tech-specs/iam-protocol.md index 603d1c06..e7e7984e 100644 --- a/docs/tech-specs/iam-protocol.md +++ b/docs/tech-specs/iam-protocol.md @@ -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 diff --git a/docs/tech-specs/iam.md b/docs/tech-specs/iam.md index a764535e..dd0e12f5 100644 --- a/docs/tech-specs/iam.md +++ b/docs/tech-specs/iam.md @@ -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 `` 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: diff --git a/trustgraph-base/trustgraph/base/iam_client.py b/trustgraph-base/trustgraph/base/iam_client.py index f90694fc..4be59de1 100644 --- a/trustgraph-base/trustgraph/base/iam_client.py +++ b/trustgraph-base/trustgraph/base/iam_client.py @@ -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. diff --git a/trustgraph-base/trustgraph/messaging/translators/iam.py b/trustgraph-base/trustgraph/messaging/translators/iam.py index 4a717bba..1d7bf21c 100644 --- a/trustgraph-base/trustgraph/messaging/translators/iam.py +++ b/trustgraph-base/trustgraph/messaging/translators/iam.py @@ -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 diff --git a/trustgraph-base/trustgraph/schema/services/iam.py b/trustgraph-base/trustgraph/schema/services/iam.py index 4b5685a5..797d6203 100644 --- a/trustgraph-base/trustgraph/schema/services/iam.py +++ b/trustgraph-base/trustgraph/schema/services/iam.py @@ -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 diff --git a/trustgraph-cli/pyproject.toml b/trustgraph-cli/pyproject.toml index 728079c8..e8062fba 100644 --- a/trustgraph-cli/pyproject.toml +++ b/trustgraph-cli/pyproject.toml @@ -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" diff --git a/trustgraph-cli/trustgraph/cli/update_user.py b/trustgraph-cli/trustgraph/cli/update_user.py new file mode 100644 index 00000000..5c1dc4d7 --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/update_user.py @@ -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() diff --git a/trustgraph-cli/trustgraph/cli/whoami.py b/trustgraph-cli/trustgraph/cli/whoami.py new file mode 100644 index 00000000..1799685d --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/whoami.py @@ -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() diff --git a/trustgraph-flow/trustgraph/gateway/dispatch/mux.py b/trustgraph-flow/trustgraph/gateway/dispatch/mux.py index 37a72f11..03cd748b 100644 --- a/trustgraph-flow/trustgraph/gateway/dispatch/mux.py +++ b/trustgraph-flow/trustgraph/gateway/dispatch/mux.py @@ -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, diff --git a/trustgraph-flow/trustgraph/gateway/endpoint/auth_endpoints.py b/trustgraph-flow/trustgraph/gateway/endpoint/auth_endpoints.py index 0b476b7b..44bbc03e 100644 --- a/trustgraph-flow/trustgraph/gateway/endpoint/auth_endpoints.py +++ b/trustgraph-flow/trustgraph/gateway/endpoint/auth_endpoints.py @@ -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 diff --git a/trustgraph-flow/trustgraph/gateway/endpoint/iam_endpoint.py b/trustgraph-flow/trustgraph/gateway/endpoint/iam_endpoint.py index 70fa33f7..749eacd3 100644 --- a/trustgraph-flow/trustgraph/gateway/endpoint/iam_endpoint.py +++ b/trustgraph-flow/trustgraph/gateway/endpoint/iam_endpoint.py @@ -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 diff --git a/trustgraph-flow/trustgraph/gateway/registry.py b/trustgraph-flow/trustgraph/gateway/registry.py index 32a517a9..5e3344f4 100644 --- a/trustgraph-flow/trustgraph/gateway/registry.py +++ b/trustgraph-flow/trustgraph/gateway/registry.py @@ -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, +)) # --------------------------------------------------------------------------- diff --git a/trustgraph-flow/trustgraph/iam/service/iam.py b/trustgraph-flow/trustgraph/iam/service/iam.py index 44c7df23..c89f65b0 100644 --- a/trustgraph-flow/trustgraph/iam/service/iam.py +++ b/trustgraph-flow/trustgraph/iam/service/iam.py @@ -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() diff --git a/trustgraph-flow/trustgraph/tables/iam.py b/trustgraph-flow/trustgraph/tables/iam.py index 3d41ebbd..f1a0734f 100644 --- a/trustgraph-flow/trustgraph/tables/iam.py +++ b/trustgraph-flow/trustgraph/tables/iam.py @@ -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,),