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/api/async_socket_client.py b/trustgraph-base/trustgraph/api/async_socket_client.py index e5d553ea..ca9146b9 100644 --- a/trustgraph-base/trustgraph/api/async_socket_client.py +++ b/trustgraph-base/trustgraph/api/async_socket_client.py @@ -49,21 +49,67 @@ class AsyncSocketClient: return f"ws://{url}" def _build_ws_url(self): - ws_url = f"{self.url.rstrip('/')}/api/v1/socket" - if self.token: - ws_url = f"{ws_url}?token={self.token}" - return ws_url + # /api/v1/socket uses the first-frame auth protocol — the + # token is sent as the first frame after connecting rather + # than in the URL. This avoids browser issues with 401 on + # the WebSocket handshake and lets long-lived sockets + # refresh credentials mid-session. + return f"{self.url.rstrip('/')}/api/v1/socket" async def connect(self): - """Establish the persistent websocket connection.""" + """Establish the persistent websocket connection and run the + first-frame auth handshake.""" if self._connected: return + if not self.token: + raise ProtocolException( + "AsyncSocketClient requires a token for first-frame " + "auth against /api/v1/socket" + ) + ws_url = self._build_ws_url() self._connect_cm = websockets.connect( ws_url, ping_interval=20, ping_timeout=self.timeout ) self._socket = await self._connect_cm.__aenter__() + + # First-frame auth: send {"type":"auth","token":"..."} and + # wait for auth-ok / auth-failed. Run before starting the + # reader task so the response isn't consumed by the reader's + # id-based routing. + await self._socket.send(json.dumps({ + "type": "auth", "token": self.token, + })) + try: + raw = await asyncio.wait_for( + self._socket.recv(), timeout=self.timeout, + ) + except asyncio.TimeoutError: + await self._socket.close() + raise ProtocolException("Timeout waiting for auth response") + + try: + resp = json.loads(raw) + except Exception: + await self._socket.close() + raise ProtocolException( + f"Unexpected non-JSON auth response: {raw!r}" + ) + + if resp.get("type") == "auth-ok": + self.workspace = resp.get("workspace", self.workspace) + elif resp.get("type") == "auth-failed": + await self._socket.close() + raise ProtocolException( + f"auth failure: {resp.get('error', 'unknown')}" + ) + else: + await self._socket.close() + raise ProtocolException( + f"Unexpected auth response: {resp!r}" + ) + self._connected = True self._reader_task = asyncio.create_task(self._reader()) diff --git a/trustgraph-base/trustgraph/api/socket_client.py b/trustgraph-base/trustgraph/api/socket_client.py index 4eade3e8..aeb15f85 100644 --- a/trustgraph-base/trustgraph/api/socket_client.py +++ b/trustgraph-base/trustgraph/api/socket_client.py @@ -112,10 +112,10 @@ class SocketClient: return f"ws://{url}" def _build_ws_url(self): - ws_url = f"{self.url.rstrip('/')}/api/v1/socket" - if self.token: - ws_url = f"{ws_url}?token={self.token}" - return ws_url + # /api/v1/socket uses the first-frame auth protocol — the + # token is sent as the first frame after connecting rather + # than in the URL. + return f"{self.url.rstrip('/')}/api/v1/socket" def _get_loop(self): """Get or create the event loop, reusing across calls.""" @@ -132,15 +132,58 @@ class SocketClient: return self._loop async def _ensure_connected(self): - """Lazily establish the persistent websocket connection.""" + """Lazily establish the persistent websocket connection and + run the first-frame auth handshake.""" if self._connected: return + if not self.token: + raise ProtocolException( + "SocketClient requires a token for first-frame auth " + "against /api/v1/socket" + ) + ws_url = self._build_ws_url() self._connect_cm = websockets.connect( ws_url, ping_interval=20, ping_timeout=self.timeout ) self._socket = await self._connect_cm.__aenter__() + + # First-frame auth — run before starting the reader so the + # auth-ok / auth-failed response isn't consumed by the reader + # loop's id-based routing. + await self._socket.send(json.dumps({ + "type": "auth", "token": self.token, + })) + try: + raw = await asyncio.wait_for( + self._socket.recv(), timeout=self.timeout, + ) + except asyncio.TimeoutError: + await self._socket.close() + raise ProtocolException("Timeout waiting for auth response") + + try: + resp = json.loads(raw) + except Exception: + await self._socket.close() + raise ProtocolException( + f"Unexpected non-JSON auth response: {raw!r}" + ) + + if resp.get("type") == "auth-ok": + self.workspace = resp.get("workspace", self.workspace) + elif resp.get("type") == "auth-failed": + await self._socket.close() + raise ProtocolException( + f"auth failure: {resp.get('error', 'unknown')}" + ) + else: + await self._socket.close() + raise ProtocolException( + f"Unexpected auth response: {resp!r}" + ) + self._connected = True self._reader_task = asyncio.create_task(self._reader()) diff --git a/trustgraph-base/trustgraph/base/iam_client.py b/trustgraph-base/trustgraph/base/iam_client.py index b36dd333..5cfda7c8 100644 --- a/trustgraph-base/trustgraph/base/iam_client.py +++ b/trustgraph-base/trustgraph/base/iam_client.py @@ -191,6 +191,26 @@ 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 delete_user(self, workspace, user_id, actor="", + timeout=IAM_TIMEOUT): + await self._request( + operation="delete-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 8d88991d..728079c8 100644 --- a/trustgraph-cli/pyproject.toml +++ b/trustgraph-cli/pyproject.toml @@ -41,6 +41,19 @@ tg-get-kg-core = "trustgraph.cli.get_kg_core:main" tg-get-document-content = "trustgraph.cli.get_document_content:main" tg-graph-to-turtle = "trustgraph.cli.graph_to_turtle:main" 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-disable-user = "trustgraph.cli.disable_user:main" +tg-enable-user = "trustgraph.cli.enable_user:main" +tg-delete-user = "trustgraph.cli.delete_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" +tg-list-api-keys = "trustgraph.cli.list_api_keys:main" +tg-revoke-api-key = "trustgraph.cli.revoke_api_key:main" +tg-list-workspaces = "trustgraph.cli.list_workspaces:main" +tg-create-workspace = "trustgraph.cli.create_workspace:main" tg-invoke-agent = "trustgraph.cli.invoke_agent:main" tg-invoke-document-rag = "trustgraph.cli.invoke_document_rag:main" tg-invoke-graph-rag = "trustgraph.cli.invoke_graph_rag:main" diff --git a/trustgraph-cli/trustgraph/cli/_iam.py b/trustgraph-cli/trustgraph/cli/_iam.py new file mode 100644 index 00000000..f5278c0c --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/_iam.py @@ -0,0 +1,75 @@ +""" +Shared helpers for IAM CLI tools. + +All IAM operations go through the gateway's ``/api/v1/iam`` forwarder, +with the three public auth operations (``login``, ``bootstrap``, +``change-password``) served via ``/api/v1/auth/...`` instead. These +helpers encapsulate the HTTP plumbing so each CLI can stay focused +on its own argument parsing and output formatting. +""" + +import json +import os +import sys + +import requests + + +DEFAULT_URL = os.getenv("TRUSTGRAPH_URL", "http://localhost:8088/") +DEFAULT_TOKEN = os.getenv("TRUSTGRAPH_TOKEN", None) + + +def _fmt_error(resp_json): + err = resp_json.get("error", {}) + if isinstance(err, dict): + t = err.get("type", "") + m = err.get("message", "") + return f"{t}: {m}" if t else m or "error" + return str(err) + + +def _post(url, path, token, body): + endpoint = url.rstrip("/") + path + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + + resp = requests.post( + endpoint, headers=headers, data=json.dumps(body), + ) + + if resp.status_code != 200: + try: + payload = resp.json() + detail = _fmt_error(payload) + except Exception: + detail = resp.text + raise RuntimeError(f"HTTP {resp.status_code}: {detail}") + + body = resp.json() + if "error" in body: + raise RuntimeError(_fmt_error(body)) + return body + + +def call_iam(url, token, request): + """Forward an IAM request through ``/api/v1/iam``. ``request`` is + the ``IamRequest`` dict shape.""" + return _post(url, "/api/v1/iam", token, request) + + +def call_auth(url, path, token, body): + """Hit one of the public auth endpoints + (``/api/v1/auth/login``, ``/api/v1/auth/change-password``, etc.). + ``token`` is optional — login and bootstrap don't need one.""" + return _post(url, path, token, body) + + +def run_main(fn, parser): + """Standard error-handling wrapper for CLI main() bodies.""" + args = parser.parse_args() + try: + fn(args) + except Exception as e: + print("Exception:", e, file=sys.stderr, flush=True) + sys.exit(1) diff --git a/trustgraph-cli/trustgraph/cli/change_password.py b/trustgraph-cli/trustgraph/cli/change_password.py new file mode 100644 index 00000000..c914b30f --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/change_password.py @@ -0,0 +1,46 @@ +""" +Change your own password. Requires the current password. +""" + +import argparse +import getpass + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_auth, run_main + + +def do_change_password(args): + current = args.current or getpass.getpass("Current password: ") + new = args.new or getpass.getpass("New password: ") + + call_auth( + args.api_url, "/api/v1/auth/change-password", args.token, + {"current_password": current, "new_password": new}, + ) + print("Password changed.") + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-change-password", 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( + "--current", default=None, + help="Current password (prompted if omitted)", + ) + parser.add_argument( + "--new", default=None, + help="New password (prompted if omitted)", + ) + run_main(do_change_password, parser) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/create_api_key.py b/trustgraph-cli/trustgraph/cli/create_api_key.py new file mode 100644 index 00000000..2b269041 --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/create_api_key.py @@ -0,0 +1,71 @@ +""" +Create an API key for a user. Prints the plaintext key to stdout — +shown once only. +""" + +import argparse +import sys + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_create_api_key(args): + key = { + "user_id": args.user_id, + "name": args.name, + } + if args.expires: + key["expires"] = args.expires + + 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", {}) + print(f"Key id: {rec.get('id', '')}", file=sys.stderr) + print(f"Name: {rec.get('name', '')}", file=sys.stderr) + print(f"Prefix: {rec.get('prefix', '')}", file=sys.stderr) + print( + "API key (shown once, capture now):", file=sys.stderr, + ) + print(plaintext) + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-create-api-key", 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="Owner user id", + ) + parser.add_argument( + "--name", required=True, + help="Operator-facing label (e.g. 'laptop', 'ci')", + ) + parser.add_argument( + "--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) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/create_user.py b/trustgraph-cli/trustgraph/cli/create_user.py new file mode 100644 index 00000000..c9253aca --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/create_user.py @@ -0,0 +1,87 @@ +""" +Create a user in the caller's workspace. Prints the new user id. +""" + +import argparse +import getpass +import sys + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_create_user(args): + password = args.password + if not password: + password = getpass.getpass( + f"Password for new user {args.username}: " + ) + + user = { + "username": args.username, + "password": password, + "roles": args.roles, + } + if args.name: + user["name"] = args.name + if args.email: + user["email"] = args.email + if args.must_change_password: + 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", {}) + print(f"User id: {rec.get('id', '')}", file=sys.stderr) + print(f"Username: {rec.get('username', '')}", file=sys.stderr) + print(f"Roles: {', '.join(rec.get('roles', []))}", file=sys.stderr) + print(rec.get("id", "")) + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-create-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( + "--username", required=True, help="Username (unique in workspace)", + ) + parser.add_argument( + "--password", default=None, + help="Password (prompted if omitted)", + ) + parser.add_argument( + "--name", default=None, help="Display name", + ) + parser.add_argument( + "--email", default=None, help="Email", + ) + parser.add_argument( + "--roles", nargs="+", default=["reader"], + help="One or more role names (default: reader)", + ) + parser.add_argument( + "--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) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/create_workspace.py b/trustgraph-cli/trustgraph/cli/create_workspace.py new file mode 100644 index 00000000..f8367720 --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/create_workspace.py @@ -0,0 +1,46 @@ +""" +Create a workspace (system-level; requires admin). +""" + +import argparse + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_create_workspace(args): + ws = {"id": args.workspace_id, "enabled": True} + if args.name: + ws["name"] = args.name + + resp = call_iam(args.api_url, args.token, { + "operation": "create-workspace", + "workspace_record": ws, + }) + rec = resp.get("workspace", {}) + print(f"Workspace created: {rec.get('id', '')}") + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-create-workspace", 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( + "--workspace-id", required=True, + help="New workspace id (must not start with '_')", + ) + parser.add_argument( + "--name", default=None, help="Display name", + ) + run_main(do_create_workspace, parser) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/delete_user.py b/trustgraph-cli/trustgraph/cli/delete_user.py new file mode 100644 index 00000000..dbdf7877 --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/delete_user.py @@ -0,0 +1,62 @@ +""" +Delete a user. Removes the user record, their username lookup, +and all their API keys. The freed username becomes available for +re-use. + +Irreversible. Use tg-disable-user if you want to preserve the +record (audit trail, username squatting protection). +""" + +import argparse + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_delete_user(args): + if not args.yes: + confirm = input( + f"Delete user {args.user_id}? This is irreversible. " + f"[type 'yes' to confirm]: " + ) + if confirm.strip() != "yes": + print("Aborted.") + return + + req = {"operation": "delete-user", "user_id": args.user_id} + if args.workspace: + req["workspace"] = args.workspace + call_iam(args.api_url, args.token, req) + print(f"Deleted user {args.user_id}") + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-delete-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 delete", + ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) + parser.add_argument( + "--yes", action="store_true", + help="Skip the interactive confirmation prompt", + ) + run_main(do_delete_user, parser) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/disable_user.py b/trustgraph-cli/trustgraph/cli/disable_user.py new file mode 100644 index 00000000..e142644b --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/disable_user.py @@ -0,0 +1,45 @@ +""" +Disable a user. Soft-deletes (enabled=false) and revokes all their +API keys. +""" + +import argparse + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_disable_user(args): + 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}") + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-disable-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 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) + + +if __name__ == "__main__": + main() 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 new file mode 100644 index 00000000..f969890e --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/list_api_keys.py @@ -0,0 +1,69 @@ +""" +List the API keys for a user. +""" + +import argparse + +import tabulate + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_list_api_keys(args): + 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: + print("No keys.") + return + + rows = [ + [ + k.get("id", ""), + k.get("name", ""), + k.get("prefix", ""), + k.get("created", ""), + k.get("last_used", "") or "—", + k.get("expires", "") or "never", + ] + for k in keys + ] + print(tabulate.tabulate( + rows, + headers=["id", "name", "prefix", "created", "last used", "expires"], + tablefmt="pretty", + stralign="left", + )) + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-list-api-keys", 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="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) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/list_users.py b/trustgraph-cli/trustgraph/cli/list_users.py new file mode 100644 index 00000000..25bc1901 --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/list_users.py @@ -0,0 +1,65 @@ +""" +List users in the caller's workspace. +""" + +import argparse + +import tabulate + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_list_users(args): + 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: + print("No users.") + return + + rows = [ + [ + u.get("id", ""), + u.get("username", ""), + u.get("name", ""), + ", ".join(u.get("roles", [])), + "yes" if u.get("enabled") else "no", + "yes" if u.get("must_change_password") else "no", + ] + for u in users + ] + print(tabulate.tabulate( + rows, + headers=["id", "username", "name", "roles", "enabled", "change-pw"], + tablefmt="pretty", + stralign="left", + )) + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-list-users", 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( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) + run_main(do_list_users, parser) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/list_workspaces.py b/trustgraph-cli/trustgraph/cli/list_workspaces.py new file mode 100644 index 00000000..170d330c --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/list_workspaces.py @@ -0,0 +1,53 @@ +""" +List workspaces (system-level; requires admin). +""" + +import argparse + +import tabulate + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_list_workspaces(args): + resp = call_iam( + args.api_url, args.token, {"operation": "list-workspaces"}, + ) + workspaces = resp.get("workspaces", []) + if not workspaces: + print("No workspaces.") + return + rows = [ + [ + w.get("id", ""), + w.get("name", ""), + "yes" if w.get("enabled") else "no", + w.get("created", ""), + ] + for w in workspaces + ] + print(tabulate.tabulate( + rows, + headers=["id", "name", "enabled", "created"], + tablefmt="pretty", + stralign="left", + )) + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-list-workspaces", 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_list_workspaces, parser) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/login.py b/trustgraph-cli/trustgraph/cli/login.py new file mode 100644 index 00000000..0e87c3b0 --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/login.py @@ -0,0 +1,62 @@ +""" +Log in with username / password. Prints the resulting JWT to +stdout so it can be captured for subsequent CLI use. +""" + +import argparse +import getpass +import sys + +from ._iam import DEFAULT_URL, call_auth, run_main + + +def do_login(args): + password = args.password + if not password: + password = getpass.getpass(f"Password for {args.username}: ") + + body = { + "username": args.username, + "password": password, + } + if args.workspace: + body["workspace"] = args.workspace + + resp = call_auth(args.api_url, "/api/v1/auth/login", None, body) + + jwt = resp.get("jwt", "") + expires = resp.get("jwt_expires", "") + + if expires: + print(f"JWT expires: {expires}", file=sys.stderr) + # Machine-readable on stdout. + print(jwt) + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-login", description=__doc__, + ) + parser.add_argument( + "-u", "--api-url", default=DEFAULT_URL, + help=f"API URL (default: {DEFAULT_URL})", + ) + parser.add_argument( + "--username", required=True, help="Username", + ) + parser.add_argument( + "--password", default=None, + help="Password (prompted if omitted)", + ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Optional workspace to log in against. Defaults to " + "the user's assigned workspace." + ), + ) + run_main(do_login, parser) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/reset_password.py b/trustgraph-cli/trustgraph/cli/reset_password.py new file mode 100644 index 00000000..600f00e1 --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/reset_password.py @@ -0,0 +1,54 @@ +""" +Admin: reset another user's password. Prints a one-time temporary +password to stdout. The user is forced to change it on next login. +""" + +import argparse +import sys + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_reset_password(args): + 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: + raise RuntimeError( + "IAM returned no temporary password — unexpected" + ) + print("Temporary password (shown once, capture now):", file=sys.stderr) + print(tmp) + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-reset-password", 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( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) + run_main(do_reset_password, parser) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-cli/trustgraph/cli/revoke_api_key.py b/trustgraph-cli/trustgraph/cli/revoke_api_key.py new file mode 100644 index 00000000..3976b56f --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/revoke_api_key.py @@ -0,0 +1,44 @@ +""" +Revoke an API key by id. +""" + +import argparse + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_revoke_api_key(args): + 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}") + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-revoke-api-key", 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( + "--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) + + +if __name__ == "__main__": + main() 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 b9bb45bc..b55501f3 100644 --- a/trustgraph-flow/trustgraph/gateway/dispatch/mux.py +++ b/trustgraph-flow/trustgraph/gateway/dispatch/mux.py @@ -122,12 +122,23 @@ class Mux: }) return - # Workspace resolution. Authenticated sockets override - # the client-supplied workspace with the resolved value - # from the identity; mismatch is an access-denied error. + # 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: - requested_ws = data.get("workspace", "") - if requested_ws and requested_ws != self.identity.workspace: + from ..capabilities import enforce_workspace + from aiohttp import web as _web + + 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": { @@ -137,7 +148,8 @@ class Mux: "complete": True, }) return - workspace = self.identity.workspace + + 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..6e7c7aa5 100644 --- a/trustgraph-flow/trustgraph/iam/service/iam.py +++ b/trustgraph-flow/trustgraph/iam/service/iam.py @@ -234,6 +234,10 @@ 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 == "delete-user": + return await self.handle_delete_user(v) if op == "create-workspace": return await self.handle_create_workspace(v) if op == "list-workspaces": @@ -711,6 +715,63 @@ 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() + + async def handle_delete_user(self, v): + """Hard-delete a user. Removes the ``iam_users`` row, the + ``iam_users_by_username`` lookup row, and every API key + belonging to the user. + + Unlike disable, this frees the username for re-use and + removes the user's personal data from storage (intended to + 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, + ) + if err is not None: + return err + + # user_row indices match get_user columns. Username is [2]. + username = user_row[2] + + # 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. + if username: + await self.table_store.delete_username_lookup( + v.workspace, username, + ) + + # Remove user record. + await self.table_store.delete_user(v.user_id) + + return IamResponse() + # ------------------------------------------------------------------ # Workspace CRUD # ------------------------------------------------------------------ diff --git a/trustgraph-flow/trustgraph/tables/iam.py b/trustgraph-flow/trustgraph/tables/iam.py index 0f4966fe..3d41ebbd 100644 --- a/trustgraph-flow/trustgraph/tables/iam.py +++ b/trustgraph-flow/trustgraph/tables/iam.py @@ -180,6 +180,9 @@ class IamTableStore: DELETE FROM iam_users_by_username WHERE workspace = ? AND username = ? """) + self.delete_user_stmt = c.prepare(""" + DELETE FROM iam_users WHERE id = ? + """) self.put_api_key_stmt = c.prepare(""" INSERT INTO iam_api_keys ( @@ -301,6 +304,17 @@ class IamTableStore: self.cassandra, self.list_users_by_workspace_stmt, (workspace,), ) + async def delete_user(self, id): + await async_execute( + self.cassandra, self.delete_user_stmt, (id,), + ) + + async def delete_username_lookup(self, workspace, username): + await async_execute( + self.cassandra, self.delete_username_lookup_stmt, + (workspace, username), + ) + # ------------------------------------------------------------------ # API keys # ------------------------------------------------------------------