Compare commits

...

5 commits

Author SHA1 Message Date
Cyber MacGeddon
ef412f2a99 Added delete user 2026-04-24 14:10:24 +01:00
Cyber MacGeddon
9ae79ff712 Updated CLI 2026-04-24 12:41:46 +01:00
Cyber MacGeddon
3bdb677607 CLI tools 2026-04-24 11:08:00 +01:00
Cyber MacGeddon
b5821bca6d Fix socket token handling 2026-04-24 09:40:15 +01:00
Cyber MacGeddon
2a071b12d0 Putting workspace in the inner request 2026-04-24 08:25:35 +01:00
25 changed files with 1431 additions and 71 deletions

252
iam-testing.txt Normal file
View file

@ -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": "<alice-uuid>", ...}} — 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": "<key-uuid>", "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":"<alice-uuid>","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":"<key-uuid>","user_id":"<alice-uuid>","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=<uuid from above>
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

View file

@ -49,21 +49,67 @@ class AsyncSocketClient:
return f"ws://{url}" return f"ws://{url}"
def _build_ws_url(self): def _build_ws_url(self):
ws_url = f"{self.url.rstrip('/')}/api/v1/socket" # /api/v1/socket uses the first-frame auth protocol — the
if self.token: # token is sent as the first frame after connecting rather
ws_url = f"{ws_url}?token={self.token}" # than in the URL. This avoids browser issues with 401 on
return ws_url # the WebSocket handshake and lets long-lived sockets
# refresh credentials mid-session.
return f"{self.url.rstrip('/')}/api/v1/socket"
async def connect(self): async def connect(self):
"""Establish the persistent websocket connection.""" """Establish the persistent websocket connection and run the
first-frame auth handshake."""
if self._connected: if self._connected:
return 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() ws_url = self._build_ws_url()
self._connect_cm = websockets.connect( self._connect_cm = websockets.connect(
ws_url, ping_interval=20, ping_timeout=self.timeout ws_url, ping_interval=20, ping_timeout=self.timeout
) )
self._socket = await self._connect_cm.__aenter__() 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._connected = True
self._reader_task = asyncio.create_task(self._reader()) self._reader_task = asyncio.create_task(self._reader())

View file

@ -112,10 +112,10 @@ class SocketClient:
return f"ws://{url}" return f"ws://{url}"
def _build_ws_url(self): def _build_ws_url(self):
ws_url = f"{self.url.rstrip('/')}/api/v1/socket" # /api/v1/socket uses the first-frame auth protocol — the
if self.token: # token is sent as the first frame after connecting rather
ws_url = f"{ws_url}?token={self.token}" # than in the URL.
return ws_url return f"{self.url.rstrip('/')}/api/v1/socket"
def _get_loop(self): def _get_loop(self):
"""Get or create the event loop, reusing across calls.""" """Get or create the event loop, reusing across calls."""
@ -132,15 +132,58 @@ class SocketClient:
return self._loop return self._loop
async def _ensure_connected(self): 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: if self._connected:
return 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() ws_url = self._build_ws_url()
self._connect_cm = websockets.connect( self._connect_cm = websockets.connect(
ws_url, ping_interval=20, ping_timeout=self.timeout ws_url, ping_interval=20, ping_timeout=self.timeout
) )
self._socket = await self._connect_cm.__aenter__() 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._connected = True
self._reader_task = asyncio.create_task(self._reader()) self._reader_task = asyncio.create_task(self._reader())

View file

@ -191,6 +191,26 @@ class IamClient(RequestResponse):
timeout=timeout, 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="", async def create_workspace(self, workspace_record, actor="",
timeout=IAM_TIMEOUT): timeout=IAM_TIMEOUT):
resp = await self._request( resp = await self._request(

View file

@ -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-get-document-content = "trustgraph.cli.get_document_content:main"
tg-graph-to-turtle = "trustgraph.cli.graph_to_turtle:main" tg-graph-to-turtle = "trustgraph.cli.graph_to_turtle:main"
tg-bootstrap-iam = "trustgraph.cli.bootstrap_iam: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-agent = "trustgraph.cli.invoke_agent:main"
tg-invoke-document-rag = "trustgraph.cli.invoke_document_rag:main" tg-invoke-document-rag = "trustgraph.cli.invoke_document_rag:main"
tg-invoke-graph-rag = "trustgraph.cli.invoke_graph_rag:main" tg-invoke-graph-rag = "trustgraph.cli.invoke_graph_rag:main"

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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. See docs/tech-specs/capabilities.md for the authoritative description.
The mapping below is the data form of the OSS bundle table in that The data here is the OSS bundle table in that spec. Enterprise
spec. Enterprise editions may replace this module with their own editions may replace this module with their own role table; the
role table; the vocabulary (capability strings) is shared. vocabulary (capability strings) is shared.
The module also exposes: Role model
----------
A role has two dimensions:
- ``PUBLIC`` a sentinel indicating an endpoint requires no 1. **capability set** which operations the role grants.
authentication (login, bootstrap). 2. **workspace scope** which workspaces the role is active in.
- ``AUTHENTICATED`` a sentinel indicating an endpoint requires a
valid identity but no specific capability (e.g. change-password). The authorisation question is: *given the caller's roles, a required
- ``check(roles, capability)`` the union-of-bundles membership test. 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 from aiohttp import web
@ -23,8 +41,8 @@ AUTHENTICATED = "__authenticated__"
# Capability vocabulary. Mirrors the "Capability list" tables in # Capability vocabulary. Mirrors the "Capability list" tables in
# capabilities.md. Kept as a set of valid strings so the gateway can # capabilities.md. Kept as a set so the gateway can fail-closed on
# fail-closed on an endpoint that declares an unknown capability. # an endpoint that declares an unknown capability.
KNOWN_CAPABILITIES = { KNOWN_CAPABILITIES = {
# Data plane # Data plane
"agent", "agent",
@ -47,7 +65,7 @@ KNOWN_CAPABILITIES = {
} }
# OSS role → capability set. Enterprise overrides this mapping. # Capability sets used below.
_READER_CAPS = { _READER_CAPS = {
"agent", "agent",
"graph:read", "graph:read",
@ -81,23 +99,62 @@ _ADMIN_CAPS = _WRITER_CAPS | {
"metrics:read", "metrics:read",
} }
ROLE_CAPABILITIES = {
"reader": _READER_CAPS, # Role definitions. Each role has a capability set and a workspace
"writer": _WRITER_CAPS, # scope. Enterprise overrides this mapping.
"admin": _ADMIN_CAPS, 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): def _scope_permits(role_name, target_workspace, assigned_workspace):
"""Return True if any of ``roles`` grants ``capability``. """Does the given role apply to ``target_workspace``?"""
role = ROLE_DEFINITIONS.get(role_name)
Unknown roles contribute zero capabilities (deterministic fail- if role is None:
closed behaviour per the spec)."""
if capability not in KNOWN_CAPABILITIES:
# Endpoint misconfiguration. Fail closed.
return False return False
for r in roles: scope = role["workspace_scope"]
if capability in ROLE_CAPABILITIES.get(r, ()): 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 True
return False return False
@ -117,18 +174,21 @@ def auth_failure():
async def enforce(request, auth, capability): async def enforce(request, auth, capability):
"""Authenticate + capability-check in one step. Returns an """Authenticate + capability-check for endpoints that carry no
``Identity`` (or ``None`` for ``PUBLIC`` endpoints) or raises workspace dimension on the request (metrics, i18n, etc.).
the appropriate HTTPException.
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, returns ``None``.
- ``AUTHENTICATED``: any valid identity.
- ``PUBLIC``: no authentication attempted, returns ``None``. - capability string: identity must have it, checked against the
- ``AUTHENTICATED``: any valid identity is accepted. caller's assigned workspace (adequate for endpoints whose
- any capability string: identity must carry a role granting it. 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: if capability == PUBLIC:
return None return None
@ -137,27 +197,42 @@ async def enforce(request, auth, capability):
if capability == AUTHENTICATED: if capability == AUTHENTICATED:
return identity return identity
if not check(identity.roles, capability): if not check(identity, capability):
raise access_denied() raise access_denied()
return identity return identity
def enforce_workspace(data, identity): def enforce_workspace(data, identity, capability=None):
"""Validate + inject the workspace field on a request body. """Resolve + validate the workspace on a request body.
OSS behaviour: - Target workspace = ``data["workspace"]`` if supplied, else the
- If ``data["workspace"]`` is present and differs from the caller's assigned workspace.
caller's assigned workspace → 403. - At least one of the caller's roles must (a) be active in the
- Otherwise, set ``data["workspace"]`` to the caller's assigned target workspace and, if ``capability`` is given, (b) grant
workspace. ``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 For ``capability=None`` the workspace scope alone is checked
checks a permitted-set instead of a single value; the wire useful when the body has a workspace but the endpoint already
protocol is unchanged.""" passed its capability check (e.g. via :func:`enforce`)."""
requested = data.get("workspace", "") if isinstance(data, dict) else "" if not isinstance(data, dict):
if requested and requested != identity.workspace: return data
raise access_denied()
if isinstance(data, dict): requested = data.get("workspace", "")
data["workspace"] = identity.workspace target = requested or identity.workspace
return data
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()

View file

@ -122,12 +122,23 @@ class Mux:
}) })
return return
# Workspace resolution. Authenticated sockets override # Workspace resolution. On authenticated sockets the
# the client-supplied workspace with the resolved value # gateway's role-scope rules apply: role workspace scope
# from the identity; mismatch is an access-denied error. # 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: if self.identity is not None:
requested_ws = data.get("workspace", "") from ..capabilities import enforce_workspace
if requested_ws and requested_ws != self.identity.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({ await self.ws.send_json({
"id": request_id, "id": request_id,
"error": { "error": {
@ -137,7 +148,8 @@ class Mux:
"complete": True, "complete": True,
}) })
return return
workspace = self.identity.workspace
workspace = data["workspace"]
else: else:
workspace = data.get("workspace", "default") workspace = data.get("workspace", "default")

View file

@ -168,7 +168,7 @@ class _RoutedSocketEndpoint:
) )
except web.HTTPException as e: except web.HTTPException as e:
return e return e
if not check(identity.roles, cap): if not check(identity, cap):
return access_denied() return access_denied()
# Delegate the websocket handling to a standalone SocketEndpoint # Delegate the websocket handling to a standalone SocketEndpoint

View file

@ -97,7 +97,7 @@ class SocketEndpoint:
except web.HTTPException as e: except web.HTTPException as e:
return e return e
if self.capability != AUTHENTICATED: if self.capability != AUTHENTICATED:
if not check(identity.roles, self.capability): if not check(identity, self.capability):
return access_denied() return access_denied()
# 50MB max message size # 50MB max message size

View file

@ -234,6 +234,10 @@ class IamService:
return await self.handle_update_user(v) return await self.handle_update_user(v)
if op == "disable-user": if op == "disable-user":
return await self.handle_disable_user(v) 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": if op == "create-workspace":
return await self.handle_create_workspace(v) return await self.handle_create_workspace(v)
if op == "list-workspaces": if op == "list-workspaces":
@ -711,6 +715,63 @@ class IamService:
return IamResponse() 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 # Workspace CRUD
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -180,6 +180,9 @@ class IamTableStore:
DELETE FROM iam_users_by_username DELETE FROM iam_users_by_username
WHERE workspace = ? AND username = ? WHERE workspace = ? AND username = ?
""") """)
self.delete_user_stmt = c.prepare("""
DELETE FROM iam_users WHERE id = ?
""")
self.put_api_key_stmt = c.prepare(""" self.put_api_key_stmt = c.prepare("""
INSERT INTO iam_api_keys ( INSERT INTO iam_api_keys (
@ -301,6 +304,17 @@ class IamTableStore:
self.cassandra, self.list_users_by_workspace_stmt, (workspace,), 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 # API keys
# ------------------------------------------------------------------ # ------------------------------------------------------------------