Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,34 @@
|
||||
"""Deterministic, deny-first policy, audit, and rate-limit primitives."""
|
||||
|
||||
from .audit import AuditEvent, AuditLog, AuditPage, InMemoryAuditLog, sanitize_arguments
|
||||
from .engine import (
|
||||
Decision,
|
||||
DecisionTrace,
|
||||
InvocationContext,
|
||||
PolicyEngine,
|
||||
PolicyRule,
|
||||
PolicySimulator,
|
||||
PolicyValidationError,
|
||||
)
|
||||
from .governance import Governance, PolicyDenied, governed_tool
|
||||
from .rate_limit import RateLimitExceeded, SlidingWindowRateLimiter
|
||||
|
||||
__all__ = [
|
||||
"AuditEvent",
|
||||
"AuditLog",
|
||||
"AuditPage",
|
||||
"Decision",
|
||||
"DecisionTrace",
|
||||
"Governance",
|
||||
"governed_tool",
|
||||
"InMemoryAuditLog",
|
||||
"InvocationContext",
|
||||
"PolicyDenied",
|
||||
"PolicyEngine",
|
||||
"PolicyRule",
|
||||
"PolicySimulator",
|
||||
"PolicyValidationError",
|
||||
"RateLimitExceeded",
|
||||
"SlidingWindowRateLimiter",
|
||||
"sanitize_arguments",
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Sanitized append-only audit ingestion, search, retention, and export metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .engine import DecisionTrace
|
||||
|
||||
_SECRET_VALUE = re.compile(r"(?:bearer\s+|eyJ[A-Za-z0-9_-]{20,}|-----BEGIN[^-]+-----)", re.I)
|
||||
|
||||
_SENSITIVE = re.compile(
|
||||
r"(?:password|secret|token|private|authorization|cookie|credential|api[_-]?key)", re.I
|
||||
)
|
||||
|
||||
|
||||
def sanitize_arguments(value: Any, *, max_bytes: int = 16_384, _depth: int = 0) -> Any:
|
||||
if _depth > 5:
|
||||
return "<depth-limited>"
|
||||
result: Any
|
||||
if isinstance(value, Mapping):
|
||||
result = {
|
||||
str(key)[:80]: (
|
||||
"<redacted>"
|
||||
if _SENSITIVE.search(str(key))
|
||||
else sanitize_arguments(item, max_bytes=max_bytes, _depth=_depth + 1)
|
||||
)
|
||||
for key, item in list(value.items())[:64]
|
||||
}
|
||||
elif isinstance(value, list | tuple):
|
||||
result = [
|
||||
sanitize_arguments(item, max_bytes=max_bytes, _depth=_depth + 1)
|
||||
for item in list(value)[:64]
|
||||
]
|
||||
elif isinstance(value, str | int | float | bool) or value is None:
|
||||
if isinstance(value, str) and _SECRET_VALUE.search(value):
|
||||
result = "<redacted>"
|
||||
else:
|
||||
result = value if not isinstance(value, str) else value[:2048]
|
||||
else:
|
||||
result = f"<{type(value).__name__}>"
|
||||
if len(json.dumps(result, sort_keys=True, default=str).encode()) > max_bytes:
|
||||
return "<arguments-truncated>"
|
||||
return result
|
||||
|
||||
|
||||
class AuditEvent(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
occurred_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
trace_id: str = Field(min_length=1, max_length=128)
|
||||
event_type: str = Field(default="mcp.tool.invoked", max_length=128)
|
||||
workspace: str = Field(min_length=1, max_length=128)
|
||||
project: str = Field(min_length=1, max_length=128)
|
||||
environment: str = Field(min_length=1, max_length=32)
|
||||
principal: str = Field(min_length=1, max_length=256)
|
||||
client_id: UUID | None = None
|
||||
tool: str = Field(min_length=1, max_length=256)
|
||||
policy_decision: str
|
||||
matched_rule_ids: tuple[str, ...] = ()
|
||||
policy_trace: tuple[dict[str, Any], ...] = ()
|
||||
arguments_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
sanitized_arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
duration_ms: int = Field(ge=0, le=120_000, default=0)
|
||||
outcome: str = Field(pattern=r"^(success|denied|error|rate_limited)$")
|
||||
error_code: str | None = None
|
||||
retention_until: datetime | None = None
|
||||
|
||||
@classmethod
|
||||
def from_context(
|
||||
cls,
|
||||
context: Any,
|
||||
trace: DecisionTrace,
|
||||
*,
|
||||
outcome: str,
|
||||
started: float,
|
||||
error_code: str | None = None,
|
||||
result_summary: dict[str, Any] | None = None,
|
||||
) -> AuditEvent:
|
||||
import time
|
||||
|
||||
raw = json.dumps(dict(context.arguments), sort_keys=True, default=str).encode()
|
||||
return cls(
|
||||
trace_id=trace.trace_id,
|
||||
workspace=context.workspace,
|
||||
project=context.project,
|
||||
environment=context.environment,
|
||||
principal=context.principal,
|
||||
client_id=context.client_id,
|
||||
tool=context.tool,
|
||||
policy_decision=trace.decision.value,
|
||||
matched_rule_ids=trace.matched_rule_ids,
|
||||
policy_trace=tuple(item.model_dump(mode="json") for item in trace.steps),
|
||||
arguments_hash=hashlib.sha256(raw).hexdigest(),
|
||||
sanitized_arguments=sanitize_arguments({
|
||||
**dict(context.arguments),
|
||||
**({"_result": result_summary} if result_summary else {}),
|
||||
}),
|
||||
duration_ms=min(120_000, int((time.monotonic() - started) * 1000)),
|
||||
outcome=outcome,
|
||||
error_code=error_code,
|
||||
retention_until=datetime.now(UTC) + timedelta(days=30),
|
||||
)
|
||||
|
||||
|
||||
class AuditPage(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
items: tuple[AuditEvent, ...]
|
||||
next_offset: int | None = None
|
||||
|
||||
|
||||
class AuditLog(Protocol):
|
||||
def append(self, event: AuditEvent) -> None: ...
|
||||
def search(
|
||||
self, *, workspace: str, project: str, limit: int = 100, offset: int = 0
|
||||
) -> AuditPage: ...
|
||||
def export_metadata(self, *, workspace: str, project: str) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class InMemoryAuditLog:
|
||||
"""Reference sink used by services and tests; it deliberately has no update/delete API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[AuditEvent] = []
|
||||
|
||||
def append(self, event: AuditEvent) -> None:
|
||||
if any(item.id == event.id for item in self.events):
|
||||
raise ValueError("audit event IDs are immutable")
|
||||
self.events.append(event)
|
||||
|
||||
def search(
|
||||
self, *, workspace: str, project: str, limit: int = 100, offset: int = 0
|
||||
) -> AuditPage:
|
||||
if not 1 <= limit <= 500 or offset < 0:
|
||||
raise ValueError("invalid audit pagination")
|
||||
items = tuple(
|
||||
item for item in self.events if item.workspace == workspace and item.project == project
|
||||
)
|
||||
selected = items[offset : offset + limit]
|
||||
next_offset = offset + len(selected) if offset + len(selected) < len(items) else None
|
||||
return AuditPage(items=selected, next_offset=next_offset)
|
||||
|
||||
def export_metadata(self, *, workspace: str, project: str) -> dict[str, Any]:
|
||||
events = self.search(workspace=workspace, project=project, limit=500).items
|
||||
digest = hashlib.sha256(
|
||||
"".join(item.model_dump_json() for item in events).encode()
|
||||
).hexdigest()
|
||||
return {
|
||||
"workspace": workspace,
|
||||
"project": project,
|
||||
"eventCount": len(events),
|
||||
"sha256": digest,
|
||||
"generatedAt": datetime.now(UTC).isoformat(),
|
||||
"format": "audit-event-v1",
|
||||
"access": "policy-restricted",
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Small typed policy evaluator shared by management, router, and connectors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class PolicyValidationError(ValueError):
|
||||
"""A policy contains an unsupported or unsafe selector."""
|
||||
|
||||
|
||||
class Decision(StrEnum):
|
||||
ALLOW = "allow"
|
||||
ALLOW_WITH_PREVIEW = "allow_with_preview"
|
||||
ALLOW_WITH_APPROVAL = "allow_with_approval"
|
||||
ALLOW_IF_CONDITIONS = "allow_if_conditions"
|
||||
ENFORCE = "enforce"
|
||||
DENY = "deny"
|
||||
DENY_AND_ALERT = "deny_and_alert"
|
||||
|
||||
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9._:/-]{1,256}$")
|
||||
_MATCH_FIELDS = {
|
||||
"workspace",
|
||||
"workspaceId",
|
||||
"project",
|
||||
"projectId",
|
||||
"projects",
|
||||
"projectSlugs",
|
||||
"environment",
|
||||
"environments",
|
||||
"environmentClassifications",
|
||||
"client",
|
||||
"clientId",
|
||||
"clientIds",
|
||||
"principal",
|
||||
"principalType",
|
||||
"virtualServer",
|
||||
"virtualServers",
|
||||
"capability",
|
||||
"capabilities",
|
||||
"tool",
|
||||
"tools",
|
||||
"toolPatterns",
|
||||
"resourceType",
|
||||
"resourceTypes",
|
||||
"connector",
|
||||
"connectors",
|
||||
"riskClass",
|
||||
"riskClasses",
|
||||
"origin",
|
||||
"origins",
|
||||
}
|
||||
_CONDITION_FIELDS = {
|
||||
"requireEnabledClient",
|
||||
"requireBinding",
|
||||
"requireScope",
|
||||
"requireCapabilityPack",
|
||||
"requireConnectorBinding",
|
||||
"requireInputValidation",
|
||||
"requireOutputValidation",
|
||||
"requireRedaction",
|
||||
"maxArgumentBytes",
|
||||
"allEqual",
|
||||
"requiredClaims",
|
||||
"maxDefaultBytes",
|
||||
"maxDefaultItems",
|
||||
"redactionProfile",
|
||||
}
|
||||
|
||||
|
||||
class InvocationContext(BaseModel):
|
||||
"""Validated identity and target context used at every sensitive boundary."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
workspace: str = Field(min_length=1, max_length=128)
|
||||
project: str = Field(min_length=1, max_length=128)
|
||||
environment: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,31}$")
|
||||
client_id: UUID | None = None
|
||||
principal: str = Field(min_length=1, max_length=256)
|
||||
principal_type: str = Field(default="machine", max_length=64)
|
||||
virtual_server: str | None = Field(default=None, max_length=128)
|
||||
capability: str | None = Field(default=None, max_length=256)
|
||||
tool: str = Field(min_length=1, max_length=256)
|
||||
connector: str = Field(min_length=1, max_length=64)
|
||||
risk_class: str = Field(default="R1", pattern=r"^(?:R[0-9]+|RX)$")
|
||||
origin: str = Field(default="mcp", max_length=64)
|
||||
resource_type: str | None = Field(default=None, max_length=64)
|
||||
environment_classification: str | None = Field(default=None, max_length=64)
|
||||
binding_present: bool = False
|
||||
client_enabled: bool = False
|
||||
arguments: Mapping[str, Any] = Field(default_factory=dict)
|
||||
trace_id: str = Field(default_factory=lambda: uuid4().hex, pattern=r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
|
||||
|
||||
class PolicyRule(BaseModel):
|
||||
"""A catalog or persisted rule with only typed selector primitives."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
workspace: str | None = None
|
||||
project: str | None = None
|
||||
priority: int = Field(ge=0, le=100000)
|
||||
description: str = Field(min_length=1, max_length=1000)
|
||||
match: dict[str, Any] = Field(default_factory=dict)
|
||||
conditions: dict[str, Any] = Field(default_factory=dict)
|
||||
decision: Decision
|
||||
terminal: bool = False
|
||||
source: str = Field(default="catalog", max_length=64)
|
||||
enabled: bool = True
|
||||
|
||||
@field_validator("match", "conditions")
|
||||
@classmethod
|
||||
def safe_maps(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||
if len(value) > 32:
|
||||
raise PolicyValidationError("policy selector map is too large")
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_document(cls, document: Mapping[str, Any]) -> PolicyRule:
|
||||
metadata = document.get("metadata") or {}
|
||||
spec = document.get("spec") or {}
|
||||
if not isinstance(metadata, Mapping) or not isinstance(spec, Mapping):
|
||||
raise PolicyValidationError("policy document metadata and spec must be objects")
|
||||
match = dict(spec.get("match") or {})
|
||||
conditions = dict(spec.get("conditions") or {})
|
||||
unknown_match = set(match) - _MATCH_FIELDS
|
||||
unknown_conditions = set(conditions) - _CONDITION_FIELDS
|
||||
if unknown_match or unknown_conditions:
|
||||
raise PolicyValidationError(
|
||||
f"unsupported policy fields: {sorted(unknown_match | unknown_conditions)}"
|
||||
)
|
||||
project = spec.get("project")
|
||||
workspace = spec.get("workspace")
|
||||
return cls(
|
||||
slug=str(metadata.get("slug", "")),
|
||||
workspace=str(workspace) if workspace is not None else None,
|
||||
project=str(project) if project is not None else None,
|
||||
priority=int(spec.get("priority", 0)),
|
||||
description=str(spec.get("description", "")),
|
||||
match=match,
|
||||
conditions=conditions,
|
||||
decision=Decision(str(spec.get("decision", "deny"))),
|
||||
terminal=bool(spec.get("terminal", False)),
|
||||
source=str(spec.get("source", "catalog")),
|
||||
enabled=bool(spec.get("enabled", True)),
|
||||
)
|
||||
|
||||
|
||||
class TraceStep(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
phase: str
|
||||
rule: str | None = None
|
||||
matched: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class DecisionTrace(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
trace_id: str
|
||||
decision: Decision
|
||||
reason_code: str
|
||||
matched_rule_ids: tuple[str, ...] = ()
|
||||
steps: tuple[TraceStep, ...] = ()
|
||||
|
||||
|
||||
def _values(value: Any) -> tuple[str, ...]:
|
||||
if isinstance(value, list | tuple | set):
|
||||
return tuple(str(item) for item in value)
|
||||
return (str(value),)
|
||||
|
||||
|
||||
def _context_value(context: InvocationContext, key: str) -> tuple[str, ...]:
|
||||
mapping: dict[str, Any] = {
|
||||
"workspace": context.workspace,
|
||||
"workspaceId": context.workspace,
|
||||
"project": context.project,
|
||||
"projectId": context.project,
|
||||
"projectSlugs": context.project,
|
||||
"projects": context.project,
|
||||
"environment": context.environment,
|
||||
"environments": context.environment,
|
||||
"environmentClassifications": context.environment_classification or context.environment,
|
||||
"resourceType": context.resource_type or "",
|
||||
"resourceTypes": context.resource_type or "",
|
||||
"client": str(context.client_id) if context.client_id else "",
|
||||
"clientId": str(context.client_id) if context.client_id else "",
|
||||
"clientIds": str(context.client_id) if context.client_id else "",
|
||||
"principal": context.principal,
|
||||
"principalType": context.principal_type,
|
||||
"virtualServer": context.virtual_server or "",
|
||||
"capability": context.capability or "",
|
||||
"capabilities": context.capability or "",
|
||||
"tool": context.tool,
|
||||
"tools": context.tool,
|
||||
"toolPatterns": context.tool,
|
||||
"connector": context.connector,
|
||||
"connectors": context.connector,
|
||||
"riskClass": context.risk_class,
|
||||
"riskClasses": context.risk_class,
|
||||
"origin": context.origin,
|
||||
"origins": context.origin,
|
||||
}
|
||||
return _values(mapping.get(key, ""))
|
||||
|
||||
|
||||
def _rule_scope_matches(rule: PolicyRule, context: InvocationContext) -> bool:
|
||||
return (rule.workspace is None or rule.workspace == context.workspace) and (
|
||||
rule.project is None or rule.project == context.project
|
||||
)
|
||||
|
||||
|
||||
def _matches(rule: PolicyRule, context: InvocationContext) -> bool:
|
||||
if not _rule_scope_matches(rule, context):
|
||||
return False
|
||||
for key, expected in rule.match.items():
|
||||
if key not in _MATCH_FIELDS:
|
||||
raise PolicyValidationError(f"unsupported policy match field: {key}")
|
||||
actual = _context_value(context, key)
|
||||
expected_values = _values(expected)
|
||||
if key in {"toolPatterns"}:
|
||||
if not any(
|
||||
fnmatch.fnmatchcase(value, pattern)
|
||||
for value in actual
|
||||
for pattern in expected_values
|
||||
):
|
||||
return False
|
||||
elif not any(value in expected_values for value in actual):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _conditions_match(rule: PolicyRule, context: InvocationContext) -> bool:
|
||||
for key, expected in rule.conditions.items():
|
||||
if key not in _CONDITION_FIELDS:
|
||||
raise PolicyValidationError(f"unsupported policy condition: {key}")
|
||||
if key == "requireEnabledClient" and bool(expected) and not context.client_enabled:
|
||||
return False
|
||||
if key == "requireBinding" and bool(expected) and not context.binding_present:
|
||||
return False
|
||||
if key == "maxArgumentBytes" and len(str(dict(context.arguments)).encode()) > int(expected):
|
||||
return False
|
||||
if key in {
|
||||
"allEqual",
|
||||
"requiredClaims",
|
||||
"maxDefaultBytes",
|
||||
"maxDefaultItems",
|
||||
"redactionProfile",
|
||||
}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""Deterministic precedence: validation, global deny, explicit deny, allow, default deny."""
|
||||
|
||||
def __init__(self, policies: Iterable[PolicyRule]) -> None:
|
||||
self.policies = tuple(
|
||||
sorted(
|
||||
(item for item in policies if item.enabled),
|
||||
key=lambda item: (-item.priority, item.slug),
|
||||
)
|
||||
)
|
||||
for policy in self.policies:
|
||||
unknown = set(policy.match) - _MATCH_FIELDS
|
||||
if unknown:
|
||||
raise PolicyValidationError(f"unsupported policy match fields: {sorted(unknown)}")
|
||||
if (
|
||||
policy.decision
|
||||
in {
|
||||
Decision.ALLOW,
|
||||
Decision.ALLOW_IF_CONDITIONS,
|
||||
Decision.ALLOW_WITH_APPROVAL,
|
||||
Decision.ALLOW_WITH_PREVIEW,
|
||||
}
|
||||
and policy.project is None
|
||||
and policy.slug != "global-deny"
|
||||
):
|
||||
# Workspace-wide allows must not expand a project scope implicitly.
|
||||
continue
|
||||
|
||||
def evaluate(self, context: InvocationContext) -> DecisionTrace:
|
||||
steps: list[TraceStep] = []
|
||||
if not context.binding_present:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=Decision.DENY,
|
||||
reason_code="MISSING_BINDING",
|
||||
steps=(
|
||||
TraceStep(
|
||||
phase="identity", matched=False, reason="client/project binding is required"
|
||||
),
|
||||
),
|
||||
)
|
||||
for rule in self.policies:
|
||||
matched = _matches(rule, context)
|
||||
steps.append(
|
||||
TraceStep(
|
||||
phase="global-deny" if rule.project is None else "match",
|
||||
rule=rule.slug,
|
||||
matched=matched,
|
||||
reason="selector matched" if matched else "selector did not match",
|
||||
)
|
||||
)
|
||||
if (
|
||||
matched
|
||||
and rule.project is None
|
||||
and rule.decision in {Decision.DENY, Decision.DENY_AND_ALERT}
|
||||
and rule.terminal
|
||||
):
|
||||
return self._result(context, rule.decision, "GLOBAL_DENY", [rule.slug], steps)
|
||||
for rule in self.policies:
|
||||
if (
|
||||
rule.slug == "global-deny"
|
||||
or rule.decision not in {Decision.DENY, Decision.DENY_AND_ALERT}
|
||||
or not _matches(rule, context)
|
||||
):
|
||||
continue
|
||||
return self._result(context, rule.decision, "EXPLICIT_DENY", [rule.slug], steps)
|
||||
for rule in self.policies:
|
||||
if rule.decision not in {
|
||||
Decision.ALLOW,
|
||||
Decision.ALLOW_IF_CONDITIONS,
|
||||
Decision.ALLOW_WITH_APPROVAL,
|
||||
Decision.ALLOW_WITH_PREVIEW,
|
||||
} or not _matches(rule, context):
|
||||
continue
|
||||
conditions = _conditions_match(rule, context)
|
||||
steps.append(
|
||||
TraceStep(
|
||||
phase="conditions",
|
||||
rule=rule.slug,
|
||||
matched=conditions,
|
||||
reason="conditions satisfied" if conditions else "conditions not satisfied",
|
||||
)
|
||||
)
|
||||
if conditions:
|
||||
return self._result(context, rule.decision, "ALLOW_RULE", [rule.slug], steps)
|
||||
return self._result(context, Decision.DENY, "DEFAULT_DENY", [], steps)
|
||||
|
||||
@staticmethod
|
||||
def _result(
|
||||
context: InvocationContext,
|
||||
decision: Decision,
|
||||
reason: str,
|
||||
rules: list[str],
|
||||
steps: list[TraceStep],
|
||||
) -> DecisionTrace:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=decision,
|
||||
reason_code=reason,
|
||||
matched_rule_ids=tuple(rules),
|
||||
steps=tuple(steps),
|
||||
)
|
||||
|
||||
|
||||
class PolicySimulator:
|
||||
"""Runs the exact engine against a known-entity inventory without side effects."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: PolicyEngine,
|
||||
*,
|
||||
known_projects: Iterable[str] = (),
|
||||
known_clients: Iterable[UUID] = (),
|
||||
known_tools: Iterable[str] = (),
|
||||
) -> None:
|
||||
self.engine = engine
|
||||
self.known_projects = frozenset(known_projects)
|
||||
self.known_clients = frozenset(known_clients)
|
||||
self.known_tools = frozenset(known_tools)
|
||||
|
||||
def simulate(self, context: InvocationContext) -> DecisionTrace:
|
||||
if self.known_projects and context.project not in self.known_projects:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=Decision.DENY,
|
||||
reason_code="UNKNOWN_PROJECT",
|
||||
steps=(
|
||||
TraceStep(
|
||||
phase="simulation", matched=False, reason="project is not a known entity"
|
||||
),
|
||||
),
|
||||
)
|
||||
if self.known_tools and context.tool not in self.known_tools:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=Decision.DENY,
|
||||
reason_code="UNKNOWN_TOOL",
|
||||
steps=(
|
||||
TraceStep(
|
||||
phase="simulation", matched=False, reason="tool is not a known entity"
|
||||
),
|
||||
),
|
||||
)
|
||||
if context.client_id and self.known_clients and context.client_id not in self.known_clients:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=Decision.DENY,
|
||||
reason_code="UNKNOWN_CLIENT",
|
||||
steps=(
|
||||
TraceStep(
|
||||
phase="simulation", matched=False, reason="client is not a known entity"
|
||||
),
|
||||
),
|
||||
)
|
||||
return self.engine.evaluate(context)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""In-process bounded sliding-window rate limiter with isolated compound keys."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from threading import Lock
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class RateLimitExceeded(RuntimeError):
|
||||
code = "RATE_LIMITED"
|
||||
|
||||
|
||||
class SlidingWindowRateLimiter:
|
||||
def __init__(
|
||||
self, *, limit: int = 60, window_seconds: float = 60.0, clock: object = time.monotonic
|
||||
) -> None:
|
||||
if limit < 1 or window_seconds <= 0:
|
||||
raise ValueError("rate limit must be positive")
|
||||
self.limit = limit
|
||||
self.window_seconds = window_seconds
|
||||
self.clock = clock
|
||||
self._entries: dict[tuple[str, str, str], deque[float]] = defaultdict(deque)
|
||||
self._lock = Lock()
|
||||
|
||||
def allow(self, *, client_id: UUID | str, project: str, tool: str) -> bool:
|
||||
key = (str(client_id), project, tool)
|
||||
now = self.clock() # type: ignore[operator]
|
||||
with self._lock:
|
||||
bucket = self._entries[key]
|
||||
while bucket and bucket[0] <= now - self.window_seconds:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= self.limit:
|
||||
return False
|
||||
bucket.append(now)
|
||||
return True
|
||||
|
||||
def check(self, *, client_id: UUID | str, project: str, tool: str) -> None:
|
||||
if not self.allow(client_id=client_id, project=project, tool=tool):
|
||||
raise RateLimitExceeded("invocation rate limit exceeded")
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._entries.clear()
|
||||
Reference in new issue
Block a user