760 lines
32 KiB
Python
760 lines
32 KiB
Python
"""Scope-bound governance for traces, Tool/MCP calls, and credentials.
|
|
|
|
This module deliberately stores references and audit metadata only. Secret
|
|
material remains in the configured credential provider boundary.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from contextlib import contextmanager
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
|
|
SCOPE_FIELDS = ("tenant_id", "user_id", "project_id", "conversation_id", "run_id")
|
|
PRIVILEGED_CAPABILITIES = frozenset({"*", "admin", "system.admin", "governance.admin"})
|
|
REFERENCE_RE = re.compile(r"^(systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$")
|
|
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$")
|
|
|
|
|
|
class GovernanceError(RuntimeError):
|
|
def __init__(self, code: str, *, field: str | None = None) -> None:
|
|
super().__init__(code if field is None else f"{code}:{field}")
|
|
self.code = code
|
|
self.field = field
|
|
|
|
|
|
def _clean(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
def _scope_tuple(value: Iterable[str] | str | None) -> tuple[str, ...]:
|
|
if value is None:
|
|
return ()
|
|
if isinstance(value, str):
|
|
value = value.split(",")
|
|
return tuple(sorted({_clean(item) for item in value if _clean(item)}))
|
|
|
|
|
|
def _canonical(value: Any) -> str:
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
|
|
|
|
def _sha(value: Any) -> str:
|
|
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _identifier(value: Any, code: str) -> str:
|
|
text = _clean(value)
|
|
if text is None or IDENTIFIER_RE.fullmatch(text) is None:
|
|
raise GovernanceError(code)
|
|
return text
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GovernanceScope:
|
|
"""Explicit resource scope; system/global resources never get fake tenants."""
|
|
|
|
scope_type: str
|
|
tenant_id: str | None = None
|
|
user_id: str | None = None
|
|
project_id: str | None = None
|
|
conversation_id: str | None = None
|
|
run_id: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "scope_type", (_clean(self.scope_type) or "").lower())
|
|
for name in SCOPE_FIELDS:
|
|
object.__setattr__(self, name, _clean(getattr(self, name)))
|
|
if self.scope_type == "tenant":
|
|
if self.tenant_id is None:
|
|
raise GovernanceError("GOVERNANCE_TENANT_ID_REQUIRED")
|
|
elif self.scope_type in {"system", "global"}:
|
|
if any(getattr(self, name) is not None for name in SCOPE_FIELDS):
|
|
raise GovernanceError("GOVERNANCE_NON_TENANT_SCOPE_INVALID")
|
|
elif self.scope_type == "legacy":
|
|
if any(getattr(self, name) is not None for name in SCOPE_FIELDS):
|
|
raise GovernanceError("GOVERNANCE_LEGACY_SCOPE_INVALID")
|
|
else:
|
|
raise GovernanceError("GOVERNANCE_SCOPE_TYPE_INVALID")
|
|
|
|
@classmethod
|
|
def from_value(cls, value: Any) -> "GovernanceScope":
|
|
def get(name: str) -> Any:
|
|
return value.get(name) if isinstance(value, Mapping) else getattr(value, name, None)
|
|
|
|
declared = _clean(get("scope_type"))
|
|
tenant_id = _clean(get("tenant_id"))
|
|
return cls(
|
|
scope_type=declared or ("tenant" if tenant_id else "legacy"),
|
|
tenant_id=tenant_id,
|
|
user_id=get("user_id"),
|
|
project_id=get("project_id"),
|
|
conversation_id=get("conversation_id"),
|
|
run_id=get("run_id"),
|
|
)
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GovernanceActor:
|
|
identity: str
|
|
scope: GovernanceScope
|
|
capabilities: tuple[str, ...] = ()
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "identity", _identifier(self.identity, "GOVERNANCE_ACTOR_INVALID"))
|
|
object.__setattr__(self, "capabilities", _scope_tuple(self.capabilities))
|
|
|
|
@classmethod
|
|
def from_value(cls, value: Any) -> "GovernanceActor":
|
|
if isinstance(value, cls):
|
|
return value
|
|
if not isinstance(value, Mapping):
|
|
raise GovernanceError("GOVERNANCE_ACTOR_INVALID")
|
|
return cls(
|
|
identity=value.get("identity") or value.get("actor_id") or value.get("user_id"),
|
|
scope=GovernanceScope.from_value(value),
|
|
capabilities=_scope_tuple(value.get("capabilities") or value.get("capability_scope")),
|
|
)
|
|
|
|
@property
|
|
def privileged(self) -> bool:
|
|
return bool(set(self.capabilities) & PRIVILEGED_CAPABILITIES)
|
|
|
|
|
|
def authorize_scope(actor: GovernanceActor | Mapping[str, Any], resource: GovernanceScope | Mapping[str, Any]) -> None:
|
|
actor = GovernanceActor.from_value(actor)
|
|
resource = resource if isinstance(resource, GovernanceScope) else GovernanceScope.from_value(resource)
|
|
if actor.privileged:
|
|
return
|
|
if resource.scope_type in {"system", "global"}:
|
|
raise GovernanceError("GOVERNANCE_SYSTEM_SCOPE_AUTHZ_DENY")
|
|
if resource.scope_type == "legacy":
|
|
if actor.scope.scope_type != "legacy":
|
|
raise GovernanceError("GOVERNANCE_LEGACY_SCOPE_AUTHZ_DENY")
|
|
return
|
|
if actor.scope.scope_type != "tenant":
|
|
raise GovernanceError("GOVERNANCE_SCOPE_MISMATCH_DENY", field="scope_type")
|
|
for name in SCOPE_FIELDS:
|
|
expected = getattr(resource, name)
|
|
actual = getattr(actor.scope, name)
|
|
if expected is not None and expected != actual:
|
|
raise GovernanceError("GOVERNANCE_SCOPE_MISMATCH_DENY", field=name)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TraceContext:
|
|
trace_id: str
|
|
span_id: str
|
|
scope: GovernanceScope
|
|
parent_span_id: str | None = None
|
|
correlation_id: str | None = None
|
|
causation_id: str | None = None
|
|
task_id: str | None = None
|
|
claim_id: str | None = None
|
|
message_id: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "trace_id", _identifier(self.trace_id, "TRACE_ID_INVALID"))
|
|
object.__setattr__(self, "span_id", _identifier(self.span_id, "TRACE_SPAN_ID_INVALID"))
|
|
for name in ("parent_span_id", "correlation_id", "causation_id", "task_id", "claim_id", "message_id"):
|
|
value = _clean(getattr(self, name))
|
|
if value is not None and IDENTIFIER_RE.fullmatch(value) is None:
|
|
raise GovernanceError("TRACE_LINEAGE_IDENTIFIER_INVALID", field=name)
|
|
object.__setattr__(self, name, value)
|
|
|
|
def child(self, *, span_id: str, actor: GovernanceActor | Mapping[str, Any], **lineage: Any) -> "TraceContext":
|
|
authorize_scope(actor, self.scope)
|
|
for name in ("trace_id", "task_id", "claim_id", "message_id", "correlation_id"):
|
|
supplied = _clean(lineage.get(name))
|
|
expected = getattr(self, name)
|
|
if supplied is not None and expected is not None and supplied != expected:
|
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field=name)
|
|
requested_scope = lineage.pop("scope", self.scope)
|
|
requested_scope = requested_scope if isinstance(requested_scope, GovernanceScope) else GovernanceScope.from_value(requested_scope)
|
|
if requested_scope != self.scope:
|
|
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
|
return TraceContext(
|
|
trace_id=self.trace_id,
|
|
span_id=span_id,
|
|
parent_span_id=self.span_id,
|
|
correlation_id=self.correlation_id,
|
|
causation_id=_clean(lineage.get("causation_id")) or self.span_id,
|
|
task_id=self.task_id,
|
|
claim_id=self.claim_id,
|
|
message_id=self.message_id,
|
|
scope=self.scope,
|
|
)
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"trace_id": self.trace_id,
|
|
"span_id": self.span_id,
|
|
"parent_span_id": self.parent_span_id,
|
|
"correlation_id": self.correlation_id,
|
|
"causation_id": self.causation_id,
|
|
"task_id": self.task_id,
|
|
"claim_id": self.claim_id,
|
|
"message_id": self.message_id,
|
|
**self.scope.as_dict(),
|
|
}
|
|
|
|
|
|
def bind_trace_lineage(
|
|
*,
|
|
actor: GovernanceActor | Mapping[str, Any],
|
|
task: Any,
|
|
message: Any,
|
|
trace_id: str,
|
|
span_id: str,
|
|
parent: TraceContext | None = None,
|
|
) -> TraceContext:
|
|
actor = GovernanceActor.from_value(actor)
|
|
task_scope = GovernanceScope.from_value(task)
|
|
message_scope = GovernanceScope.from_value(message)
|
|
if task_scope != message_scope:
|
|
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
|
authorize_scope(actor, task_scope)
|
|
|
|
def get(value: Any, name: str) -> Any:
|
|
return value.get(name) if isinstance(value, Mapping) else getattr(value, name, None)
|
|
|
|
task_id = _clean(get(task, "task_id"))
|
|
message_task = _clean(get(message, "task_id"))
|
|
if task_id and message_task and task_id != message_task:
|
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="task_id")
|
|
context = TraceContext(
|
|
trace_id=trace_id,
|
|
span_id=span_id,
|
|
parent_span_id=parent.span_id if parent else None,
|
|
correlation_id=_clean(get(task, "correlation_id")) or _clean(get(message, "correlation_id")),
|
|
causation_id=_clean(get(message, "causation_id")) or (parent.span_id if parent else None),
|
|
task_id=task_id or message_task,
|
|
claim_id=_clean(get(task, "claim_id")) or _clean(get(message, "claim_id")),
|
|
message_id=_clean(get(message, "envelope_id")) or _clean(get(message, "id")),
|
|
scope=task_scope,
|
|
)
|
|
if parent is not None:
|
|
if parent.trace_id != context.trace_id or parent.scope != context.scope:
|
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY")
|
|
if parent.task_id and context.task_id and parent.task_id != context.task_id:
|
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="task_id")
|
|
return context
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ToolDescriptor:
|
|
name: str
|
|
kind: str
|
|
scope: GovernanceScope
|
|
required_capabilities: tuple[str, ...] = ()
|
|
credential_ref: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "name", _identifier(self.name, "TOOL_NAME_INVALID"))
|
|
object.__setattr__(self, "kind", (_clean(self.kind) or "").lower())
|
|
if self.kind not in {"tool", "mcp"}:
|
|
raise GovernanceError("TOOL_KIND_INVALID")
|
|
object.__setattr__(self, "required_capabilities", _scope_tuple(self.required_capabilities))
|
|
if self.credential_ref is not None and REFERENCE_RE.fullmatch(self.credential_ref) is None:
|
|
raise GovernanceError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CredentialGrant:
|
|
grant_id: str
|
|
credential_ref: str
|
|
scope: GovernanceScope
|
|
operations: tuple[str, ...]
|
|
task_id: str | None = None
|
|
tool_name: str | None = None
|
|
expires_at: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "grant_id", _identifier(self.grant_id, "CREDENTIAL_GRANT_ID_INVALID"))
|
|
if REFERENCE_RE.fullmatch(self.credential_ref or "") is None:
|
|
raise GovernanceError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
|
object.__setattr__(self, "operations", _scope_tuple(self.operations))
|
|
if not self.operations:
|
|
raise GovernanceError("CREDENTIAL_OPERATIONS_REQUIRED")
|
|
if self.expires_at is not None:
|
|
parsed = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
|
|
if parsed.tzinfo is None:
|
|
raise GovernanceError("CREDENTIAL_EXPIRY_INVALID")
|
|
|
|
def authorize(
|
|
self,
|
|
actor: GovernanceActor | Mapping[str, Any],
|
|
*,
|
|
operation: str,
|
|
task_id: str | None = None,
|
|
tool_name: str | None = None,
|
|
now: datetime | None = None,
|
|
) -> str:
|
|
try:
|
|
authorize_scope(actor, self.scope)
|
|
except GovernanceError as exc:
|
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field=exc.field) from exc
|
|
if operation not in self.operations:
|
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="operation")
|
|
if self.task_id is not None and self.task_id != _clean(task_id):
|
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="task_id")
|
|
if self.tool_name is not None and self.tool_name != _clean(tool_name):
|
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="tool_name")
|
|
if self.expires_at is not None:
|
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
|
expiry = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
if current >= expiry:
|
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="expires_at")
|
|
return self.credential_ref
|
|
|
|
def public(self) -> dict[str, Any]:
|
|
return {
|
|
"grant_id": self.grant_id,
|
|
"credential_ref": self.credential_ref,
|
|
"operations": list(self.operations),
|
|
"task_id": self.task_id,
|
|
"tool_name": self.tool_name,
|
|
"expires_at": self.expires_at,
|
|
**self.scope.as_dict(),
|
|
}
|
|
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS governance_schema_metadata (
|
|
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
|
schema_version INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS governed_tools (
|
|
name TEXT PRIMARY KEY,
|
|
kind TEXT NOT NULL,
|
|
scope_json TEXT NOT NULL,
|
|
required_capabilities_json TEXT NOT NULL,
|
|
credential_ref TEXT,
|
|
descriptor_sha256 TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS governed_trace_spans (
|
|
trace_id TEXT NOT NULL,
|
|
span_id TEXT NOT NULL,
|
|
parent_span_id TEXT,
|
|
context_json TEXT NOT NULL,
|
|
context_sha256 TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY(trace_id, span_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS governed_credential_grants (
|
|
grant_id TEXT PRIMARY KEY,
|
|
grant_json TEXT NOT NULL,
|
|
grant_sha256 TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS governed_tool_invocations (
|
|
invocation_key TEXT PRIMARY KEY,
|
|
request_sha256 TEXT NOT NULL,
|
|
tool_name TEXT NOT NULL,
|
|
task_id TEXT NOT NULL,
|
|
claim_id TEXT NOT NULL,
|
|
trace_id TEXT NOT NULL,
|
|
span_id TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
receipt_sha256 TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS governance_audit_evidence (
|
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
evidence_id TEXT NOT NULL UNIQUE,
|
|
action TEXT NOT NULL,
|
|
actor_identity TEXT NOT NULL,
|
|
trace_id TEXT,
|
|
span_id TEXT,
|
|
resource_id TEXT NOT NULL,
|
|
outcome TEXT NOT NULL,
|
|
details_json TEXT NOT NULL,
|
|
previous_sha256 TEXT NOT NULL,
|
|
evidence_sha256 TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
|
|
|
|
class GovernanceStore:
|
|
def __init__(self, path: str | Path) -> None:
|
|
self.path = Path(path)
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
with self._connection() as connection:
|
|
has_metadata = connection.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='governance_schema_metadata'"
|
|
).fetchone()
|
|
if has_metadata:
|
|
row = connection.execute(
|
|
"SELECT schema_version FROM governance_schema_metadata WHERE singleton=1"
|
|
).fetchone()
|
|
if row is not None and int(row[0]) != 1:
|
|
raise GovernanceError("GOVERNANCE_SCHEMA_VERSION_UNSUPPORTED")
|
|
connection.executescript(_SCHEMA)
|
|
row = connection.execute(
|
|
"SELECT schema_version FROM governance_schema_metadata WHERE singleton=1"
|
|
).fetchone()
|
|
if row is None:
|
|
connection.execute(
|
|
"INSERT INTO governance_schema_metadata(singleton,schema_version) VALUES(1,1)"
|
|
)
|
|
connection.commit()
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
connection = sqlite3.connect(self.path, timeout=30.0)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA foreign_keys=ON")
|
|
connection.execute("PRAGMA busy_timeout=30000")
|
|
return connection
|
|
|
|
@contextmanager
|
|
def _connection(self):
|
|
connection = self._connect()
|
|
try:
|
|
yield connection
|
|
connection.commit()
|
|
except Exception:
|
|
connection.rollback()
|
|
raise
|
|
finally:
|
|
connection.close()
|
|
|
|
def _audit(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
*,
|
|
action: str,
|
|
actor: GovernanceActor,
|
|
resource_id: str,
|
|
outcome: str,
|
|
trace: TraceContext | None,
|
|
details: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
previous = connection.execute(
|
|
"SELECT evidence_sha256 FROM governance_audit_evidence ORDER BY sequence DESC LIMIT 1"
|
|
).fetchone()
|
|
previous_sha = str(previous[0]) if previous else ""
|
|
created_at = _now()
|
|
material = {
|
|
"action": action,
|
|
"actor_identity": actor.identity,
|
|
"trace_id": trace.trace_id if trace else None,
|
|
"span_id": trace.span_id if trace else None,
|
|
"resource_id": resource_id,
|
|
"outcome": outcome,
|
|
"details": dict(details),
|
|
"previous_sha256": previous_sha,
|
|
"created_at": created_at,
|
|
}
|
|
digest = _sha(material)
|
|
evidence_id = "gev-" + digest[:32]
|
|
connection.execute(
|
|
"""INSERT INTO governance_audit_evidence
|
|
(evidence_id,action,actor_identity,trace_id,span_id,resource_id,outcome,
|
|
details_json,previous_sha256,evidence_sha256,created_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(
|
|
evidence_id, action, actor.identity, material["trace_id"], material["span_id"],
|
|
resource_id, outcome, _canonical(material["details"]), previous_sha, digest, created_at,
|
|
),
|
|
)
|
|
return {"evidence_id": evidence_id, "evidence_sha256": digest, **material}
|
|
|
|
def record_trace(self, trace: TraceContext, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
|
actor = GovernanceActor.from_value(actor)
|
|
authorize_scope(actor, trace.scope)
|
|
context = trace.as_dict()
|
|
digest = _sha(context)
|
|
with self._connection() as connection:
|
|
existing = connection.execute(
|
|
"SELECT context_sha256 FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
|
(trace.trace_id, trace.span_id),
|
|
).fetchone()
|
|
if existing is not None and existing[0] != digest:
|
|
raise GovernanceError("TRACE_LINEAGE_CONFLICT")
|
|
if existing is not None:
|
|
return {"context": context, "context_sha256": digest, "idempotent": True}
|
|
if trace.parent_span_id:
|
|
parent = connection.execute(
|
|
"SELECT context_json FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
|
(trace.trace_id, trace.parent_span_id),
|
|
).fetchone()
|
|
if parent is None:
|
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="parent_span_id")
|
|
parent_context = json.loads(parent[0])
|
|
if GovernanceScope.from_value(parent_context) != trace.scope:
|
|
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
|
connection.execute(
|
|
"INSERT OR IGNORE INTO governed_trace_spans VALUES (?,?,?,?,?,?)",
|
|
(trace.trace_id, trace.span_id, trace.parent_span_id, _canonical(context), digest, _now()),
|
|
)
|
|
evidence = self._audit(
|
|
connection, action="TRACE_RECORDED", actor=actor,
|
|
resource_id=f"{trace.trace_id}:{trace.span_id}", outcome="ALLOW", trace=trace,
|
|
details={"context_sha256": digest},
|
|
)
|
|
return {"context": context, "context_sha256": digest, "evidence": evidence}
|
|
|
|
def register_tool(self, descriptor: ToolDescriptor, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
|
actor = GovernanceActor.from_value(actor)
|
|
if not actor.privileged:
|
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
|
public = {
|
|
"name": descriptor.name,
|
|
"kind": descriptor.kind,
|
|
"required_capabilities": list(descriptor.required_capabilities),
|
|
"credential_ref": descriptor.credential_ref,
|
|
**descriptor.scope.as_dict(),
|
|
}
|
|
digest = _sha(public)
|
|
with self._connection() as connection:
|
|
existing = connection.execute(
|
|
"SELECT descriptor_sha256 FROM governed_tools WHERE name=?", (descriptor.name,)
|
|
).fetchone()
|
|
if existing is not None and existing[0] != digest:
|
|
raise GovernanceError("TOOL_DESCRIPTOR_CONFLICT")
|
|
if existing is not None:
|
|
return {"descriptor": public, "descriptor_sha256": digest, "idempotent": True}
|
|
connection.execute(
|
|
"INSERT OR IGNORE INTO governed_tools VALUES (?,?,?,?,?,?)",
|
|
(descriptor.name, descriptor.kind, _canonical(descriptor.scope.as_dict()),
|
|
_canonical(descriptor.required_capabilities), descriptor.credential_ref, digest),
|
|
)
|
|
evidence = self._audit(
|
|
connection, action="TOOL_REGISTERED", actor=actor, resource_id=descriptor.name,
|
|
outcome="ALLOW", trace=None, details={"descriptor_sha256": digest},
|
|
)
|
|
return {"descriptor": public, "descriptor_sha256": digest, "evidence": evidence}
|
|
|
|
def authorize_tool(
|
|
self,
|
|
*,
|
|
actor: GovernanceActor | Mapping[str, Any],
|
|
tool_name: str,
|
|
trace: TraceContext,
|
|
requested_scope: GovernanceScope | Mapping[str, Any],
|
|
input_digest: str,
|
|
invocation_key: str,
|
|
) -> dict[str, Any]:
|
|
actor = GovernanceActor.from_value(actor)
|
|
invocation_key = _identifier(invocation_key, "TOOL_INVOCATION_KEY_INVALID")
|
|
if not re.fullmatch(r"[0-9a-f]{64}", input_digest):
|
|
raise GovernanceError("TOOL_INPUT_DIGEST_INVALID")
|
|
if trace.task_id is None or trace.claim_id is None:
|
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="active_claim")
|
|
requested_scope = requested_scope if isinstance(requested_scope, GovernanceScope) else GovernanceScope.from_value(requested_scope)
|
|
if requested_scope != trace.scope:
|
|
raise GovernanceError("TOOL_SCOPE_PROPAGATION")
|
|
authorize_scope(actor, requested_scope)
|
|
with self._connection() as connection:
|
|
persisted_trace = connection.execute(
|
|
"SELECT context_sha256 FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
|
(trace.trace_id, trace.span_id),
|
|
).fetchone()
|
|
if persisted_trace is None or persisted_trace[0] != _sha(trace.as_dict()):
|
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="trace")
|
|
row = connection.execute("SELECT * FROM governed_tools WHERE name=?", (tool_name,)).fetchone()
|
|
if row is None:
|
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
|
descriptor_scope = GovernanceScope.from_value(json.loads(row["scope_json"]))
|
|
if descriptor_scope.scope_type == "tenant" and descriptor_scope != requested_scope:
|
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
|
required = set(json.loads(row["required_capabilities_json"]))
|
|
if not required.issubset(set(actor.capabilities)):
|
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
|
request_sha = _sha({
|
|
"tool_name": tool_name, "input_digest": input_digest,
|
|
"task_id": trace.task_id, "claim_id": trace.claim_id,
|
|
"trace_id": trace.trace_id, "span_id": trace.span_id,
|
|
"scope": requested_scope.as_dict(),
|
|
})
|
|
replay = connection.execute(
|
|
"SELECT * FROM governed_tool_invocations WHERE invocation_key=?",
|
|
(invocation_key,),
|
|
).fetchone()
|
|
if replay is not None:
|
|
if replay["request_sha256"] != request_sha:
|
|
raise GovernanceError("TOOL_INVOCATION_IDEMPOTENCY_CONFLICT")
|
|
if replay["status"] != "VERIFIED":
|
|
raise GovernanceError("TOOL_INVOCATION_RECONCILIATION_REQUIRED")
|
|
return {
|
|
"tool_name": tool_name, "trace_id": trace.trace_id,
|
|
"span_id": trace.span_id, "invocation_key": invocation_key,
|
|
"status": "VERIFIED", "receipt_sha256": replay["receipt_sha256"],
|
|
"idempotent": True,
|
|
}
|
|
created_at = _now()
|
|
connection.execute(
|
|
"""INSERT INTO governed_tool_invocations
|
|
(invocation_key,request_sha256,tool_name,task_id,claim_id,trace_id,span_id,
|
|
status,receipt_sha256,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(invocation_key, request_sha, tool_name, trace.task_id, trace.claim_id,
|
|
trace.trace_id, trace.span_id, "AUTHORIZED", None, created_at, created_at),
|
|
)
|
|
evidence = self._audit(
|
|
connection, action="TOOL_AUTHORIZED", actor=actor, resource_id=tool_name,
|
|
outcome="ALLOW", trace=trace,
|
|
details={"input_sha256": input_digest, "scope_sha256": _sha(requested_scope.as_dict()),
|
|
"invocation_key": invocation_key},
|
|
)
|
|
return {
|
|
"tool_name": tool_name,
|
|
"trace_id": trace.trace_id,
|
|
"span_id": trace.span_id,
|
|
"invocation_key": invocation_key,
|
|
"status": "AUTHORIZED",
|
|
"credential_required": row["credential_ref"] is not None,
|
|
"evidence": evidence,
|
|
}
|
|
|
|
def record_tool_receipt(
|
|
self,
|
|
*,
|
|
invocation_key: str,
|
|
result_digest: str,
|
|
actor: GovernanceActor | Mapping[str, Any],
|
|
trace: TraceContext,
|
|
) -> dict[str, Any]:
|
|
actor = GovernanceActor.from_value(actor)
|
|
invocation_key = _identifier(invocation_key, "TOOL_INVOCATION_KEY_INVALID")
|
|
if not re.fullmatch(r"[0-9a-f]{64}", result_digest):
|
|
raise GovernanceError("TOOL_RECEIPT_DIGEST_INVALID")
|
|
authorize_scope(actor, trace.scope)
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"SELECT * FROM governed_tool_invocations WHERE invocation_key=?",
|
|
(invocation_key,),
|
|
).fetchone()
|
|
if row is None or row["trace_id"] != trace.trace_id or row["span_id"] != trace.span_id:
|
|
raise GovernanceError("TOOL_RECEIPT_BINDING_INVALID")
|
|
if row["status"] == "VERIFIED":
|
|
if row["receipt_sha256"] != result_digest:
|
|
raise GovernanceError("TOOL_RECEIPT_CONFLICT")
|
|
return {"invocation_key": invocation_key, "status": "VERIFIED", "idempotent": True}
|
|
if row["status"] != "AUTHORIZED":
|
|
raise GovernanceError("TOOL_INVOCATION_RECONCILIATION_REQUIRED")
|
|
connection.execute(
|
|
"UPDATE governed_tool_invocations SET status='VERIFIED',receipt_sha256=?,updated_at=? "
|
|
"WHERE invocation_key=? AND status='AUTHORIZED'",
|
|
(result_digest, _now(), invocation_key),
|
|
)
|
|
evidence = self._audit(
|
|
connection, action="TOOL_RESULT_VERIFIED", actor=actor,
|
|
resource_id=row["tool_name"], outcome="ALLOW", trace=trace,
|
|
details={"invocation_key": invocation_key, "result_sha256": result_digest},
|
|
)
|
|
return {"invocation_key": invocation_key, "status": "VERIFIED", "evidence": evidence}
|
|
|
|
def store_grant(self, grant: CredentialGrant, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
|
actor = GovernanceActor.from_value(actor)
|
|
if not actor.privileged:
|
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY")
|
|
public = grant.public()
|
|
digest = _sha(public)
|
|
with self._connection() as connection:
|
|
existing = connection.execute(
|
|
"SELECT grant_sha256 FROM governed_credential_grants WHERE grant_id=?", (grant.grant_id,)
|
|
).fetchone()
|
|
if existing is not None and existing[0] != digest:
|
|
raise GovernanceError("CREDENTIAL_GRANT_CONFLICT")
|
|
connection.execute(
|
|
"INSERT OR IGNORE INTO governed_credential_grants VALUES (?,?,?,?)",
|
|
(grant.grant_id, _canonical(public), digest, _now()),
|
|
)
|
|
evidence = self._audit(
|
|
connection, action="CREDENTIAL_GRANT_STORED", actor=actor,
|
|
resource_id=grant.grant_id, outcome="ALLOW", trace=None,
|
|
details={"grant_sha256": digest},
|
|
)
|
|
return {"grant": public, "grant_sha256": digest, "evidence": evidence}
|
|
|
|
def authorize_credential(
|
|
self,
|
|
*,
|
|
grant_id: str,
|
|
actor: GovernanceActor | Mapping[str, Any],
|
|
trace: TraceContext,
|
|
operation: str,
|
|
task_id: str | None,
|
|
tool_name: str | None,
|
|
now: datetime | None = None,
|
|
) -> dict[str, Any]:
|
|
actor = GovernanceActor.from_value(actor)
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"SELECT grant_json FROM governed_credential_grants WHERE grant_id=?", (grant_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY")
|
|
raw = json.loads(row[0])
|
|
grant = CredentialGrant(
|
|
grant_id=raw["grant_id"], credential_ref=raw["credential_ref"],
|
|
scope=GovernanceScope.from_value(raw), operations=tuple(raw["operations"]),
|
|
task_id=raw.get("task_id"), tool_name=raw.get("tool_name"),
|
|
expires_at=raw.get("expires_at"),
|
|
)
|
|
if trace.scope != grant.scope:
|
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="trace_scope")
|
|
reference = grant.authorize(
|
|
actor, operation=operation, task_id=task_id, tool_name=tool_name, now=now
|
|
)
|
|
evidence = self._audit(
|
|
connection, action="CREDENTIAL_AUTHORIZED", actor=actor,
|
|
resource_id=grant_id, outcome="ALLOW", trace=trace,
|
|
details={"operation": operation, "task_id": task_id, "tool_name": tool_name,
|
|
"credential_ref_sha256": _sha(reference)},
|
|
)
|
|
return {"credential_ref": reference, "evidence": evidence}
|
|
|
|
def verify_audit_chain(self, required_actions: Iterable[str] = ()) -> bool:
|
|
ordered_actions: list[str] = []
|
|
previous = ""
|
|
with self._connection() as connection:
|
|
rows = connection.execute("SELECT * FROM governance_audit_evidence ORDER BY sequence").fetchall()
|
|
for row in rows:
|
|
details = json.loads(row["details_json"])
|
|
material = {
|
|
"action": row["action"], "actor_identity": row["actor_identity"],
|
|
"trace_id": row["trace_id"], "span_id": row["span_id"],
|
|
"resource_id": row["resource_id"], "outcome": row["outcome"],
|
|
"details": details, "previous_sha256": row["previous_sha256"],
|
|
"created_at": row["created_at"],
|
|
}
|
|
if row["previous_sha256"] != previous or row["evidence_sha256"] != _sha(material):
|
|
return False
|
|
previous = row["evidence_sha256"]
|
|
ordered_actions.append(row["action"])
|
|
position = 0
|
|
for required in required_actions:
|
|
try:
|
|
position = ordered_actions.index(required, position) + 1
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
__all__ = [
|
|
"CredentialGrant", "GovernanceActor", "GovernanceError", "GovernanceScope",
|
|
"GovernanceStore", "ToolDescriptor", "TraceContext", "authorize_scope",
|
|
"bind_trace_lineage",
|
|
]
|