155 lines
6.2 KiB
Python
155 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import select
|
|
|
|
from packages.hub_policy import AuditEvent, AuditPage
|
|
from services.control_api.persistence.models import (
|
|
AuditEventRow,
|
|
EnvironmentRow,
|
|
ProjectRow,
|
|
WorkspaceRow,
|
|
)
|
|
|
|
|
|
class SqlAlchemyAuditLog:
|
|
"""Append-only production audit sink backed by the Hub-owned database."""
|
|
|
|
def __init__(self, sessions: Any, *, issuer: str) -> None:
|
|
self.sessions = sessions
|
|
self.issuer = issuer
|
|
|
|
@staticmethod
|
|
def _workspace_id(session: Any, workspace: str) -> UUID | None:
|
|
try:
|
|
identifier = UUID(workspace)
|
|
except ValueError:
|
|
identifier = None
|
|
if identifier is not None and session.get(WorkspaceRow, identifier) is not None:
|
|
return identifier
|
|
value = session.scalar(select(WorkspaceRow.id).where(WorkspaceRow.slug == workspace))
|
|
return value if isinstance(value, UUID) else None
|
|
|
|
def append(self, event: AuditEvent) -> None:
|
|
with self.sessions() as session, session.begin():
|
|
workspace_id = self._workspace_id(session, event.workspace)
|
|
if workspace_id is None:
|
|
raise RuntimeError("audit workspace is not registered")
|
|
project = session.scalar(
|
|
select(ProjectRow).where(
|
|
ProjectRow.workspace_id == workspace_id,
|
|
ProjectRow.slug == event.project,
|
|
)
|
|
)
|
|
if project is None:
|
|
raise RuntimeError("audit project is not registered")
|
|
environment_id = session.scalar(
|
|
select(EnvironmentRow.id).where(
|
|
EnvironmentRow.workspace_id == workspace_id,
|
|
EnvironmentRow.project_id == project.id,
|
|
EnvironmentRow.name == event.environment,
|
|
)
|
|
)
|
|
if environment_id is None:
|
|
raise RuntimeError("audit environment is not registered")
|
|
session.add(
|
|
AuditEventRow(
|
|
id=event.id,
|
|
occurred_at=event.occurred_at,
|
|
trace_id=event.trace_id,
|
|
workspace_id=workspace_id,
|
|
project_id=project.id,
|
|
environment_id=environment_id,
|
|
event_type=event.event_type,
|
|
principal_issuer=self.issuer,
|
|
principal_subject=event.principal,
|
|
client_id=event.client_id,
|
|
tool_name=event.tool,
|
|
policy_decision=event.policy_decision,
|
|
matched_rule_ids=list(event.matched_rule_ids),
|
|
policy_trace=list(event.policy_trace),
|
|
arguments_hash=event.arguments_hash,
|
|
sanitized_arguments=event.sanitized_arguments,
|
|
duration_ms=event.duration_ms,
|
|
outcome=event.outcome,
|
|
error_code=event.error_code,
|
|
retention_until=event.retention_until,
|
|
)
|
|
)
|
|
|
|
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")
|
|
with self.sessions() as session:
|
|
workspace_id = self._workspace_id(session, workspace)
|
|
if workspace_id is None:
|
|
return AuditPage(items=())
|
|
project_row = session.scalar(
|
|
select(ProjectRow).where(
|
|
ProjectRow.workspace_id == workspace_id,
|
|
ProjectRow.slug == project,
|
|
)
|
|
)
|
|
if project_row is None:
|
|
return AuditPage(items=())
|
|
rows = session.scalars(
|
|
select(AuditEventRow)
|
|
.where(
|
|
AuditEventRow.workspace_id == workspace_id,
|
|
AuditEventRow.project_id == project_row.id,
|
|
)
|
|
.order_by(AuditEventRow.occurred_at, AuditEventRow.id)
|
|
.offset(offset)
|
|
.limit(limit + 1)
|
|
).all()
|
|
selected = rows[:limit]
|
|
environments = {
|
|
row.id: row.name
|
|
for row in session.scalars(
|
|
select(EnvironmentRow).where(EnvironmentRow.workspace_id == workspace_id)
|
|
).all()
|
|
}
|
|
items = tuple(
|
|
AuditEvent(
|
|
id=row.id,
|
|
occurred_at=row.occurred_at,
|
|
trace_id=row.trace_id,
|
|
event_type=row.event_type,
|
|
workspace=workspace,
|
|
project=project,
|
|
environment=environments.get(row.environment_id, "unknown"),
|
|
principal=row.principal_subject or "unknown",
|
|
client_id=row.client_id,
|
|
tool=row.tool_name or "mcp.request",
|
|
policy_decision=row.policy_decision or "deny",
|
|
matched_rule_ids=tuple(row.matched_rule_ids),
|
|
policy_trace=tuple(row.policy_trace),
|
|
arguments_hash=row.arguments_hash or "0" * 64,
|
|
sanitized_arguments=dict(row.sanitized_arguments),
|
|
duration_ms=row.duration_ms or 0,
|
|
outcome=row.outcome,
|
|
error_code=row.error_code,
|
|
retention_until=row.retention_until,
|
|
)
|
|
for row in selected
|
|
)
|
|
return AuditPage(
|
|
items=items,
|
|
next_offset=offset + limit if len(rows) > limit else None,
|
|
)
|
|
|
|
def export_metadata(self, *, workspace: str, project: str) -> dict[str, Any]:
|
|
page = self.search(workspace=workspace, project=project, limit=500)
|
|
return {
|
|
"workspace": workspace,
|
|
"project": project,
|
|
"eventCount": len(page.items),
|
|
"lastEventAt": page.items[-1].occurred_at.isoformat() if page.items else None,
|
|
"format": "audit-event-v1",
|
|
"access": "policy-restricted",
|
|
}
|