mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
Three threads, all reinforcing the contract's system-level vs.
workspace-association distinction.
WS Mux service routing
- tg-show-flows (and any workspace-level service over the WS) was
failing with "unknown service" because the post-refactor Mux
unconditionally looked up flow-service:<kind>. Now branches on
the envelope's flow field: with flow → flow-service:<kind>;
without flow → <kind>:<op> from the inner body; with bare op
lookup for service=iam. Resource and parameters come from the
matched op's own extractors — same path the HTTP endpoints take.
Optional workspace on system-level user/key ops
- list-users returns the deployment-wide list when no workspace is
supplied, filters when one is. get-user, update-user,
disable-user, enable-user, delete-user, reset-password,
create-api-key, list-api-keys, revoke-api-key all treat workspace
as an optional integrity check rather than a required argument.
- create-user keeps workspace required — there it's the new user's
home-workspace binding, a parameter rather than an address.
- API keys reclassified as SYSTEM-level resources. By the same
reasoning that makes users system-level, an API key is a
credential record on a deployment-wide registry; the workspace it
authenticates to is a property, not a containment.
Self-service surface
- whoami: returns the caller's own user record. AUTHENTICATED-only;
no users:read capability required. Foundation for UI affordances
that depend on the caller's permissions.
- bootstrap-status: POST /api/v1/auth/bootstrap-status, PUBLIC,
side-effect-free. Returns {bootstrap_available: bool} so a
first-run UI can decide whether to render setup without consuming
the bootstrap op.
- Gateway now injects actor=identity.handle on every authenticated
forward to iam-svc (IamEndpoint and WS Mux iam path), overwriting
any caller-supplied value. Underpins whoami, audit logging, and
future regime-side decisions that need actor identity.
- tg-whoami and tg-update-user CLIs.
Spec polish
- iam-contract.md: actor-injection rule documented; whoami /
bootstrap-status added to operations list; permission-scope
framing tightened (workspace scope is a property of the grant,
not the user or role).
- iam.md: self-service section; gateway flow gains the actor-
injection step; role section reframed so iam-svc constraints
don't leak into contract-level prose.
- iam-protocol.md: ops table updated for whoami, bootstrap-status,
optional-workspace pattern; bootstrap_available added to the
IamResponse listing.
114 lines
3.8 KiB
Python
114 lines
3.8 KiB
Python
"""
|
|
Registry-driven /api/v1/iam endpoint.
|
|
|
|
The gateway no longer gates IAM management with a single coarse
|
|
``users:admin`` capability. Instead, each operation declares its
|
|
own capability + resource shape in the registry (``registry.py``);
|
|
this endpoint reads the body's ``operation`` field, looks up the
|
|
declaration, and asks the IAM regime to authorise the call.
|
|
|
|
Operations not in the registry produce a 400 ``unknown operation``.
|
|
This is the gateway's primary mechanism for fail-closed gating of
|
|
the IAM surface — the registry is the source of truth.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from aiohttp import web
|
|
|
|
from .. capabilities import (
|
|
PUBLIC, AUTHENTICATED, auth_failure,
|
|
)
|
|
from .. registry import lookup, RequestContext
|
|
|
|
logger = logging.getLogger("iam-endpoint")
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
|
class IamEndpoint:
|
|
"""POST /api/v1/iam — generic forwarder gated by the operation
|
|
registry. The IAM dispatcher (``iam_dispatcher``) forwards the
|
|
body verbatim to iam-svc once authorisation succeeds."""
|
|
|
|
def __init__(self, endpoint_path, auth, dispatcher):
|
|
self.path = endpoint_path
|
|
self.auth = auth
|
|
self.dispatcher = dispatcher
|
|
|
|
async def start(self):
|
|
pass
|
|
|
|
def add_routes(self, app):
|
|
app.add_routes([web.post(self.path, self.handle)])
|
|
|
|
async def handle(self, request):
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return web.json_response(
|
|
{"error": "invalid json"}, status=400,
|
|
)
|
|
if not isinstance(body, dict):
|
|
return web.json_response(
|
|
{"error": "body must be an object"}, status=400,
|
|
)
|
|
|
|
op_name = body.get("operation", "")
|
|
op = lookup(op_name)
|
|
if op is None:
|
|
return web.json_response(
|
|
{"error": "unknown operation"}, status=400,
|
|
)
|
|
|
|
# Authentication: required for everything except PUBLIC.
|
|
identity = None
|
|
if op.capability != PUBLIC:
|
|
try:
|
|
identity = await self.auth.authenticate(request)
|
|
except web.HTTPException:
|
|
raise
|
|
|
|
# Authorisation: capability sentinels short-circuit the
|
|
# regime call; capability strings go through authorise().
|
|
if op.capability not in (PUBLIC, AUTHENTICATED):
|
|
ctx = RequestContext(
|
|
body=body,
|
|
match_info=dict(request.match_info),
|
|
identity=identity,
|
|
)
|
|
try:
|
|
resource = op.extract_resource(ctx)
|
|
parameters = op.extract_parameters(ctx)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"extractor failed for {op_name!r}: "
|
|
f"{type(e).__name__}: {e}"
|
|
)
|
|
return web.json_response(
|
|
{"error": "bad request"}, status=400,
|
|
)
|
|
|
|
await self.auth.authorise(
|
|
identity, op.capability, resource, parameters,
|
|
)
|
|
|
|
# Plumb the authenticated caller's handle through as ``actor``
|
|
# so iam-svc handlers (e.g. whoami, future actor-scoped
|
|
# checks) know who is making the request. The gateway is
|
|
# the only authority for this — body-supplied ``actor``
|
|
# values are overwritten so callers can't impersonate.
|
|
if identity is not None:
|
|
body["actor"] = identity.handle
|
|
|
|
async def responder(x, fin):
|
|
pass
|
|
|
|
try:
|
|
resp = await self.dispatcher.process(body, responder)
|
|
except web.HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Exception: {e}", exc_info=True)
|
|
return web.json_response({"error": str(e)})
|
|
|
|
return web.json_response(resp)
|