600 lines
21 KiB
Python
600 lines
21 KiB
Python
"""Workspace/project-scoped repositories.
|
|
|
|
Every repository constructor requires a ``Scope``. Project-owned repositories
|
|
reject workspace-only scopes, and every query applies the scope predicate before
|
|
loading or mutating a row. There is intentionally no generic unrestricted
|
|
query or audit update method.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import UTC, datetime
|
|
from typing import Any, TypeVar, cast
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import Select, select, update
|
|
from sqlalchemy.orm import Session
|
|
|
|
from packages.hub_core.domain import (
|
|
Approval,
|
|
AuditEvent,
|
|
Capability,
|
|
Client,
|
|
ClientBinding,
|
|
Connector,
|
|
Environment,
|
|
KnowledgeChunk,
|
|
KnowledgeDocument,
|
|
KnowledgeSource,
|
|
Operation,
|
|
Policy,
|
|
PolicyDecision,
|
|
Project,
|
|
ReconcileRun,
|
|
RiskClass,
|
|
Scope,
|
|
ToolDefinition,
|
|
VirtualServer,
|
|
VirtualServerCapability,
|
|
Workspace,
|
|
)
|
|
from packages.hub_core.errors import (
|
|
AuditMutationError,
|
|
ConcurrentUpdateError,
|
|
DomainError,
|
|
InvalidTransition,
|
|
MissingProjectScope,
|
|
)
|
|
from packages.hub_core.state_machines import transition
|
|
|
|
from .models import (
|
|
ApprovalRow,
|
|
AuditEventRow,
|
|
CapabilityRow,
|
|
ClientBindingRow,
|
|
ClientRow,
|
|
ConnectorRow,
|
|
EnvironmentRow,
|
|
KnowledgeChunkRow,
|
|
KnowledgeDocumentRow,
|
|
KnowledgeSourceRow,
|
|
OperationRow,
|
|
PolicyDecisionRow,
|
|
PolicyRow,
|
|
ProjectRow,
|
|
ReconcileRunRow,
|
|
RiskClassRow,
|
|
ToolDefinitionRow,
|
|
VirtualServerCapabilityRow,
|
|
VirtualServerRow,
|
|
WorkspaceRow,
|
|
)
|
|
|
|
RowT = TypeVar("RowT")
|
|
DomainT = TypeVar("DomainT", bound=BaseModel)
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
|
|
def _normalized_row_data(row: Any, rename_fields: dict[str, str] | None = None) -> dict[str, Any]:
|
|
reverse = {target: source for source, target in (rename_fields or {}).items()}
|
|
values: dict[str, Any] = {}
|
|
for column in row.__table__.columns:
|
|
value = getattr(row, column.name)
|
|
if isinstance(value, datetime) and value.tzinfo is None:
|
|
value = value.replace(tzinfo=UTC)
|
|
values[reverse.get(column.name, column.name)] = value
|
|
return values
|
|
|
|
|
|
def _atomic_transition(
|
|
session: Session,
|
|
row_type: type[Any],
|
|
scope: Scope,
|
|
record_id: UUID,
|
|
current: str,
|
|
target: str,
|
|
expected_version: int,
|
|
machine: str,
|
|
state_field: str,
|
|
*,
|
|
project_required: bool,
|
|
) -> None:
|
|
next_state = transition(machine, current, target)
|
|
statement = (
|
|
update(row_type)
|
|
.where(row_type.id == record_id)
|
|
.where(row_type.workspace_id == scope.workspace_id)
|
|
.where(row_type.revision == expected_version)
|
|
)
|
|
if project_required:
|
|
statement = statement.where(row_type.project_id == scope.project_id)
|
|
result = session.execute(
|
|
statement.values(
|
|
**{
|
|
state_field: next_state,
|
|
"revision": expected_version + 1,
|
|
"updated_at": _now(),
|
|
}
|
|
)
|
|
)
|
|
if result.rowcount != 1:
|
|
raise ConcurrentUpdateError(f"stale version for {row_type.__name__} {record_id}")
|
|
|
|
|
|
class ScopedRepository[RowT, DomainT]:
|
|
"""Small typed CRUD boundary with mandatory workspace/project filtering."""
|
|
|
|
row_type: Any
|
|
domain_type: Any
|
|
project_required = True
|
|
project_filter_if_present = True
|
|
rename_fields: dict[str, str] = {}
|
|
|
|
def __init__(self, session: Session, scope: Scope) -> None:
|
|
if not isinstance(scope, Scope):
|
|
raise TypeError("repository scope must be a Scope")
|
|
self.session = session
|
|
self.scope = scope
|
|
if self.project_required and scope.project_id is None:
|
|
raise MissingProjectScope(f"{type(self).__name__} requires project scope")
|
|
|
|
def _statement(self) -> Select[tuple[RowT]]:
|
|
row = self.row_type
|
|
statement = select(row).where(row.workspace_id == self.scope.workspace_id)
|
|
if self.project_required or (
|
|
self.project_filter_if_present and self.scope.project_id is not None
|
|
):
|
|
statement = statement.where(row.project_id == self.scope.project_id)
|
|
return statement.order_by(row.id)
|
|
|
|
def _row_to_domain(self, row: RowT) -> DomainT:
|
|
data = _normalized_row_data(row, self.rename_fields)
|
|
data = {key: value for key, value in data.items() if key in self.domain_type.model_fields}
|
|
return cast(DomainT, self.domain_type.model_validate(data))
|
|
|
|
def _model_data(self, model: DomainT) -> dict[str, Any]:
|
|
values = cast(Any, model).model_dump()
|
|
columns = set(self.row_type.__table__.columns.keys())
|
|
data: dict[str, Any] = {}
|
|
for field, value in values.items():
|
|
target = self.rename_fields.get(field, field)
|
|
if target in columns:
|
|
data[target] = value
|
|
return data
|
|
|
|
def list(self, *, limit: int = 100, offset: int = 0) -> list[DomainT]:
|
|
if not 1 <= limit <= 500:
|
|
raise ValueError("limit must be between 1 and 500")
|
|
if offset < 0:
|
|
raise ValueError("offset must be non-negative")
|
|
rows = self.session.scalars(self._statement().limit(limit).offset(offset)).all()
|
|
return [self._row_to_domain(row) for row in rows]
|
|
|
|
def get(self, record_id: UUID) -> DomainT | None:
|
|
row = self.session.scalars(self._statement().where(self.row_type.id == record_id)).first()
|
|
return self._row_to_domain(row) if row is not None else None
|
|
|
|
def create(self, model: DomainT) -> DomainT:
|
|
data = self._model_data(model)
|
|
if data.get("workspace_id") != self.scope.workspace_id:
|
|
raise ValueError("model workspace does not match repository scope")
|
|
if self.project_required and data.get("project_id") != self.scope.project_id:
|
|
raise ValueError("model project does not match repository scope")
|
|
row = self.row_type(**data)
|
|
self.session.add(row)
|
|
self.session.flush()
|
|
return self._row_to_domain(row)
|
|
|
|
def update(self, model: DomainT, *, expected_version: int) -> DomainT:
|
|
record_id = cast(Any, model).id
|
|
data = self._model_data(model)
|
|
data.pop("id", None)
|
|
data.pop("created_at", None)
|
|
data.pop("revision", None)
|
|
data["updated_at"] = _now()
|
|
data["revision"] = expected_version + 1
|
|
statement = (
|
|
update(self.row_type)
|
|
.where(self.row_type.id == record_id)
|
|
.where(self.row_type.workspace_id == self.scope.workspace_id)
|
|
.where(self.row_type.revision == expected_version)
|
|
)
|
|
if self.project_required or (
|
|
self.project_filter_if_present and self.scope.project_id is not None
|
|
):
|
|
statement = statement.where(self.row_type.project_id == self.scope.project_id)
|
|
result = self.session.execute(statement.values(**data))
|
|
if result.rowcount != 1:
|
|
raise ConcurrentUpdateError(f"stale version for {type(self).__name__} {record_id}")
|
|
self.session.flush()
|
|
return self.get(record_id) # type: ignore[return-value]
|
|
|
|
|
|
class WorkspaceRepository(ScopedRepository[WorkspaceRow, Workspace]):
|
|
row_type = WorkspaceRow
|
|
domain_type = Workspace
|
|
project_required = False
|
|
project_filter_if_present = False
|
|
|
|
def create(self, model: Workspace) -> Workspace:
|
|
if model.id != self.scope.workspace_id:
|
|
raise ValueError("workspace model does not match repository scope")
|
|
row = WorkspaceRow(**model.model_dump())
|
|
self.session.add(row)
|
|
self.session.flush()
|
|
return self._row_to_domain(row)
|
|
|
|
def _statement(self) -> Select[tuple[WorkspaceRow]]:
|
|
return (
|
|
select(self.row_type)
|
|
.where(self.row_type.id == self.scope.workspace_id)
|
|
.order_by(self.row_type.id)
|
|
)
|
|
|
|
|
|
class ProjectRepository(ScopedRepository[ProjectRow, Project]):
|
|
row_type = ProjectRow
|
|
domain_type = Project
|
|
project_required = False
|
|
project_filter_if_present = False
|
|
|
|
def _statement(self) -> Select[tuple[ProjectRow]]:
|
|
statement = select(self.row_type).where(
|
|
self.row_type.workspace_id == self.scope.workspace_id
|
|
)
|
|
if self.scope.project_id is not None:
|
|
statement = statement.where(self.row_type.id == self.scope.project_id)
|
|
return statement.order_by(self.row_type.id)
|
|
|
|
def create(self, model: Project) -> Project:
|
|
if self.scope.project_id is not None and model.id != self.scope.project_id:
|
|
raise ValueError("project model does not match repository scope")
|
|
return super().create(model)
|
|
|
|
|
|
class RiskClassRepository:
|
|
"""Read-only catalog boundary for safe global risk classes."""
|
|
|
|
def __init__(self, session: Session, scope: Scope) -> None:
|
|
if not isinstance(scope, Scope):
|
|
raise TypeError("repository scope must be a Scope")
|
|
self.session = session
|
|
self.scope = scope
|
|
|
|
def list(self) -> list[RiskClass]:
|
|
rows = self.session.scalars(select(RiskClassRow).order_by(RiskClassRow.code)).all()
|
|
return [RiskClass.model_validate(row) for row in rows]
|
|
|
|
|
|
class CapabilityRepository:
|
|
"""Read/create boundary for global capability definitions, always scoped by caller."""
|
|
|
|
def __init__(self, session: Session, scope: Scope) -> None:
|
|
if not isinstance(scope, Scope):
|
|
raise TypeError("repository scope must be a Scope")
|
|
self.session = session
|
|
self.scope = scope
|
|
|
|
def list(self, *, limit: int = 100, offset: int = 0) -> list[Capability]:
|
|
rows = self.session.scalars(
|
|
select(CapabilityRow)
|
|
.order_by(CapabilityRow.canonical_name, CapabilityRow.version)
|
|
.limit(limit)
|
|
.offset(offset)
|
|
).all()
|
|
return [Capability.model_validate(_normalized_row_data(row)) for row in rows]
|
|
|
|
def create(self, model: Capability) -> Capability:
|
|
row = CapabilityRow(**model.model_dump())
|
|
self.session.add(row)
|
|
self.session.flush()
|
|
return Capability.model_validate(_normalized_row_data(row))
|
|
|
|
|
|
class ToolDefinitionRepository(ScopedRepository[ToolDefinitionRow, ToolDefinition]):
|
|
row_type = ToolDefinitionRow
|
|
domain_type = ToolDefinition
|
|
|
|
|
|
class VirtualServerCapabilityRepository(
|
|
ScopedRepository[VirtualServerCapabilityRow, VirtualServerCapability]
|
|
):
|
|
row_type = VirtualServerCapabilityRow
|
|
domain_type = VirtualServerCapability
|
|
|
|
|
|
class ClientBindingRepository(ScopedRepository[ClientBindingRow, ClientBinding]):
|
|
row_type = ClientBindingRow
|
|
domain_type = ClientBinding
|
|
|
|
|
|
class EnvironmentRepository(ScopedRepository[EnvironmentRow, Environment]):
|
|
row_type = EnvironmentRow
|
|
domain_type = Environment
|
|
|
|
|
|
class ConnectorRepository(ScopedRepository[ConnectorRow, Connector]):
|
|
row_type = ConnectorRow
|
|
domain_type = Connector
|
|
|
|
def _model_data(self, model: Connector) -> dict[str, Any]:
|
|
data = super()._model_data(model)
|
|
data["environment_scope"] = (
|
|
str(model.environment_id) if model.environment_id else "__project__"
|
|
)
|
|
return data
|
|
|
|
def transition_health(
|
|
self, connector_id: UUID, target: str, *, expected_version: int
|
|
) -> Connector:
|
|
row = self.session.scalars(
|
|
self._statement().where(self.row_type.id == connector_id)
|
|
).first()
|
|
if row is None:
|
|
raise LookupError(f"connector not found in scope: {connector_id}")
|
|
_atomic_transition(
|
|
self.session,
|
|
self.row_type,
|
|
self.scope,
|
|
connector_id,
|
|
row.health_state,
|
|
target,
|
|
expected_version,
|
|
"connector_health",
|
|
"health_state",
|
|
project_required=True,
|
|
)
|
|
self.session.flush()
|
|
return self.get(connector_id) # type: ignore[return-value]
|
|
|
|
|
|
class VirtualServerRepository(ScopedRepository[VirtualServerRow, VirtualServer]):
|
|
row_type = VirtualServerRow
|
|
domain_type = VirtualServer
|
|
rename_fields = {"metadata": "record_metadata"}
|
|
|
|
|
|
class ClientRepository(ScopedRepository[ClientRow, Client]):
|
|
row_type = ClientRow
|
|
domain_type = Client
|
|
project_required = False
|
|
project_filter_if_present = False
|
|
rename_fields = {"metadata": "record_metadata"}
|
|
|
|
def transition_status(self, client_id: UUID, target: str, *, expected_version: int) -> Client:
|
|
row = self.session.scalars(self._statement().where(self.row_type.id == client_id)).first()
|
|
if row is None:
|
|
raise LookupError(f"client not found in workspace: {client_id}")
|
|
_atomic_transition(
|
|
self.session,
|
|
self.row_type,
|
|
self.scope,
|
|
client_id,
|
|
row.status,
|
|
target,
|
|
expected_version,
|
|
"client",
|
|
"status",
|
|
project_required=False,
|
|
)
|
|
if target == "revoked":
|
|
row.revoked_at = _now()
|
|
self.session.flush()
|
|
return self.get(client_id) # type: ignore[return-value]
|
|
|
|
|
|
class PolicyRepository(ScopedRepository[PolicyRow, Policy]):
|
|
row_type = PolicyRow
|
|
domain_type = Policy
|
|
project_required = False
|
|
rename_fields = {"match": "match_json", "conditions": "conditions_json"}
|
|
|
|
|
|
class PolicyDecisionRepository:
|
|
"""Short-retention decision record boundary with mandatory workspace scope."""
|
|
|
|
def __init__(self, session: Session, scope: Scope) -> None:
|
|
if not isinstance(scope, Scope):
|
|
raise TypeError("repository scope must be a Scope")
|
|
self.session = session
|
|
self.scope = scope
|
|
|
|
def create(self, model: PolicyDecision) -> PolicyDecision:
|
|
if model.workspace_id != self.scope.workspace_id:
|
|
raise ValueError("policy decision workspace does not match scope")
|
|
if self.scope.project_id is not None and model.project_id != self.scope.project_id:
|
|
raise ValueError("policy decision project does not match scope")
|
|
row = PolicyDecisionRow(**model.model_dump())
|
|
self.session.add(row)
|
|
self.session.flush()
|
|
return PolicyDecision.model_validate(_normalized_row_data(row))
|
|
|
|
def list(self, *, limit: int = 100, offset: int = 0) -> list[PolicyDecision]:
|
|
statement = select(PolicyDecisionRow).where(
|
|
PolicyDecisionRow.workspace_id == self.scope.workspace_id
|
|
)
|
|
if self.scope.project_id is not None:
|
|
statement = statement.where(PolicyDecisionRow.project_id == self.scope.project_id)
|
|
statement = (
|
|
statement.order_by(PolicyDecisionRow.created_at, PolicyDecisionRow.id)
|
|
.limit(limit)
|
|
.offset(offset)
|
|
)
|
|
return [
|
|
PolicyDecision.model_validate(_normalized_row_data(row))
|
|
for row in self.session.scalars(statement).all()
|
|
]
|
|
|
|
|
|
class ReconcileRunRepository(ScopedRepository[ReconcileRunRow, ReconcileRun]):
|
|
row_type = ReconcileRunRow
|
|
domain_type = ReconcileRun
|
|
|
|
def transition_status(
|
|
self, run_id: UUID, target: str, *, expected_version: int
|
|
) -> ReconcileRun:
|
|
row = self.session.scalars(self._statement().where(self.row_type.id == run_id)).first()
|
|
if row is None:
|
|
raise LookupError(f"reconcile run not found in scope: {run_id}")
|
|
_atomic_transition(
|
|
self.session,
|
|
self.row_type,
|
|
self.scope,
|
|
run_id,
|
|
row.status,
|
|
target,
|
|
expected_version,
|
|
"reconcile",
|
|
"status",
|
|
project_required=True,
|
|
)
|
|
if target in {"succeeded", "failed", "invalid"}:
|
|
row.completed_at = _now()
|
|
self.session.flush()
|
|
return self.get(run_id) # type: ignore[return-value]
|
|
|
|
|
|
class OperationRepository(ScopedRepository[OperationRow, Operation]):
|
|
row_type = OperationRow
|
|
domain_type = Operation
|
|
|
|
def transition_state(
|
|
self, operation_id: UUID, target: str, *, expected_version: int
|
|
) -> Operation:
|
|
row = self.session.scalars(
|
|
self._statement().where(self.row_type.id == operation_id)
|
|
).first()
|
|
if row is None:
|
|
raise LookupError(f"operation not found in scope: {operation_id}")
|
|
_atomic_transition(
|
|
self.session,
|
|
self.row_type,
|
|
self.scope,
|
|
operation_id,
|
|
row.state,
|
|
target,
|
|
expected_version,
|
|
"operation",
|
|
"state",
|
|
project_required=True,
|
|
)
|
|
self.session.flush()
|
|
return self.get(operation_id) # type: ignore[return-value]
|
|
|
|
|
|
class ApprovalRepository(ScopedRepository[ApprovalRow, Approval]):
|
|
"""Approval boundary with plan-hash and state-machine enforcement."""
|
|
|
|
row_type = ApprovalRow
|
|
domain_type = Approval
|
|
|
|
def transition_state(
|
|
self, approval_id: UUID, target: str, *, expected_version: int
|
|
) -> Approval:
|
|
row = self.session.scalars(self._statement().where(self.row_type.id == approval_id)).first()
|
|
if row is None:
|
|
raise LookupError(f"approval not found in scope: {approval_id}")
|
|
if target == "approved":
|
|
operation = self.session.scalars(
|
|
select(OperationRow)
|
|
.where(OperationRow.id == row.operation_id)
|
|
.where(OperationRow.workspace_id == self.scope.workspace_id)
|
|
.where(OperationRow.project_id == self.scope.project_id)
|
|
).first()
|
|
if operation is None or operation.plan_hash != row.plan_hash:
|
|
raise DomainError("approval plan hash does not match operation")
|
|
if operation.state != "approval_required":
|
|
raise InvalidTransition("operation is not waiting for approval")
|
|
_atomic_transition(
|
|
self.session,
|
|
self.row_type,
|
|
self.scope,
|
|
approval_id,
|
|
row.state,
|
|
target,
|
|
expected_version,
|
|
"approval",
|
|
"state",
|
|
project_required=True,
|
|
)
|
|
if target == "approved":
|
|
row.approved_at = _now()
|
|
self.session.flush()
|
|
return self.get(approval_id) # type: ignore[return-value]
|
|
|
|
|
|
class AuditEventRepository:
|
|
"""Append/query-only audit boundary; update and delete are not exposed."""
|
|
|
|
_sensitive_key = re.compile(
|
|
r"(?:password|secret|token|private|authorization|cookie|credential|connection)", re.I
|
|
)
|
|
|
|
def __init__(self, session: Session, scope: Scope) -> None:
|
|
if not isinstance(scope, Scope):
|
|
raise TypeError("repository scope must be a Scope")
|
|
self.session = session
|
|
self.scope = scope
|
|
|
|
@classmethod
|
|
def _assert_sanitized(cls, value: Any) -> None:
|
|
if isinstance(value, dict):
|
|
for key, item in value.items():
|
|
if cls._sensitive_key.search(str(key)):
|
|
raise AuditMutationError(f"sensitive audit field is prohibited: {key}")
|
|
cls._assert_sanitized(item)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
cls._assert_sanitized(item)
|
|
|
|
def create(self, event: AuditEvent) -> AuditEvent:
|
|
if event.workspace_id != self.scope.workspace_id:
|
|
raise ValueError("audit workspace does not match repository scope")
|
|
if self.scope.project_id is not None and event.project_id != self.scope.project_id:
|
|
raise ValueError("audit project does not match repository scope")
|
|
self._assert_sanitized(event.sanitized_arguments)
|
|
row = AuditEventRow(**event.model_dump())
|
|
self.session.add(row)
|
|
self.session.flush()
|
|
return AuditEvent.model_validate(row)
|
|
|
|
def list(self, *, limit: int = 100, offset: int = 0) -> list[AuditEvent]:
|
|
if not 1 <= limit <= 500 or offset < 0:
|
|
raise ValueError("invalid pagination")
|
|
statement = select(AuditEventRow).where(
|
|
AuditEventRow.workspace_id == self.scope.workspace_id
|
|
)
|
|
if self.scope.project_id is not None:
|
|
statement = statement.where(AuditEventRow.project_id == self.scope.project_id)
|
|
statement = (
|
|
statement.order_by(AuditEventRow.occurred_at, AuditEventRow.id)
|
|
.limit(limit)
|
|
.offset(offset)
|
|
)
|
|
return [
|
|
AuditEvent.model_validate(_normalized_row_data(row))
|
|
for row in self.session.scalars(statement).all()
|
|
]
|
|
|
|
|
|
class KnowledgeSourceRepository(ScopedRepository[KnowledgeSourceRow, KnowledgeSource]):
|
|
row_type = KnowledgeSourceRow
|
|
domain_type = KnowledgeSource
|
|
|
|
|
|
class KnowledgeDocumentRepository(ScopedRepository[KnowledgeDocumentRow, KnowledgeDocument]):
|
|
row_type = KnowledgeDocumentRow
|
|
domain_type = KnowledgeDocument
|
|
rename_fields = {"metadata": "record_metadata"}
|
|
|
|
|
|
class KnowledgeChunkRepository(ScopedRepository[KnowledgeChunkRow, KnowledgeChunk]):
|
|
row_type = KnowledgeChunkRow
|
|
domain_type = KnowledgeChunk
|
|
rename_fields = {"metadata": "record_metadata"}
|