Files
ITWorx_MCP_Hub-Public/services/runtime/client_service.py
T

256 lines
10 KiB
Python

from __future__ import annotations
import hashlib
import json
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import select
from packages.hub_clients import (
BootstrapHandle,
ClientConfig,
ClientKind,
ClientRecord,
ClientStatus,
generate_config,
)
from services.control_api.auth.models import AuthenticatedPrincipal
from services.control_api.persistence.models import (
AuditEventRow,
ClientBindingRow,
ClientRow,
EnvironmentRow,
ProjectRow,
VirtualServerRow,
WorkspaceRow,
)
class PersistentClientService:
"""Database-backed production lifecycle shared by the control API and dataplane."""
def __init__(self, sessions: Any, *, issuer: str, public_host: str) -> None:
self.sessions = sessions
self.issuer = issuer
self.public_host = public_host
@staticmethod
def _record(row: ClientRow) -> ClientRecord:
metadata = row.record_metadata if isinstance(row.record_metadata, dict) else {}
client_status = {
"active": ClientStatus.ENABLED,
"disabled": ClientStatus.DISABLED,
"pending": ClientStatus.PENDING,
"revoked": ClientStatus.REVOKED,
}.get(row.status, ClientStatus.DISABLED)
return ClientRecord(
id=row.id,
name=str(metadata.get("name", row.subject)),
project=str(metadata.get("project", "unknown")),
environment=str(metadata.get("environment", "prod")),
kind=ClientKind(str(metadata.get("kind", ClientKind.NATIVE_2026.value))),
status=client_status,
credential_reference=str(
metadata.get("credentialReference", f"authentik://mcp-client/{row.id}")
),
created_at=row.created_at,
last_seen_at=row.last_seen_at,
disabled_at=metadata.get("disabledAt"),
revoked_at=row.revoked_at,
)
def create(
self, *, name: str, project: str, environment: str, kind: ClientKind
) -> tuple[ClientRecord, BootstrapHandle, ClientConfig]:
now = datetime.now(UTC)
client_id = uuid4()
bootstrap = BootstrapHandle(
client_id=client_id,
reference=f"authentik-bootstrap://{uuid4()}",
expires_at=now + timedelta(minutes=5),
)
credential_reference = f"authentik://mcp-client/{client_id}"
with self.sessions() as session, session.begin():
workspace_id = session.scalar(
select(WorkspaceRow.id).where(WorkspaceRow.slug == "itworx")
)
if workspace_id is None:
raise ValueError("client workspace is not initialized")
project_row = session.scalar(
select(ProjectRow).where(
ProjectRow.workspace_id == workspace_id,
ProjectRow.slug == project,
ProjectRow.status == "active",
)
)
if project_row is None:
raise ValueError("client project is not registered")
environment_row = session.scalar(
select(EnvironmentRow).where(
EnvironmentRow.workspace_id == workspace_id,
EnvironmentRow.project_id == project_row.id,
EnvironmentRow.name == environment,
EnvironmentRow.status == "active",
)
)
if environment_row is None:
raise ValueError("client environment is not registered")
server = session.scalar(
select(VirtualServerRow).where(
VirtualServerRow.workspace_id == workspace_id,
VirtualServerRow.project_id == project_row.id,
VirtualServerRow.environment_id == environment_row.id,
VirtualServerRow.enabled.is_(True),
)
)
if server is None:
raise ValueError("client environment has no enabled virtual server")
row = ClientRow(
id=client_id,
workspace_id=workspace_id,
subject=f"itworx-mcp-client-{client_id}",
issuer=self.issuer,
status="pending",
record_metadata={
"managedBy": "runtime-onboarding",
"name": name,
"project": project,
"environment": environment,
"kind": kind.value,
"credentialReference": credential_reference,
"bootstrapReference": bootstrap.reference,
"bootstrapExpiresAt": bootstrap.expires_at.isoformat(),
"operatorDisabled": False,
},
)
session.add(row)
session.flush()
session.add(
ClientBindingRow(
workspace_id=workspace_id,
project_id=project_row.id,
client_id=client_id,
virtual_server_id=server.id,
enabled=False,
)
)
session.flush()
record = self._record(row)
return record, bootstrap, generate_config(record, public_host=self.public_host)
def list(self) -> tuple[ClientRecord, ...]:
with self.sessions() as session:
rows = session.scalars(
select(ClientRow)
.join(WorkspaceRow, WorkspaceRow.id == ClientRow.workspace_id)
.where(WorkspaceRow.slug == "itworx")
.order_by(ClientRow.created_at, ClientRow.id)
).all()
return tuple(self._record(row) for row in rows)
def _get(self, client_id: UUID) -> ClientRecord:
with self.sessions() as session:
row = session.get(ClientRow, client_id)
if row is None:
raise KeyError(client_id)
return self._record(row)
def config(self, client_id: UUID, kind: ClientKind | None = None) -> ClientConfig:
record = self._get(client_id)
selected = record if kind is None else record.model_copy(update={"kind": kind})
return generate_config(selected, public_host=self.public_host)
def transition(
self, client_id: UUID, status: ClientStatus, actor: AuthenticatedPrincipal | None = None
) -> ClientRecord:
now = datetime.now(UTC)
with self.sessions() as session, session.begin():
row = session.scalar(
select(ClientRow)
.join(WorkspaceRow, WorkspaceRow.id == ClientRow.workspace_id)
.where(ClientRow.id == client_id, WorkspaceRow.slug == "itworx")
)
if row is None:
raise KeyError(client_id)
metadata = dict(row.record_metadata or {})
if row.status == "revoked" and status is ClientStatus.DISABLED:
raise ValueError("revoked clients cannot be changed to disabled")
metadata["operatorDisabled"] = True
if status is ClientStatus.DISABLED:
metadata["disabledAt"] = now.isoformat()
row.status = "disabled"
else:
row.status = "revoked"
row.revoked_at = now
row.record_metadata = metadata
bindings = session.scalars(
select(ClientBindingRow).where(ClientBindingRow.client_id == client_id)
).all()
for binding in bindings:
binding.enabled = False
scope_binding = bindings[0] if len(bindings) == 1 else None
scope_server = (
session.get(VirtualServerRow, scope_binding.virtual_server_id)
if scope_binding is not None
else None
)
session.flush()
action = "client.disable" if status is ClientStatus.DISABLED else "client.revoke"
arguments = {"clientId": str(client_id), "action": action}
session.add(
AuditEventRow(
id=uuid4(),
occurred_at=now,
trace_id=str(uuid4()),
workspace_id=row.workspace_id,
project_id=scope_binding.project_id if scope_binding else None,
environment_id=scope_server.environment_id if scope_server else None,
virtual_server_id=scope_server.id if scope_server else None,
client_id=row.id,
event_type=action,
tool_name=action,
principal_issuer=(actor.issuer if actor is not None else self.issuer)[:512],
principal_subject=(
actor.subject if actor is not None else "runtime-client-management"
)[:256],
policy_decision="allow",
matched_rule_ids=[action],
policy_trace=[{"action": action, "decision": "allow"}],
arguments_hash=hashlib.sha256(
json.dumps(arguments, sort_keys=True).encode("utf-8")
).hexdigest(),
sanitized_arguments=arguments,
duration_ms=0,
outcome="success",
retention_until=now + timedelta(days=30),
)
)
return self._record(row)
def disable(self, client_id: UUID) -> ClientRecord:
return self.transition(client_id, ClientStatus.DISABLED)
def revoke(self, client_id: UUID) -> ClientRecord:
return self.transition(client_id, ClientStatus.REVOKED)
def bootstrap(self, client_id: UUID) -> BootstrapHandle:
record = self._get(client_id)
if record.status is ClientStatus.REVOKED:
raise PermissionError("revoked clients cannot receive a bootstrap reference")
handle = BootstrapHandle(
client_id=client_id,
reference=f"authentik-bootstrap://{uuid4()}",
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
with self.sessions() as session, session.begin():
row = session.get(ClientRow, client_id)
if row is None:
raise KeyError(client_id)
metadata = dict(row.record_metadata or {})
metadata["bootstrapReference"] = handle.reference
metadata["bootstrapExpiresAt"] = handle.expires_at.isoformat()
row.record_metadata = metadata
return handle