351 lines
12 KiB
Python
351 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from collections.abc import Iterable
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
|
from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify
|
|
from govoplan_core.core.access import (
|
|
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
|
|
AccessGovernanceProjectionV1,
|
|
GovernanceProjectionBatch,
|
|
GovernanceProjectionCommand,
|
|
GovernanceProjectionOutcome,
|
|
GovernanceProjectionResult,
|
|
GovernanceTemplateMaterialization,
|
|
)
|
|
from govoplan_core.core.runtime import get_registry
|
|
from govoplan_core.security.permissions import validate_tenant_permissions
|
|
from govoplan_core.tenancy.scope import Tenant
|
|
|
|
TEMPLATE_KINDS = {"group", "role"}
|
|
ASSIGNMENT_MODES = {"available", "required"}
|
|
MAX_ASSIGNMENTS_PER_TEMPLATE = 500
|
|
MAX_TEMPLATES_PER_SYNCHRONIZATION = 100
|
|
|
|
|
|
def _governance_projection() -> AccessGovernanceProjectionV1:
|
|
registry = get_registry()
|
|
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1):
|
|
raise AdminValidationError("Access governance projection v1 capability is not configured.")
|
|
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1)
|
|
if not isinstance(capability, AccessGovernanceProjectionV1):
|
|
raise AdminValidationError("Access governance projection v1 capability is invalid.")
|
|
return capability
|
|
|
|
|
|
def _materialization(
|
|
item: GovernanceTemplate,
|
|
*,
|
|
tenant_id: str,
|
|
required: bool = False,
|
|
) -> GovernanceTemplateMaterialization:
|
|
return GovernanceTemplateMaterialization(
|
|
template_id=item.id,
|
|
kind=item.kind, # type: ignore[arg-type]
|
|
tenant_id=tenant_id,
|
|
slug=item.slug,
|
|
name=item.name,
|
|
description=item.description,
|
|
permissions=tuple(item.permissions or []),
|
|
is_active=item.is_active,
|
|
required=required,
|
|
)
|
|
|
|
|
|
def _command(
|
|
item: GovernanceTemplate,
|
|
assignment: GovernanceTemplateAssignment,
|
|
*,
|
|
operation: str,
|
|
source: str,
|
|
) -> GovernanceProjectionCommand:
|
|
return GovernanceProjectionCommand(
|
|
assignment_id=assignment.id,
|
|
operation=operation, # type: ignore[arg-type]
|
|
template=_materialization(
|
|
item,
|
|
tenant_id=assignment.tenant_id,
|
|
required=assignment.mode == "required",
|
|
),
|
|
provenance={
|
|
"source": source,
|
|
"template_id": item.id,
|
|
"assignment_mode": assignment.mode,
|
|
},
|
|
)
|
|
|
|
|
|
def _reconcile(
|
|
session: Session,
|
|
commands: Iterable[GovernanceProjectionCommand],
|
|
*,
|
|
source: str,
|
|
dry_run: bool = False,
|
|
) -> GovernanceProjectionResult:
|
|
bounded = tuple(commands)
|
|
if not bounded:
|
|
return GovernanceProjectionResult(
|
|
operation_id=f"admin-governance:{uuid.uuid4()}",
|
|
outcomes=(),
|
|
dry_run=dry_run,
|
|
)
|
|
batch = GovernanceProjectionBatch(
|
|
operation_id=f"admin-governance:{uuid.uuid4()}",
|
|
commands=bounded,
|
|
dry_run=dry_run,
|
|
)
|
|
result = _governance_projection().reconcile(session, batch)
|
|
expected = {item.assignment_id for item in bounded}
|
|
returned = {item.assignment_id for item in result.outcomes}
|
|
if expected != returned or len(result.outcomes) != len(bounded):
|
|
raise AdminValidationError(f"Access governance projection returned an incomplete {source} result.")
|
|
return result
|
|
|
|
|
|
def _raise_blocked(result: GovernanceProjectionResult) -> None:
|
|
if not result.blocked:
|
|
return
|
|
summaries = [
|
|
f"{item.tenant_id}: {item.message or ', '.join(item.blocker_codes) or item.status}"
|
|
for item in result.blocked[:10]
|
|
]
|
|
suffix = "" if len(result.blocked) <= 10 else f" (+{len(result.blocked) - 10} more)"
|
|
raise AdminConflictError("Governance synchronization blocked: " + "; ".join(summaries) + suffix)
|
|
|
|
|
|
def validate_template(kind: str, permissions: list[str]) -> list[str]:
|
|
if kind not in TEMPLATE_KINDS:
|
|
raise AdminValidationError("Template kind must be group or role.")
|
|
if kind == "group":
|
|
if permissions:
|
|
raise AdminValidationError("Group templates do not contain permissions; assign roles to groups inside each tenant.")
|
|
return []
|
|
try:
|
|
return validate_tenant_permissions(permissions)
|
|
except ValueError as exc:
|
|
raise AdminValidationError(str(exc)) from exc
|
|
|
|
|
|
def create_template(
|
|
session: Session,
|
|
*,
|
|
kind: str,
|
|
slug: str,
|
|
name: str,
|
|
description: str | None,
|
|
permissions: list[str],
|
|
is_active: bool,
|
|
assignments: list[dict[str, str]],
|
|
) -> GovernanceTemplate:
|
|
normalized_slug = slugify(slug)
|
|
permissions = validate_template(kind, permissions)
|
|
exists = session.query(GovernanceTemplate).filter(
|
|
GovernanceTemplate.kind == kind,
|
|
GovernanceTemplate.slug == normalized_slug,
|
|
).first()
|
|
if exists:
|
|
raise AdminConflictError(f"A {kind} template with slug {normalized_slug!r} already exists.")
|
|
item = GovernanceTemplate(
|
|
kind=kind,
|
|
slug=normalized_slug,
|
|
name=name.strip(),
|
|
description=description,
|
|
permissions=permissions,
|
|
is_active=is_active,
|
|
)
|
|
session.add(item)
|
|
session.flush()
|
|
set_template_assignments(session, item, assignments)
|
|
return item
|
|
|
|
|
|
def update_template(
|
|
session: Session,
|
|
item: GovernanceTemplate,
|
|
*,
|
|
name: str,
|
|
description: str | None,
|
|
permissions: list[str],
|
|
is_active: bool,
|
|
assignments: list[dict[str, str]],
|
|
) -> GovernanceTemplate:
|
|
item.name = name.strip()
|
|
item.description = description
|
|
item.permissions = validate_template(item.kind, permissions)
|
|
item.is_active = is_active
|
|
set_template_assignments(session, item, assignments)
|
|
return item
|
|
|
|
|
|
def set_template_assignments(
|
|
session: Session,
|
|
item: GovernanceTemplate,
|
|
assignments: list[dict[str, str]],
|
|
) -> GovernanceProjectionResult:
|
|
if len(assignments) > MAX_ASSIGNMENTS_PER_TEMPLATE:
|
|
raise AdminValidationError(
|
|
f"A governance template supports at most {MAX_ASSIGNMENTS_PER_TEMPLATE} tenant assignments."
|
|
)
|
|
desired: dict[str, str] = {}
|
|
for assignment in assignments:
|
|
tenant_id = assignment.get("tenant_id", "")
|
|
mode = assignment.get("mode", "available")
|
|
if not tenant_id:
|
|
raise AdminValidationError("Template assignments require a tenant id.")
|
|
if tenant_id in desired:
|
|
raise AdminValidationError(f"Duplicate tenant assignment: {tenant_id}")
|
|
if mode not in ASSIGNMENT_MODES:
|
|
raise AdminValidationError("Template assignment mode must be available or required.")
|
|
desired[tenant_id] = mode
|
|
|
|
known_tenants = {
|
|
tenant_id
|
|
for (tenant_id,) in session.query(Tenant.id).filter(Tenant.id.in_(desired)).all()
|
|
} if desired else set()
|
|
missing_tenants = sorted(set(desired) - known_tenants)
|
|
if missing_tenants:
|
|
preview = ", ".join(missing_tenants[:10])
|
|
suffix = "" if len(missing_tenants) <= 10 else f" (+{len(missing_tenants) - 10} more)"
|
|
raise AdminValidationError(f"Unknown tenants: {preview}{suffix}")
|
|
|
|
existing = {
|
|
row.tenant_id: row
|
|
for row in session.query(GovernanceTemplateAssignment)
|
|
.filter(GovernanceTemplateAssignment.template_id == item.id)
|
|
.all()
|
|
}
|
|
removed = [row for tenant_id, row in existing.items() if tenant_id not in desired]
|
|
retained: list[GovernanceTemplateAssignment] = []
|
|
for tenant_id, mode in desired.items():
|
|
row = existing.get(tenant_id)
|
|
if row is None:
|
|
row = GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode)
|
|
session.add(row)
|
|
else:
|
|
row.mode = mode
|
|
retained.append(row)
|
|
session.flush()
|
|
|
|
commands = [
|
|
_command(item, row, operation="remove", source="admin.assignment-reconciliation")
|
|
for row in removed
|
|
]
|
|
commands.extend(
|
|
_command(item, row, operation="upsert", source="admin.assignment-reconciliation")
|
|
for row in retained
|
|
)
|
|
result = _reconcile(
|
|
session,
|
|
commands,
|
|
source="assignment reconciliation",
|
|
)
|
|
_raise_blocked(result)
|
|
for row in removed:
|
|
session.delete(row)
|
|
session.flush()
|
|
return result
|
|
|
|
|
|
def sync_template(session: Session, item: GovernanceTemplate) -> GovernanceProjectionResult:
|
|
return synchronize_templates(session, template_ids=(item.id,), dry_run=False)
|
|
|
|
|
|
def synchronize_templates(
|
|
session: Session,
|
|
*,
|
|
template_ids: Iterable[str],
|
|
dry_run: bool,
|
|
) -> GovernanceProjectionResult:
|
|
requested = tuple(dict.fromkeys(template_ids))
|
|
if not requested or len(requested) > MAX_TEMPLATES_PER_SYNCHRONIZATION:
|
|
raise AdminValidationError(
|
|
f"Select between 1 and {MAX_TEMPLATES_PER_SYNCHRONIZATION} governance templates."
|
|
)
|
|
templates = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(requested)).all()
|
|
templates_by_id = {item.id: item for item in templates}
|
|
missing_templates = sorted(set(requested) - set(templates_by_id))
|
|
if missing_templates:
|
|
raise AdminValidationError("Unknown governance templates: " + ", ".join(missing_templates[:10]))
|
|
assignments = (
|
|
session.query(GovernanceTemplateAssignment)
|
|
.filter(GovernanceTemplateAssignment.template_id.in_(requested))
|
|
.order_by(
|
|
GovernanceTemplateAssignment.template_id.asc(),
|
|
GovernanceTemplateAssignment.tenant_id.asc(),
|
|
)
|
|
.all()
|
|
)
|
|
if len(assignments) > MAX_ASSIGNMENTS_PER_TEMPLATE:
|
|
raise AdminValidationError(
|
|
f"A synchronization run supports at most {MAX_ASSIGNMENTS_PER_TEMPLATE} tenant assignments."
|
|
)
|
|
tenant_ids = {item.tenant_id for item in assignments}
|
|
known_tenants = {
|
|
tenant_id
|
|
for (tenant_id,) in session.query(Tenant.id).filter(Tenant.id.in_(tenant_ids)).all()
|
|
} if tenant_ids else set()
|
|
|
|
valid_commands = [
|
|
_command(
|
|
templates_by_id[assignment.template_id],
|
|
assignment,
|
|
operation="upsert",
|
|
source="admin.bulk-synchronization",
|
|
)
|
|
for assignment in assignments
|
|
if assignment.tenant_id in known_tenants
|
|
]
|
|
result = _reconcile(
|
|
session,
|
|
valid_commands,
|
|
source="bulk synchronization",
|
|
dry_run=dry_run,
|
|
)
|
|
invalid_outcomes = [
|
|
GovernanceProjectionOutcome(
|
|
assignment_id=assignment.id,
|
|
template_id=assignment.template_id,
|
|
tenant_id=assignment.tenant_id,
|
|
kind=templates_by_id[assignment.template_id].kind, # type: ignore[arg-type]
|
|
operation="upsert",
|
|
status="blocked",
|
|
blocker_codes=("unknown_tenant",),
|
|
message="The assigned tenant no longer exists.",
|
|
provenance={
|
|
"source": "admin.bulk-synchronization",
|
|
"template_id": assignment.template_id,
|
|
"assignment_mode": assignment.mode,
|
|
},
|
|
)
|
|
for assignment in assignments
|
|
if assignment.tenant_id not in known_tenants
|
|
]
|
|
outcome_by_assignment = {
|
|
item.assignment_id: item for item in (*result.outcomes, *invalid_outcomes)
|
|
}
|
|
return GovernanceProjectionResult(
|
|
operation_id=result.operation_id,
|
|
outcomes=tuple(outcome_by_assignment[item.id] for item in assignments),
|
|
dry_run=dry_run,
|
|
)
|
|
|
|
|
|
def delete_template(session: Session, item: GovernanceTemplate) -> GovernanceProjectionResult:
|
|
assignments = session.query(GovernanceTemplateAssignment).filter(
|
|
GovernanceTemplateAssignment.template_id == item.id
|
|
).all()
|
|
result = _reconcile(
|
|
session,
|
|
(
|
|
_command(item, assignment, operation="remove", source="admin.template-deletion")
|
|
for assignment in assignments
|
|
),
|
|
source="template deletion",
|
|
)
|
|
_raise_blocked(result)
|
|
session.delete(item)
|
|
return result
|