trustgraph/trustgraph-base/trustgraph/schema/services/iam.py
Cyber MacGeddon 006c40ebf7 refactor(iam): pluggable IAM regime via authenticate/authorise contract
The gateway no longer holds any policy state — capability sets, role
definitions, workspace scope rules.  Per the IAM contract it asks the
regime "may this identity perform this capability on this resource?"
per request.  That moves the OSS role-based regime entirely into
iam-svc, which can be replaced (SSO, ABAC, ReBAC) without changing
the gateway, the wire protocol, or backend services.

Contract:
- authenticate(credential) -> Identity (handle, workspace,
  principal_id, source).  No roles, claims, or policy state surface
  to the gateway.
- authorise(identity, capability, resource, parameters) -> (allow,
  ttl).  Cached per-decision (regime TTL clamped above; fail-closed
  on regime errors).
- authorise_many available as a fan-out variant.

Operation registry drives every authorisation decision:
- /api/v1/iam -> IamEndpoint, looks up bare op name (create-user,
  list-workspaces, ...).
- /api/v1/{kind} -> RegistryRoutedVariableEndpoint, <kind>:<op>
  (config:get, flow:list-blueprints, librarian:add-document, ...).
- /api/v1/flow/{flow}/service/{kind} -> flow-service:<kind>.
- /api/v1/flow/{flow}/{import,export}/{kind} ->
  flow-{import,export}:<kind>.
- WS Mux per-frame -> flow-service:<kind>; closes a gap where
  authenticated users could hit any service kind.
85 operations registered across the surface.

JWT carries identity only — sub + workspace.  The roles claim is gone;
the gateway never reads policy state from a credential.

The three coarse *_KIND_CAPABILITY maps are removed.  The registry is
the only source of truth for the capability + resource shape of an
operation.  Tests migrated to the new Identity shape and to
authorise()-mocked auth doubles.

Specs updated: docs/tech-specs/iam-contract.md (Identity surface,
caching, registry-naming conventions), iam.md (JWT shape, gateway
flow, role section reframed as OSS-regime detail), iam-protocol.md
(positioned as one implementation of the contract).
2026-04-28 16:19:06 +01:00

169 lines
4.6 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 = ""
# ---- 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')
############################################################################