174 lines
5.7 KiB
Python
174 lines
5.7 KiB
Python
"""Shared decision, rate-limit, and audit lifecycle used at every invocation boundary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import time
|
|
from collections.abc import Callable, Mapping
|
|
from functools import wraps
|
|
from typing import Any, TypeVar, cast
|
|
|
|
from .audit import AuditEvent, AuditLog
|
|
from .engine import Decision, DecisionTrace, InvocationContext, PolicyEngine
|
|
from .rate_limit import RateLimitExceeded, SlidingWindowRateLimiter
|
|
|
|
|
|
class PolicyDenied(PermissionError):
|
|
def __init__(self, trace: DecisionTrace) -> None:
|
|
super().__init__(trace.reason_code)
|
|
self.trace = trace
|
|
self.code = trace.reason_code
|
|
|
|
|
|
T = TypeVar("T", bound=Callable[..., Any])
|
|
|
|
|
|
def governed_tool(tool: str) -> Callable[[T], T]:
|
|
"""Decorate direct connector tool methods with the shared governance lifecycle."""
|
|
|
|
def decorator(method: T) -> T:
|
|
@wraps(method)
|
|
def wrapped(self: Any, context: Any, *args: Any, **kwargs: Any) -> Any:
|
|
governance = getattr(self, "governance", None)
|
|
if governance is None or getattr(self, "_governance_dispatch", False):
|
|
return method(self, context, *args, **kwargs)
|
|
bound = inspect.signature(method).bind(self, context, *args, **kwargs)
|
|
arguments = {
|
|
key: value
|
|
for key, value in bound.arguments.items()
|
|
if key not in {"self", "context"}
|
|
}
|
|
policy_context = governance.context(
|
|
workspace=str(context.workspace_id),
|
|
project=context.project_slug,
|
|
environment=context.environment,
|
|
principal=str(context.client_id or "anonymous"),
|
|
client_id=context.client_id,
|
|
binding_present=context.client_id is not None,
|
|
client_enabled=context.client_id is not None,
|
|
tool=tool,
|
|
connector=getattr(self, "connector_type", "connector"),
|
|
arguments=arguments,
|
|
)
|
|
self._governance_dispatch = True
|
|
try:
|
|
return governance.invoke(
|
|
policy_context, lambda: method(self, context, *args, **kwargs)
|
|
)
|
|
finally:
|
|
self._governance_dispatch = False
|
|
|
|
return cast(T, wrapped)
|
|
|
|
return decorator
|
|
|
|
|
|
class Governance:
|
|
def __init__(
|
|
self,
|
|
engine: PolicyEngine,
|
|
audit: AuditLog,
|
|
*,
|
|
rate_limiter: SlidingWindowRateLimiter | None = None,
|
|
) -> None:
|
|
self.engine = engine
|
|
self.audit = audit
|
|
self.rate_limiter = rate_limiter
|
|
|
|
def begin(self, context: InvocationContext) -> tuple[DecisionTrace, float]:
|
|
started = time.monotonic()
|
|
trace = self.engine.evaluate(context)
|
|
if trace.decision in {Decision.DENY, Decision.DENY_AND_ALERT}:
|
|
self.audit.append(
|
|
AuditEvent.from_context(
|
|
context, trace, outcome="denied", started=started, error_code=trace.reason_code
|
|
)
|
|
)
|
|
raise PolicyDenied(trace)
|
|
if self.rate_limiter is not None:
|
|
try:
|
|
self.rate_limiter.check(
|
|
client_id=context.client_id or context.principal,
|
|
project=context.project,
|
|
tool=context.tool,
|
|
)
|
|
except RateLimitExceeded:
|
|
self.audit.append(
|
|
AuditEvent.from_context(
|
|
context,
|
|
trace,
|
|
outcome="rate_limited",
|
|
started=started,
|
|
error_code="RATE_LIMITED",
|
|
)
|
|
)
|
|
raise
|
|
return trace, started
|
|
|
|
def finish(
|
|
self,
|
|
context: InvocationContext,
|
|
trace: DecisionTrace,
|
|
started: float,
|
|
*,
|
|
outcome: str = "success",
|
|
error_code: str | None = None,
|
|
result_summary: dict[str, Any] | None = None,
|
|
) -> None:
|
|
self.audit.append(
|
|
AuditEvent.from_context(
|
|
context, trace, outcome=outcome, started=started, error_code=error_code,
|
|
result_summary=result_summary,
|
|
)
|
|
)
|
|
|
|
def invoke(self, context: InvocationContext, operation: Callable[[], Any]) -> Any:
|
|
trace, started = self.begin(context)
|
|
try:
|
|
result = operation()
|
|
except Exception as exc:
|
|
self.finish(
|
|
context,
|
|
trace,
|
|
started,
|
|
outcome="error",
|
|
error_code=getattr(exc, "code", type(exc).__name__),
|
|
)
|
|
raise
|
|
summary = None
|
|
if context.tool == "gitea.pullrequest.merge" and isinstance(result, dict):
|
|
data = result.get("data") or {}
|
|
if isinstance(data, dict):
|
|
summary = data.get("auditEvidence")
|
|
self.finish(context, trace, started, result_summary=summary)
|
|
return result
|
|
|
|
@staticmethod
|
|
def context(
|
|
*,
|
|
workspace: str,
|
|
project: str,
|
|
environment: str,
|
|
principal: str,
|
|
tool: str,
|
|
connector: str,
|
|
arguments: Mapping[str, Any],
|
|
client_id: Any = None,
|
|
binding_present: bool = True,
|
|
client_enabled: bool = True,
|
|
**kwargs: Any,
|
|
) -> InvocationContext:
|
|
return InvocationContext(
|
|
workspace=workspace,
|
|
project=project,
|
|
environment=environment,
|
|
principal=principal,
|
|
tool=tool,
|
|
connector=connector,
|
|
arguments=dict(arguments),
|
|
client_id=client_id,
|
|
binding_present=binding_present,
|
|
client_enabled=client_enabled,
|
|
**kwargs,
|
|
)
|