# steward/core/capabilities.py """Plugin/core capability registry — opportunistic, decoupled synergy. A capability is a named, permission-gated action that one part of the system (core module or plugin) publishes and another can discover + invoke WITHOUT a hard import. The publisher registers a callable under a string key; a consumer checks `has_capability(key)` (graceful degradation — the synergy is a bonus, never a requirement) and calls `invoke_capability(key, actor_role, ...)`. First consumer: the host_agent plugin invokes "ansible.run_playbook" to deploy its agent via Ansible instead of importing the Ansible runner directly. Security: every capability declares a required_role; invoke_capability enforces the caller's role meets it, so a low-privilege context can't drive a privileged action in another module. """ from __future__ import annotations import inspect import logging from dataclasses import dataclass from typing import Callable from steward.auth.middleware import role_meets from steward.models.users import UserRole logger = logging.getLogger(__name__) class CapabilityUnavailable(Exception): """Raised when an unknown capability key is invoked.""" class CapabilityForbidden(Exception): """Raised when the actor's role is below the capability's required_role.""" @dataclass class Capability: key: str fn: Callable label: str description: str required_role: UserRole _CAPABILITIES: dict[str, Capability] = {} def register_capability( key: str, fn: Callable, *, label: str, description: str = "", required_role: UserRole = UserRole.admin, ) -> None: """Publish a capability. Last registration for a key wins (idempotent re-register).""" _CAPABILITIES[key] = Capability(key, fn, label, description, required_role) def has_capability(key: str) -> bool: return key in _CAPABILITIES def get_capability(key: str) -> Capability | None: return _CAPABILITIES.get(key) def list_capabilities() -> list[Capability]: return list(_CAPABILITIES.values()) def clear_capabilities() -> None: """Reset the registry (tests).""" _CAPABILITIES.clear() async def invoke_capability(key: str, actor_role: UserRole, /, *args, **kwargs): """Invoke a registered capability after a role check. Awaits async callables. Raises CapabilityUnavailable if the key isn't registered, CapabilityForbidden if actor_role is insufficient. """ cap = _CAPABILITIES.get(key) if cap is None: raise CapabilityUnavailable(key) if not role_meets(actor_role, cap.required_role): raise CapabilityForbidden( f"capability {key!r} requires role {cap.required_role.value}" ) result = cap.fn(*args, **kwargs) if inspect.isawaitable(result): result = await result return result