"""Per-endpoint management-API permission matrix. Framework-agnostic (no FastAPI import) so the matrix itself is directly unit-testable without standing up an app. `services/runtime/control_api.py` wires this into FastAPI dependencies. Replaces the previous "has at least one management role" gate — under which a viewer, an auditor, and an admin were functionally identical in what they were allowed to do, including the most destructive operation in the API (catalog reconciliation) — with an explicit, deny-by-default table checked against every route. ## The matrix | Action | viewer | auditor | operator | admin | |---|---|---|---|---| | dashboard.read | X | X | X | X | | resource.read (projects/servers/tools/connectors/policies/clients) | X | X | X | X | | audit.read | | X | X | X | | policy.simulate | X | X | X | X | | catalog.preview | X | X | X | X | | tool.test | | | X | X | | tool.invoke | | | X | X | | operations.check | | | X | X | | client.bootstrap | | | X | X | | client.disable | | | X | X | | client.revoke | | | X | X | | client.create | | | | X | | catalog.apply | | | | X | Rationale for the split: `viewer`/`auditor` are both read-only, distinguished only by whether audit/evidence access is included. `operator` additionally gets bounded, reversible-in-effect-if-not-in-mechanism client lifecycle actions on *existing* clients (bootstrap re-issuance, disable, revoke) -- useful for incident response without granting provisioning power. `admin` alone can mint new trusted client identity (`client.create`) or mutate the catalog for real (`catalog.apply`); both create durable, hard-to-undo state. `policy.simulate` and `catalog.preview` are side-effect-free by contract (see services/control_api/policy/service.py and services/control_api/catalog/router.py) and are available to every role, same as any other read. """ from __future__ import annotations import logging from collections.abc import Mapping from services.control_api.auth.models import AuthenticatedPrincipal, ManagementRole logger = logging.getLogger("itworx_mcp_hub.management_authz") _R = ManagementRole ENDPOINT_PERMISSIONS: Mapping[str, frozenset[ManagementRole]] = { "dashboard.read": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}), "resource.read": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}), "audit.read": frozenset({_R.AUDITOR, _R.OPERATOR, _R.ADMIN}), "policy.simulate": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}), "catalog.preview": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}), "tool.test": frozenset({_R.OPERATOR, _R.ADMIN}), "tool.invoke": frozenset({_R.OPERATOR, _R.ADMIN}), "operations.check": frozenset({_R.OPERATOR, _R.ADMIN}), "client.read": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}), "client.bootstrap": frozenset({_R.OPERATOR, _R.ADMIN}), "client.disable": frozenset({_R.OPERATOR, _R.ADMIN}), "client.revoke": frozenset({_R.OPERATOR, _R.ADMIN}), "client.create": frozenset({_R.ADMIN}), "catalog.apply": frozenset({_R.ADMIN}), } # Actions available to every authenticated management role are not # "privileged" in the audit sense -- logging every dashboard poll would be # pure noise. Anything requiring more than baseline viewer access is logged # on both allow and deny; baseline reads are logged only on deny. _BASELINE_ROLES = frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}) class InsufficientRole(PermissionError): """Raised when a principal's roles don't satisfy an action's requirement.""" def __init__(self, action: str, principal: AuthenticatedPrincipal) -> None: self.action = action self.principal_roles = principal.roles super().__init__(f"role(s) {sorted(principal.roles)} cannot perform {action!r}") def check_role(principal: AuthenticatedPrincipal, action: str) -> None: """Raise InsufficientRole unless `principal` has a role permitted for `action`. Every call is logged: denials always, and allows for any action whose permitted role set is narrower than the full baseline (i.e. every non-universally-available action) -- "audit every privileged action and denial" from the WP-230 acceptance criteria. """ try: allowed = ENDPOINT_PERMISSIONS[action] except KeyError as exc: raise ValueError(f"unknown management action: {action!r}") from exc is_privileged = allowed != _BASELINE_ROLES if not (principal.roles & allowed): logger.warning( "management_authz_denied", extra={ "action": action, "subject": principal.subject, "principal_roles": sorted(role.value for role in principal.roles), "allowed_roles": sorted(role.value for role in allowed), }, ) raise InsufficientRole(action, principal) if is_privileged: logger.info( "management_authz_allowed", extra={ "action": action, "subject": principal.subject, "principal_roles": sorted(role.value for role in principal.roles), }, )