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