trustgraph/trustgraph-flow/trustgraph/gateway/endpoint/iam_endpoint.py
cybermaggedon 9fc1d4527b
iam: self-service ops, optional workspace filters, Mux service routing (#855)
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.
2026-04-28 22:13:12 +01:00

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)