From 9ae79ff712a3e706fc6795b4af2dc527027a4922 Mon Sep 17 00:00:00 2001 From: Cyber MacGeddon Date: Fri, 24 Apr 2026 12:41:46 +0100 Subject: [PATCH] Updated CLI --- iam-testing.txt | 252 ++++++++++++++++++ trustgraph-base/trustgraph/base/iam_client.py | 10 + trustgraph-cli/pyproject.toml | 1 + .../trustgraph/cli/create_api_key.py | 15 +- trustgraph-cli/trustgraph/cli/create_user.py | 9 + trustgraph-cli/trustgraph/cli/disable_user.py | 15 +- trustgraph-cli/trustgraph/cli/enable_user.py | 45 ++++ .../trustgraph/cli/list_api_keys.py | 15 +- trustgraph-cli/trustgraph/cli/list_users.py | 14 +- .../trustgraph/cli/reset_password.py | 15 +- .../trustgraph/cli/revoke_api_key.py | 15 +- .../trustgraph/gateway/capabilities.py | 181 +++++++++---- .../trustgraph/gateway/dispatch/mux.py | 53 ++-- .../trustgraph/gateway/endpoint/manager.py | 2 +- .../trustgraph/gateway/endpoint/socket.py | 2 +- trustgraph-flow/trustgraph/iam/service/iam.py | 19 ++ 16 files changed, 558 insertions(+), 105 deletions(-) create mode 100644 iam-testing.txt create mode 100644 trustgraph-cli/trustgraph/cli/enable_user.py diff --git a/iam-testing.txt b/iam-testing.txt new file mode 100644 index 00000000..0d03ffc3 --- /dev/null +++ b/iam-testing.txt @@ -0,0 +1,252 @@ + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation": "bootstrap"}' + + + + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation": "resolve-api-key", "api_key": "tg_r-n43hDWV9WOY06w6o5YpevAxirlS33D"}' + + + + + + + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation": "resolve-api-key", "api_key": "asdalsdjasdkasdasda"}' + + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"list-users","workspace":"default"}' + + + + # 1. Admin creates a writer user "alice" + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "create-user", + "workspace": "default", + "user": { + "username": "alice", + "name": "Alice", + "email": "alice@example.com", + "password": "changeme", + "roles": ["writer"] + } + }' + # expect: {"user": {"id": "", ...}} — grab alice's uuid + + # 2. Issue alice an API key + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "create-api-key", + "workspace": "default", + "key": { + "user_id": "f2363a10-3b83-44ea-a008-43caae8ba607", + "name": "alice-laptop" + } + }' + # expect: {"api_key_plaintext": "tg_...", "api_key": {"id": "", "prefix": "tg_xxxx", ...}} + + # 3. Resolve alice's key — should return alice's id + workspace + writer role + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"resolve-api-key","api_key":"tg_gt4buvk5NG-QS7oP_0Gk5yTWyj1qensf"}' + + # expect: {"resolved_user_id":"","resolved_workspace":"default","resolved_roles":["writer"]} + + # 4. List alice's keys (admin view of alice's keys) + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"list-api-keys","workspace":"default","user_id":"f2363a10-3b83-44ea-a008-43caae8ba607"}' + # expect: {"api_keys": [{"id":"","user_id":"","name":"alice-laptop","prefix":"tg_xxxx",...}]} + + # 5. Revoke alice's key + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"revoke-api-key","workspace":"default","key_id":"55f1c1f7-5448-49fd-9eda-56c192b61177"}' + + + # expect: {} (empty, no error) + + # 6. Confirm the revoked key no longer resolves + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"resolve-api-key","api_key":"tg_gt4buvk5NG-QS7oP_0Gk5yTWyj1qensf"}' + # expect: {"error":{"type":"auth-failed","message":"unknown api key"}} + + + +---------------------------------------------------------------------------- + + You'll want to re-bootstrap a fresh deployment to pick up the new signing-key row (or accept that login will lazily generate one on first + call). Then: + + # 1. Create a user with a known password (admin's password is random) + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"create-user","workspace":"default","user":{"username":"alice","password":"s3cret","roles":["writer"]}}' + + + + # 2. Log alice in + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"login","username":"alice","password":"s3cret"}' + # expect: {"jwt":"eyJ...","jwt_expires":"2026-..."} + + # 3. Fetch the public key (what the gateway will use later to verify) + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"get-signing-key-public"}' + + # expect: {"signing_key_public":"-----BEGIN PUBLIC KEY-----\n..."} + + # 4. Wrong password + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Authorization: Bearer $GATEWAY_SECRET" \ + -H "Content-Type: application/json" \ + -d '{"operation":"login","username":"alice","password":"nope"}' + + + + # expect: {"error":{"type":"auth-failed","message":"bad credentials"}} + + + + + +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAseLB/a9Bo/RN/Rb/x763 ++vdxmUKG75oWsXBmbwZGDXyN6fwqZ3L7cEje93qK0PYFuCHxhY1Hn0gW7FZ8ovH+ +qEksekUlpfPYqKGiT5Mb0DKk49D4yKkIbJFugWalpwIilvRbQO0jy3V8knqGQ1xL +NfNYFrI2Rxe0Tq2OHVYc5YwYbyj1nz2TY5fd9qrzXtGRv5HZztkl25lWhRvG9G0K +urKDdBDbi894gIYorXvcwZw/b1GDXG/aUy/By1Oy3hXnCLsN8pA3nA437TTTWxHx +QgPH15jIF9hezO+3/ESZ7EhVEtgmwTxPddfXRa0ZoT6JyWOgcloKtnP4Lp9eQ4va +yQIDAQAB +-----END PUBLIC KEY----- + + + + + + New operations: + - change-password — self-service. Requires current + new password. + - reset-password — admin-driven. Generates a random temporary, sets must_change_password=true, returns plaintext once. + - get-user, update-user, disable-user — workspace-scoped. update-user refuses to change username (immutable — error if different) and refuses + password-via-update. disable-user also revokes all the user's API keys, per spec. + - create-workspace, list-workspaces, get-workspace, update-workspace, disable-workspace — system-level. disable-workspace cascades: disables + all users + revokes all their keys. Rejects ids starting with _ (reserved, per the bootstrap framework convention). + - rotate-signing-key — generates a new Ed25519 key, retires the current one (sets retired timestamp; row stays for future grace-period + validation), switches the in-memory cache. + + Touched files: + - trustgraph-flow/trustgraph/tables/iam.py — added retire_signing_key, update_user_profile, update_user_password, update_user_enabled, + update_workspace. + - trustgraph-flow/trustgraph/iam/service/iam.py — 12 new handlers + dispatch entries. + - trustgraph-base/trustgraph/base/iam_client.py — matching client helpers for all of them. + + Smoke-test suggestions: + + # change password for alice (from "s3cret" → "n3wer") + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"change-password","user_id":"b2960feb-caef-401d-af65-01bdb6960cad","password":"s3cret","new_password":"n3wer"}' + + # login with new password + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"login","username":"alice","password":"n3wer"}' + + # admin resets alice's password + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"reset-password","workspace":"default","user_id":"b2960feb-caef-401d-af65-01bdb6960cad"}' + + + # → {"temporary_password":"..."} + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"login","username":"alice","password":"fH2ttyrIcVXCIkH_"}' + + + # create a second workspace + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"create-workspace","workspace_record":{"id":"acme","name":"Acme Corp","enabled":true}}' + + + # rotate signing key (next login produces a JWT signed by a new kid) + + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -d '{"operation":"rotate-signing-key"}' + + + + + + + curl -s -X POST "http://localhost:8088/api/v1/flow" \ + -H "Authorization: Bearer tg_bs_kBAhfejiEJmbcO1gElbxk3MpV7wQFygP" \ + -H "Content-Type: application/json" \ + -d '{"operation":"list-flows"}' + + curl -s -X POST "http://localhost:8088/api/v1/iam" \ + -H "Authorization: Bearer tg_bs_kBAhfejiEJmbcO1gElbxk3MpV7wQFygP" \ + -H "Content-Type: application/json" \ + -d '{"operation":"list-users"}' + + + + curl -s -X POST http://localhost:8088/api/v1/iam \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer tg_bs_kBAhfejiEJmbcO1gElbxk3MpV7wQFygP" \ + -d '{ + "operation": "create-user", + "workspace": "default", + "user": { + "username": "alice", + "name": "Alice", + "email": "alice@example.com", + "password": "s3cret", + "roles": ["writer"] + } + }' + + + + + # Login (public, no token needed) → returns a JWT + curl -s -X POST "http://localhost:8088/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"alice","password":"s3cret"}' + + + + export TRUSTGRAPH_TOKEN=$(tg-bootstrap-iam) # on fresh bootstrap-mode deployment + # or set to your existing admin API key + + tg-create-user --username alice --roles writer + # → prints alice's user id + + ALICE_ID= + + ALICE_KEY=$(tg-create-api-key --user-id $ALICE_ID --name alice-laptop) + # → alice's plaintext API key + + tg-list-users + tg-list-api-keys --user-id $ALICE_ID + + tg-revoke-api-key --key-id <...> + tg-disable-user --user-id $ALICE_ID + + # User self-service: + tg-login --username alice # prompts for password, prints JWT + tg-change-password # prompts for current + new + + diff --git a/trustgraph-base/trustgraph/base/iam_client.py b/trustgraph-base/trustgraph/base/iam_client.py index b36dd333..3ebbaa92 100644 --- a/trustgraph-base/trustgraph/base/iam_client.py +++ b/trustgraph-base/trustgraph/base/iam_client.py @@ -191,6 +191,16 @@ class IamClient(RequestResponse): timeout=timeout, ) + async def enable_user(self, workspace, user_id, actor="", + timeout=IAM_TIMEOUT): + await self._request( + operation="enable-user", + workspace=workspace, + actor=actor, + user_id=user_id, + timeout=timeout, + ) + async def create_workspace(self, workspace_record, actor="", timeout=IAM_TIMEOUT): resp = await self._request( diff --git a/trustgraph-cli/pyproject.toml b/trustgraph-cli/pyproject.toml index f460f078..6fd735e3 100644 --- a/trustgraph-cli/pyproject.toml +++ b/trustgraph-cli/pyproject.toml @@ -45,6 +45,7 @@ tg-login = "trustgraph.cli.login:main" tg-create-user = "trustgraph.cli.create_user:main" tg-list-users = "trustgraph.cli.list_users:main" tg-disable-user = "trustgraph.cli.disable_user:main" +tg-enable-user = "trustgraph.cli.enable_user:main" tg-change-password = "trustgraph.cli.change_password:main" tg-reset-password = "trustgraph.cli.reset_password:main" tg-create-api-key = "trustgraph.cli.create_api_key:main" diff --git a/trustgraph-cli/trustgraph/cli/create_api_key.py b/trustgraph-cli/trustgraph/cli/create_api_key.py index 351f311f..2b269041 100644 --- a/trustgraph-cli/trustgraph/cli/create_api_key.py +++ b/trustgraph-cli/trustgraph/cli/create_api_key.py @@ -17,10 +17,10 @@ def do_create_api_key(args): if args.expires: key["expires"] = args.expires - resp = call_iam(args.api_url, args.token, { - "operation": "create-api-key", - "key": key, - }) + req = {"operation": "create-api-key", "key": key} + if args.workspace: + req["workspace"] = args.workspace + resp = call_iam(args.api_url, args.token, req) plaintext = resp.get("api_key_plaintext", "") rec = resp.get("api_key", {}) @@ -57,6 +57,13 @@ def main(): "--expires", default=None, help="ISO-8601 expiry (optional; empty = no expiry)", ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) run_main(do_create_api_key, parser) diff --git a/trustgraph-cli/trustgraph/cli/create_user.py b/trustgraph-cli/trustgraph/cli/create_user.py index 742b4a05..c9253aca 100644 --- a/trustgraph-cli/trustgraph/cli/create_user.py +++ b/trustgraph-cli/trustgraph/cli/create_user.py @@ -29,6 +29,8 @@ def do_create_user(args): user["must_change_password"] = True req = {"operation": "create-user", "user": user} + if args.workspace: + req["workspace"] = args.workspace resp = call_iam(args.api_url, args.token, req) rec = resp.get("user", {}) @@ -71,6 +73,13 @@ def main(): "--must-change-password", action="store_true", help="Force password change on next login", ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) run_main(do_create_user, parser) diff --git a/trustgraph-cli/trustgraph/cli/disable_user.py b/trustgraph-cli/trustgraph/cli/disable_user.py index eb39367b..e142644b 100644 --- a/trustgraph-cli/trustgraph/cli/disable_user.py +++ b/trustgraph-cli/trustgraph/cli/disable_user.py @@ -9,10 +9,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main def do_disable_user(args): - call_iam(args.api_url, args.token, { - "operation": "disable-user", - "user_id": args.user_id, - }) + req = {"operation": "disable-user", "user_id": args.user_id} + if args.workspace: + req["workspace"] = args.workspace + call_iam(args.api_url, args.token, req) print(f"Disabled user {args.user_id}") @@ -31,6 +31,13 @@ def main(): parser.add_argument( "--user-id", required=True, help="User id to disable", ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) run_main(do_disable_user, parser) diff --git a/trustgraph-cli/trustgraph/cli/enable_user.py b/trustgraph-cli/trustgraph/cli/enable_user.py new file mode 100644 index 00000000..c762366a --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/enable_user.py @@ -0,0 +1,45 @@ +""" +Re-enable a previously disabled user. Does not restore their API +keys — those must be re-issued by an admin. +""" + +import argparse + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_enable_user(args): + req = {"operation": "enable-user", "user_id": args.user_id} + if args.workspace: + req["workspace"] = args.workspace + call_iam(args.api_url, args.token, req) + print(f"Enabled user {args.user_id}") + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-enable-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="User id to enable", + ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) + run_main(do_enable_user, parser) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/list_api_keys.py b/trustgraph-cli/trustgraph/cli/list_api_keys.py index 02b3f217..f969890e 100644 --- a/trustgraph-cli/trustgraph/cli/list_api_keys.py +++ b/trustgraph-cli/trustgraph/cli/list_api_keys.py @@ -10,10 +10,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main def do_list_api_keys(args): - resp = call_iam(args.api_url, args.token, { - "operation": "list-api-keys", - "user_id": args.user_id, - }) + req = {"operation": "list-api-keys", "user_id": args.user_id} + if args.workspace: + req["workspace"] = args.workspace + resp = call_iam(args.api_url, args.token, req) keys = resp.get("api_keys", []) if not keys: @@ -55,6 +55,13 @@ def main(): "--user-id", required=True, help="Owner user id", ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) run_main(do_list_api_keys, parser) diff --git a/trustgraph-cli/trustgraph/cli/list_users.py b/trustgraph-cli/trustgraph/cli/list_users.py index b78b7c20..25bc1901 100644 --- a/trustgraph-cli/trustgraph/cli/list_users.py +++ b/trustgraph-cli/trustgraph/cli/list_users.py @@ -10,9 +10,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main def do_list_users(args): - resp = call_iam( - args.api_url, args.token, {"operation": "list-users"}, - ) + req = {"operation": "list-users"} + if args.workspace: + req["workspace"] = args.workspace + resp = call_iam(args.api_url, args.token, req) users = resp.get("users", []) if not users: @@ -50,6 +51,13 @@ def main(): "-t", "--token", default=DEFAULT_TOKEN, help="Auth token (default: $TRUSTGRAPH_TOKEN)", ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) run_main(do_list_users, parser) diff --git a/trustgraph-cli/trustgraph/cli/reset_password.py b/trustgraph-cli/trustgraph/cli/reset_password.py index da76dbbc..600f00e1 100644 --- a/trustgraph-cli/trustgraph/cli/reset_password.py +++ b/trustgraph-cli/trustgraph/cli/reset_password.py @@ -10,10 +10,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main def do_reset_password(args): - resp = call_iam(args.api_url, args.token, { - "operation": "reset-password", - "user_id": args.user_id, - }) + req = {"operation": "reset-password", "user_id": args.user_id} + if args.workspace: + req["workspace"] = args.workspace + resp = call_iam(args.api_url, args.token, req) tmp = resp.get("temporary_password", "") if not tmp: @@ -40,6 +40,13 @@ def main(): "--user-id", required=True, help="Target user id", ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) run_main(do_reset_password, parser) diff --git a/trustgraph-cli/trustgraph/cli/revoke_api_key.py b/trustgraph-cli/trustgraph/cli/revoke_api_key.py index fc5292df..3976b56f 100644 --- a/trustgraph-cli/trustgraph/cli/revoke_api_key.py +++ b/trustgraph-cli/trustgraph/cli/revoke_api_key.py @@ -8,10 +8,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main def do_revoke_api_key(args): - call_iam(args.api_url, args.token, { - "operation": "revoke-api-key", - "key_id": args.key_id, - }) + req = {"operation": "revoke-api-key", "key_id": args.key_id} + if args.workspace: + req["workspace"] = args.workspace + call_iam(args.api_url, args.token, req) print(f"Revoked key {args.key_id}") @@ -30,6 +30,13 @@ def main(): parser.add_argument( "--key-id", required=True, help="Key id to revoke", ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) run_main(do_revoke_api_key, parser) diff --git a/trustgraph-flow/trustgraph/gateway/capabilities.py b/trustgraph-flow/trustgraph/gateway/capabilities.py index 5413a4b1..15e25684 100644 --- a/trustgraph-flow/trustgraph/gateway/capabilities.py +++ b/trustgraph-flow/trustgraph/gateway/capabilities.py @@ -1,18 +1,36 @@ """ -Capability vocabulary and OSS role bundles. +Capability vocabulary, role definitions, and authorisation helpers. See docs/tech-specs/capabilities.md for the authoritative description. -The mapping below is the data form of the OSS bundle table in that -spec. Enterprise editions may replace this module with their own -role table; the vocabulary (capability strings) is shared. +The data here is the OSS bundle table in that spec. Enterprise +editions may replace this module with their own role table; the +vocabulary (capability strings) is shared. -The module also exposes: +Role model +---------- +A role has two dimensions: -- ``PUBLIC`` — a sentinel indicating an endpoint requires no - authentication (login, bootstrap). -- ``AUTHENTICATED`` — a sentinel indicating an endpoint requires a - valid identity but no specific capability (e.g. change-password). -- ``check(roles, capability)`` — the union-of-bundles membership test. + 1. **capability set** — which operations the role grants. + 2. **workspace scope** — which workspaces the role is active in. + +The authorisation question is: *given the caller's roles, a required +capability, and a target workspace, does any role grant the +capability AND apply to the target workspace?* + +Workspace scope values recognised here: + + - ``"assigned"`` — the role applies only to the caller's own + assigned workspace (stored on their user record). + - ``"*"`` — the role applies to every workspace. + +Enterprise editions can add richer scopes (explicit permitted-set, +patterns, etc.) without changing the wire protocol. + +Sentinels +--------- +- ``PUBLIC`` — endpoint requires no authentication. +- ``AUTHENTICATED`` — endpoint requires a valid identity, no + specific capability. """ from aiohttp import web @@ -23,8 +41,8 @@ AUTHENTICATED = "__authenticated__" # Capability vocabulary. Mirrors the "Capability list" tables in -# capabilities.md. Kept as a set of valid strings so the gateway can -# fail-closed on an endpoint that declares an unknown capability. +# capabilities.md. Kept as a set so the gateway can fail-closed on +# an endpoint that declares an unknown capability. KNOWN_CAPABILITIES = { # Data plane "agent", @@ -47,7 +65,7 @@ KNOWN_CAPABILITIES = { } -# OSS role → capability set. Enterprise overrides this mapping. +# Capability sets used below. _READER_CAPS = { "agent", "graph:read", @@ -81,23 +99,62 @@ _ADMIN_CAPS = _WRITER_CAPS | { "metrics:read", } -ROLE_CAPABILITIES = { - "reader": _READER_CAPS, - "writer": _WRITER_CAPS, - "admin": _ADMIN_CAPS, + +# Role definitions. Each role has a capability set and a workspace +# scope. Enterprise overrides this mapping. +ROLE_DEFINITIONS = { + "reader": { + "capabilities": _READER_CAPS, + "workspace_scope": "assigned", + }, + "writer": { + "capabilities": _WRITER_CAPS, + "workspace_scope": "assigned", + }, + "admin": { + "capabilities": _ADMIN_CAPS, + "workspace_scope": "*", + }, } -def check(roles, capability): - """Return True if any of ``roles`` grants ``capability``. - - Unknown roles contribute zero capabilities (deterministic fail- - closed behaviour per the spec).""" - if capability not in KNOWN_CAPABILITIES: - # Endpoint misconfiguration. Fail closed. +def _scope_permits(role_name, target_workspace, assigned_workspace): + """Does the given role apply to ``target_workspace``?""" + role = ROLE_DEFINITIONS.get(role_name) + if role is None: return False - for r in roles: - if capability in ROLE_CAPABILITIES.get(r, ()): + scope = role["workspace_scope"] + if scope == "*": + return True + if scope == "assigned": + return target_workspace == assigned_workspace + # Future scope types (lists, patterns) extend here. + return False + + +def check(identity, capability, target_workspace=None): + """Is ``identity`` permitted to invoke ``capability`` on + ``target_workspace``? + + Passes iff some role held by the caller both (a) grants + ``capability`` and (b) is active in ``target_workspace``. + + ``target_workspace`` defaults to the caller's assigned workspace, + which makes this function usable for system-level operations and + for authenticated endpoints that don't take a workspace argument + (the call collapses to "do any of my roles grant this cap?").""" + if capability not in KNOWN_CAPABILITIES: + return False + + target = target_workspace or identity.workspace + + for role_name in identity.roles: + role = ROLE_DEFINITIONS.get(role_name) + if role is None: + continue + if capability not in role["capabilities"]: + continue + if _scope_permits(role_name, target, identity.workspace): return True return False @@ -117,18 +174,21 @@ def auth_failure(): async def enforce(request, auth, capability): - """Authenticate + capability-check in one step. Returns an - ``Identity`` (or ``None`` for ``PUBLIC`` endpoints) or raises - the appropriate HTTPException. + """Authenticate + capability-check for endpoints that carry no + workspace dimension on the request (metrics, i18n, etc.). - Usage in an endpoint handler: + For endpoints that carry a workspace field on the body, call + :func:`enforce_workspace` *after* parsing the body to validate + the workspace and re-check the capability in that scope. Most + endpoints do both. - identity = await enforce(request, self.auth, self.capability) - - - ``PUBLIC``: no authentication attempted, returns ``None``. - - ``AUTHENTICATED``: any valid identity is accepted. - - any capability string: identity must carry a role granting it. - """ + - ``PUBLIC``: no authentication, returns ``None``. + - ``AUTHENTICATED``: any valid identity. + - capability string: identity must have it, checked against the + caller's assigned workspace (adequate for endpoints whose + capability is system-level, e.g. ``metrics:read``, or where + the real workspace-aware check happens in + :func:`enforce_workspace` after body parsing).""" if capability == PUBLIC: return None @@ -137,27 +197,42 @@ async def enforce(request, auth, capability): if capability == AUTHENTICATED: return identity - if not check(identity.roles, capability): + if not check(identity, capability): raise access_denied() return identity -def enforce_workspace(data, identity): - """Validate + inject the workspace field on a request body. +def enforce_workspace(data, identity, capability=None): + """Resolve + validate the workspace on a request body. - OSS behaviour: - - If ``data["workspace"]`` is present and differs from the - caller's assigned workspace → 403. - - Otherwise, set ``data["workspace"]`` to the caller's assigned - workspace. + - Target workspace = ``data["workspace"]`` if supplied, else the + caller's assigned workspace. + - At least one of the caller's roles must (a) be active in the + target workspace and, if ``capability`` is given, (b) grant + ``capability``. Otherwise 403. + - On success, ``data["workspace"]`` is overwritten with the + resolved value — callers can rely on the outgoing message + having the gateway's chosen workspace rather than any + caller-supplied value. - Enterprise editions will plug in a different resolver that - checks a permitted-set instead of a single value; the wire - protocol is unchanged.""" - requested = data.get("workspace", "") if isinstance(data, dict) else "" - if requested and requested != identity.workspace: - raise access_denied() - if isinstance(data, dict): - data["workspace"] = identity.workspace - return data + For ``capability=None`` the workspace scope alone is checked — + useful when the body has a workspace but the endpoint already + passed its capability check (e.g. via :func:`enforce`).""" + if not isinstance(data, dict): + return data + + requested = data.get("workspace", "") + target = requested or identity.workspace + + for role_name in identity.roles: + role = ROLE_DEFINITIONS.get(role_name) + if role is None: + continue + if capability is not None and capability not in role["capabilities"]: + continue + if _scope_permits(role_name, target, identity.workspace): + data["workspace"] = target + return data + + raise access_denied() diff --git a/trustgraph-flow/trustgraph/gateway/dispatch/mux.py b/trustgraph-flow/trustgraph/gateway/dispatch/mux.py index 7d62f163..b55501f3 100644 --- a/trustgraph-flow/trustgraph/gateway/dispatch/mux.py +++ b/trustgraph-flow/trustgraph/gateway/dispatch/mux.py @@ -122,35 +122,34 @@ class Mux: }) return - # Workspace resolution. Authenticated sockets override - # any client-supplied workspace — on both the envelope and - # the inner request payload — with the resolved value from - # the identity. A mismatched value at either layer is an - # access-denied error. Injecting into the inner request - # means clients don't have to repeat the workspace in - # every payload; the same convenience HTTP callers get - # via enforce_workspace. + # Workspace resolution. On authenticated sockets the + # gateway's role-scope rules apply: role workspace scope + # determines which target workspaces are permitted. The + # resolved value is written to both the envelope and the + # inner request payload so clients don't have to repeat it + # per-message (same convenience HTTP callers get via + # enforce_workspace). if self.identity is not None: - for layer, blob in ( - ("envelope", data), - ("inner", data.get("request")), - ): - if not isinstance(blob, dict): - continue - req = blob.get("workspace", "") - if req and req != self.identity.workspace: - await self.ws.send_json({ - "id": request_id, - "error": { - "message": "access denied", - "type": "access-denied", - }, - "complete": True, - }) - return - blob["workspace"] = self.identity.workspace + from ..capabilities import enforce_workspace + from aiohttp import web as _web - workspace = self.identity.workspace + try: + enforce_workspace(data, self.identity) + inner = data.get("request") + if isinstance(inner, dict): + enforce_workspace(inner, self.identity) + except _web.HTTPForbidden: + await self.ws.send_json({ + "id": request_id, + "error": { + "message": "access denied", + "type": "access-denied", + }, + "complete": True, + }) + return + + workspace = data["workspace"] else: workspace = data.get("workspace", "default") diff --git a/trustgraph-flow/trustgraph/gateway/endpoint/manager.py b/trustgraph-flow/trustgraph/gateway/endpoint/manager.py index 5bdaf367..05df0847 100644 --- a/trustgraph-flow/trustgraph/gateway/endpoint/manager.py +++ b/trustgraph-flow/trustgraph/gateway/endpoint/manager.py @@ -168,7 +168,7 @@ class _RoutedSocketEndpoint: ) except web.HTTPException as e: return e - if not check(identity.roles, cap): + if not check(identity, cap): return access_denied() # Delegate the websocket handling to a standalone SocketEndpoint diff --git a/trustgraph-flow/trustgraph/gateway/endpoint/socket.py b/trustgraph-flow/trustgraph/gateway/endpoint/socket.py index d0e86567..08629ea2 100644 --- a/trustgraph-flow/trustgraph/gateway/endpoint/socket.py +++ b/trustgraph-flow/trustgraph/gateway/endpoint/socket.py @@ -97,7 +97,7 @@ class SocketEndpoint: except web.HTTPException as e: return e if self.capability != AUTHENTICATED: - if not check(identity.roles, self.capability): + if not check(identity, self.capability): return access_denied() # 50MB max message size diff --git a/trustgraph-flow/trustgraph/iam/service/iam.py b/trustgraph-flow/trustgraph/iam/service/iam.py index 7c7aaffd..57e23e06 100644 --- a/trustgraph-flow/trustgraph/iam/service/iam.py +++ b/trustgraph-flow/trustgraph/iam/service/iam.py @@ -234,6 +234,8 @@ class IamService: return await self.handle_update_user(v) if op == "disable-user": return await self.handle_disable_user(v) + if op == "enable-user": + return await self.handle_enable_user(v) if op == "create-workspace": return await self.handle_create_workspace(v) if op == "list-workspaces": @@ -711,6 +713,23 @@ class IamService: return IamResponse() + 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) + if err is not None: + return err + + await self.table_store.update_user_enabled( + id=v.user_id, enabled=True, + ) + return IamResponse() + # ------------------------------------------------------------------ # Workspace CRUD # ------------------------------------------------------------------