trustgraph/trustgraph-base/trustgraph/schema/services/iam.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

173 lines
4.7 KiB
Python

from dataclasses import dataclass, field
from ..core.topic import queue
from ..core.primitives import Error
############################################################################
# IAM service — see docs/tech-specs/iam-protocol.md for the full protocol.
#
# Transport: request/response pub/sub, correlated by the `id` message
# property. Caller is the API gateway only; the IAM service trusts
# the bus per the enforcement-boundary policy (no per-request auth
# against the caller).
@dataclass
class UserInput:
username: str = ""
name: str = ""
email: str = ""
# Only populated on create-user; never on update-user.
password: str = ""
roles: list[str] = field(default_factory=list)
enabled: bool = True
must_change_password: bool = False
@dataclass
class UserRecord:
id: str = ""
workspace: str = ""
username: str = ""
name: str = ""
email: str = ""
roles: list[str] = field(default_factory=list)
enabled: bool = True
must_change_password: bool = False
created: str = ""
@dataclass
class WorkspaceInput:
id: str = ""
name: str = ""
enabled: bool = True
@dataclass
class WorkspaceRecord:
id: str = ""
name: str = ""
enabled: bool = True
created: str = ""
@dataclass
class ApiKeyInput:
user_id: str = ""
name: str = ""
expires: str = ""
@dataclass
class ApiKeyRecord:
id: str = ""
user_id: str = ""
name: str = ""
# First 4 chars of the plaintext token, for operator identification
# in list-api-keys. Never enough to reconstruct the key.
prefix: str = ""
expires: str = ""
created: str = ""
last_used: str = ""
@dataclass
class IamRequest:
operation: str = ""
# Workspace scope. Required on workspace-scoped operations;
# omitted for system-level ops (workspace CRUD, signing-key
# ops, bootstrap, resolve-api-key, login).
workspace: str = ""
# Acting user id for audit. Empty for internal-origin and for
# operations that resolve an identity (login, resolve-api-key).
actor: str = ""
user_id: str = ""
username: str = ""
key_id: str = ""
api_key: str = ""
password: str = ""
new_password: str = ""
user: UserInput | None = None
workspace_record: WorkspaceInput | None = None
key: ApiKeyInput | None = None
# ---- authorise / authorise-many inputs ----
# Capability string from the vocabulary in capabilities.md.
capability: str = ""
# Resource identifier as JSON. See the IAM contract spec for
# the resource-component vocabulary. An empty dict denotes a
# system-level resource.
resource_json: str = ""
# Operation parameters as JSON. Decision-relevant fields the
# operation supplied that are not part of the resource address
# (e.g. workspace association on create-user).
parameters_json: str = ""
# For authorise-many: a JSON-serialised list of
# {"capability": str, "resource": dict, "parameters": dict}.
authorise_checks: str = ""
@dataclass
class IamResponse:
user: UserRecord | None = None
users: list[UserRecord] = field(default_factory=list)
workspace: WorkspaceRecord | None = None
workspaces: list[WorkspaceRecord] = field(default_factory=list)
# create-api-key returns the plaintext once; never populated
# on any other operation.
api_key_plaintext: str = ""
api_key: ApiKeyRecord | None = None
api_keys: list[ApiKeyRecord] = field(default_factory=list)
# login, rotate-signing-key
jwt: str = ""
jwt_expires: str = ""
# get-signing-key-public
signing_key_public: str = ""
# resolve-api-key
resolved_user_id: str = ""
resolved_workspace: str = ""
resolved_roles: list[str] = field(default_factory=list)
# reset-password
temporary_password: str = ""
# bootstrap
bootstrap_admin_user_id: str = ""
bootstrap_admin_api_key: str = ""
# bootstrap-status — true iff iam-svc is in 'bootstrap' mode with
# empty tables, i.e. an unconsumed bootstrap call would succeed.
bootstrap_available: bool = False
# ---- authorise / authorise-many outputs ----
# authorise: the regime's allow / deny verdict.
decision_allow: bool = False
# Cache TTL the regime suggests, in seconds. Gateway respects
# this for both allow and deny decisions; bounded above by
# gateway-side policy (typically <= 60s).
decision_ttl_seconds: int = 0
# authorise-many: a JSON-serialised list of {"allow": bool,
# "ttl": int} in the same order as the request's
# authorise_checks.
decisions_json: str = ""
error: Error | None = None
iam_request_queue = queue('iam', cls='request')
iam_response_queue = queue('iam', cls='response')
############################################################################