256 lines
10 KiB
Python
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
|