Updated CLI

This commit is contained in:
Cyber MacGeddon 2026-04-24 12:41:46 +01:00
parent 3bdb677607
commit 9ae79ff712
16 changed files with 558 additions and 105 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

@ -191,6 +191,16 @@ 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 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

@ -45,6 +45,7 @@ tg-login = "trustgraph.cli.login:main"
tg-create-user = "trustgraph.cli.create_user:main" tg-create-user = "trustgraph.cli.create_user:main"
tg-list-users = "trustgraph.cli.list_users:main" tg-list-users = "trustgraph.cli.list_users:main"
tg-disable-user = "trustgraph.cli.disable_user:main" tg-disable-user = "trustgraph.cli.disable_user:main"
tg-enable-user = "trustgraph.cli.enable_user:main"
tg-change-password = "trustgraph.cli.change_password:main" tg-change-password = "trustgraph.cli.change_password:main"
tg-reset-password = "trustgraph.cli.reset_password:main" tg-reset-password = "trustgraph.cli.reset_password:main"
tg-create-api-key = "trustgraph.cli.create_api_key:main" tg-create-api-key = "trustgraph.cli.create_api_key:main"

View file

@ -17,10 +17,10 @@ def do_create_api_key(args):
if args.expires: if args.expires:
key["expires"] = args.expires key["expires"] = args.expires
resp = call_iam(args.api_url, args.token, { req = {"operation": "create-api-key", "key": key}
"operation": "create-api-key", if args.workspace:
"key": key, req["workspace"] = args.workspace
}) resp = call_iam(args.api_url, args.token, req)
plaintext = resp.get("api_key_plaintext", "") plaintext = resp.get("api_key_plaintext", "")
rec = resp.get("api_key", {}) rec = resp.get("api_key", {})
@ -57,6 +57,13 @@ def main():
"--expires", default=None, "--expires", default=None,
help="ISO-8601 expiry (optional; empty = no expiry)", 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) run_main(do_create_api_key, parser)

View file

@ -29,6 +29,8 @@ def do_create_user(args):
user["must_change_password"] = True user["must_change_password"] = True
req = {"operation": "create-user", "user": user} req = {"operation": "create-user", "user": user}
if args.workspace:
req["workspace"] = args.workspace
resp = call_iam(args.api_url, args.token, req) resp = call_iam(args.api_url, args.token, req)
rec = resp.get("user", {}) rec = resp.get("user", {})
@ -71,6 +73,13 @@ def main():
"--must-change-password", action="store_true", "--must-change-password", action="store_true",
help="Force password change on next login", 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) run_main(do_create_user, parser)

View file

@ -9,10 +9,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main
def do_disable_user(args): def do_disable_user(args):
call_iam(args.api_url, args.token, { req = {"operation": "disable-user", "user_id": args.user_id}
"operation": "disable-user", if args.workspace:
"user_id": args.user_id, req["workspace"] = args.workspace
}) call_iam(args.api_url, args.token, req)
print(f"Disabled user {args.user_id}") print(f"Disabled user {args.user_id}")
@ -31,6 +31,13 @@ def main():
parser.add_argument( parser.add_argument(
"--user-id", required=True, help="User id to disable", "--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) run_main(do_disable_user, parser)

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

@ -10,10 +10,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main
def do_list_api_keys(args): def do_list_api_keys(args):
resp = call_iam(args.api_url, args.token, { req = {"operation": "list-api-keys", "user_id": args.user_id}
"operation": "list-api-keys", if args.workspace:
"user_id": args.user_id, req["workspace"] = args.workspace
}) resp = call_iam(args.api_url, args.token, req)
keys = resp.get("api_keys", []) keys = resp.get("api_keys", [])
if not keys: if not keys:
@ -55,6 +55,13 @@ def main():
"--user-id", required=True, "--user-id", required=True,
help="Owner user id", 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) run_main(do_list_api_keys, parser)

View file

@ -10,9 +10,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main
def do_list_users(args): def do_list_users(args):
resp = call_iam( req = {"operation": "list-users"}
args.api_url, args.token, {"operation": "list-users"}, if args.workspace:
) req["workspace"] = args.workspace
resp = call_iam(args.api_url, args.token, req)
users = resp.get("users", []) users = resp.get("users", [])
if not users: if not users:
@ -50,6 +51,13 @@ def main():
"-t", "--token", default=DEFAULT_TOKEN, "-t", "--token", default=DEFAULT_TOKEN,
help="Auth token (default: $TRUSTGRAPH_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) run_main(do_list_users, parser)

View file

@ -10,10 +10,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main
def do_reset_password(args): def do_reset_password(args):
resp = call_iam(args.api_url, args.token, { req = {"operation": "reset-password", "user_id": args.user_id}
"operation": "reset-password", if args.workspace:
"user_id": args.user_id, req["workspace"] = args.workspace
}) resp = call_iam(args.api_url, args.token, req)
tmp = resp.get("temporary_password", "") tmp = resp.get("temporary_password", "")
if not tmp: if not tmp:
@ -40,6 +40,13 @@ def main():
"--user-id", required=True, "--user-id", required=True,
help="Target user id", 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) run_main(do_reset_password, parser)

View file

@ -8,10 +8,10 @@ from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main
def do_revoke_api_key(args): def do_revoke_api_key(args):
call_iam(args.api_url, args.token, { req = {"operation": "revoke-api-key", "key_id": args.key_id}
"operation": "revoke-api-key", if args.workspace:
"key_id": args.key_id, req["workspace"] = args.workspace
}) call_iam(args.api_url, args.token, req)
print(f"Revoked key {args.key_id}") print(f"Revoked key {args.key_id}")
@ -30,6 +30,13 @@ def main():
parser.add_argument( parser.add_argument(
"--key-id", required=True, help="Key id to revoke", "--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) run_main(do_revoke_api_key, parser)

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:
raise access_denied()
if isinstance(data, dict):
data["workspace"] = identity.workspace
return data 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()

View file

@ -122,23 +122,23 @@ class Mux:
}) })
return return
# Workspace resolution. Authenticated sockets override # Workspace resolution. On authenticated sockets the
# any client-supplied workspace — on both the envelope and # gateway's role-scope rules apply: role workspace scope
# the inner request payload — with the resolved value from # determines which target workspaces are permitted. The
# the identity. A mismatched value at either layer is an # resolved value is written to both the envelope and the
# access-denied error. Injecting into the inner request # inner request payload so clients don't have to repeat it
# means clients don't have to repeat the workspace in # per-message (same convenience HTTP callers get via
# every payload; the same convenience HTTP callers get # enforce_workspace).
# via enforce_workspace.
if self.identity is not None: if self.identity is not None:
for layer, blob in ( from ..capabilities import enforce_workspace
("envelope", data), from aiohttp import web as _web
("inner", data.get("request")),
): try:
if not isinstance(blob, dict): enforce_workspace(data, self.identity)
continue inner = data.get("request")
req = blob.get("workspace", "") if isinstance(inner, dict):
if req and req != self.identity.workspace: 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": {
@ -148,9 +148,8 @@ class Mux:
"complete": True, "complete": True,
}) })
return return
blob["workspace"] = self.identity.workspace
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,8 @@ 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 == "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 +713,23 @@ 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()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Workspace CRUD # Workspace CRUD
# ------------------------------------------------------------------ # ------------------------------------------------------------------