Files
FabledSteward/steward/core/capabilities.py
T
bvandeusen f3e919892d
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m10s
feat(plugins): plugin capability registry + host_agent→Ansible deploy synergy
Implements #253's framework: a small core capability registry
(steward/core/capabilities.py) where a module/plugin publishes a named,
role-gated action and a consumer discovers it via has_capability() and runs it
via invoke_capability() — no hard import, graceful degradation, permission
propagation (actor role checked against the capability's required_role).

Core publishes "ansible.run_playbook" (operator) wrapping ansible.runner.
trigger_run (extended to accept a caller-built inventory). First consumer: the
host_agent plugin gains "Deploy via Ansible" on its settings page — pick an
inventory target/group and it installs/updates the agent via the bundled
host_agent/install.yml, minting a fresh token per host and injecting it as an
inventory hostvar (turning per-host curl|sh into one run). Exposed role
ordering as middleware.role_meets. Unit tests for the registry + role checks.

Task #253 (milestone #37).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:28:29 -04:00

96 lines
2.8 KiB
Python

# 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