Files
govoplan-access/src/govoplan_access/backend/governance_materializer.py
T

403 lines
14 KiB
Python

from __future__ import annotations
from collections import defaultdict
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import (
Group,
GroupRoleAssignment,
Role,
UserGroupMembership,
UserRoleAssignment,
new_uuid,
)
from govoplan_core.admin.common import AdminConflictError
from govoplan_core.core.access import (
AccessGovernanceMaterializer,
AccessGovernanceProjectionV1,
GovernanceProjectionBatch,
GovernanceProjectionCommand,
GovernanceProjectionOutcome,
GovernanceProjectionResult,
GovernanceTemplateMaterialization,
)
from govoplan_core.core.runtime import get_registry
class SqlAccessGovernanceMaterializer(
AccessGovernanceMaterializer,
AccessGovernanceProjectionV1,
):
"""Reconcile governance projections with a constant number of bulk reads."""
def reconcile(
self,
session: object,
batch: GovernanceProjectionBatch,
) -> GovernanceProjectionResult:
db = _session(session)
commands = tuple(batch.commands)
group_commands = tuple(item for item in commands if item.template.kind == "group")
role_commands = tuple(item for item in commands if item.template.kind == "role")
groups, duplicate_group_keys = _managed_groups(db, group_commands)
roles, duplicate_role_keys = _managed_roles(db, role_commands)
used_group_slugs = _used_slugs(db, Group, group_commands)
used_role_slugs = _used_slugs(db, Role, role_commands)
group_ids = {item.id for item in groups.values()}
role_ids = {item.id for item in roles.values()}
group_memberships = _assignment_counts(db, UserGroupMembership, UserGroupMembership.group_id, group_ids)
group_role_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.group_id, group_ids)
role_user_links = _assignment_counts(db, UserRoleAssignment, UserRoleAssignment.role_id, role_ids)
role_group_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.role_id, role_ids)
outcomes: list[GovernanceProjectionOutcome] = []
for command in commands:
key = (command.template.tenant_id, command.template.template_id)
if command.template.kind == "group":
outcome = self._reconcile_group(
db,
command,
groups,
duplicate_group_keys,
used_group_slugs,
group_memberships,
group_role_links,
dry_run=batch.dry_run,
)
else:
outcome = self._reconcile_role(
db,
command,
roles,
duplicate_role_keys,
used_role_slugs,
role_user_links,
role_group_links,
dry_run=batch.dry_run,
)
outcomes.append(outcome)
if outcome.status in {"removed", "absent"}:
groups.pop(key, None)
roles.pop(key, None)
if not batch.dry_run:
db.flush()
return GovernanceProjectionResult(
operation_id=batch.operation_id,
outcomes=tuple(outcomes),
dry_run=batch.dry_run,
)
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
self._legacy_reconcile(session, template, operation="upsert")
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
self._legacy_reconcile(session, template, operation="remove")
def _legacy_reconcile(
self,
session: object,
template: GovernanceTemplateMaterialization,
*,
operation: str,
) -> None:
command = GovernanceProjectionCommand(
assignment_id=f"legacy:{template.kind}:{template.template_id}:{template.tenant_id}",
operation=operation, # type: ignore[arg-type]
template=template,
provenance={"contract": "access.governanceMaterializer"},
)
result = self.reconcile(
session,
GovernanceProjectionBatch(
operation_id=command.assignment_id,
commands=(command,),
),
)
if result.blocked:
raise AdminConflictError(result.blocked[0].message or "Governance projection was blocked.")
def _reconcile_group(
self,
db: Session,
command: GovernanceProjectionCommand,
existing: dict[tuple[str, str], Group],
duplicate_keys: set[tuple[str, str]],
used_slugs: dict[str, set[str]],
membership_counts: dict[str, int],
role_counts: dict[str, int],
*,
dry_run: bool,
) -> GovernanceProjectionOutcome:
template = command.template
key = (template.tenant_id, template.template_id)
group = existing.get(key)
if key in duplicate_keys:
return _outcome(
command,
status="failed",
blocker_codes=("duplicate_managed_projection",),
message="Multiple managed groups exist for this template and tenant.",
)
if command.operation == "remove":
if group is None:
return _outcome(command, status="absent")
blockers: list[str] = []
if membership_counts.get(group.id, 0):
blockers.append("group_has_members")
if role_counts.get(group.id, 0):
blockers.append("group_has_roles")
if blockers:
return _outcome(
command,
status="blocked",
resource_id=group.id,
blocker_codes=tuple(blockers),
message=f"Cannot remove {template.name!r} while its managed group has members or roles.",
)
if not dry_run:
try:
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
except AdminConflictError as exc:
return _outcome(
command,
status="blocked",
resource_id=group.id,
blocker_codes=("module_delete_veto",),
message=str(exc),
)
db.delete(group)
return _outcome(command, status="removed", resource_id=group.id)
if group is None:
resource_id = new_uuid()
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
if not dry_run:
group = Group(
id=resource_id,
tenant_id=template.tenant_id,
slug=slug,
name=template.name,
description=template.description,
is_active=template.is_active,
system_template_id=template.template_id,
system_required=template.required,
)
db.add(group)
existing[key] = group
return _outcome(command, status="created", resource_id=resource_id)
changes = {
"name": template.name,
"description": template.description,
"system_required": template.required,
}
if template.required:
changes["is_active"] = template.is_active
changed = any(getattr(group, field) != value for field, value in changes.items())
if changed and not dry_run:
for field, value in changes.items():
setattr(group, field, value)
return _outcome(command, status="updated" if changed else "unchanged", resource_id=group.id)
def _reconcile_role(
self,
db: Session,
command: GovernanceProjectionCommand,
existing: dict[tuple[str, str], Role],
duplicate_keys: set[tuple[str, str]],
used_slugs: dict[str, set[str]],
user_counts: dict[str, int],
group_counts: dict[str, int],
*,
dry_run: bool,
) -> GovernanceProjectionOutcome:
template = command.template
key = (template.tenant_id, template.template_id)
role = existing.get(key)
if key in duplicate_keys:
return _outcome(
command,
status="failed",
blocker_codes=("duplicate_managed_projection",),
message="Multiple managed roles exist for this template and tenant.",
)
if command.operation == "remove":
if role is None:
return _outcome(command, status="absent")
blockers: list[str] = []
if user_counts.get(role.id, 0):
blockers.append("role_has_users")
if group_counts.get(role.id, 0):
blockers.append("role_has_groups")
if blockers:
return _outcome(
command,
status="blocked",
resource_id=role.id,
blocker_codes=tuple(blockers),
message=f"Cannot remove {template.name!r} while its managed role is assigned to users or groups.",
)
if not dry_run:
db.delete(role)
return _outcome(command, status="removed", resource_id=role.id)
if role is None:
resource_id = new_uuid()
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
if not dry_run:
role = Role(
id=resource_id,
tenant_id=template.tenant_id,
slug=slug,
name=template.name,
description=template.description,
permissions=list(template.permissions),
is_builtin=False,
is_assignable=template.is_active,
system_template_id=template.template_id,
system_required=template.required,
)
db.add(role)
existing[key] = role
return _outcome(command, status="created", resource_id=resource_id)
changes: dict[str, object] = {
"name": template.name,
"description": template.description,
"permissions": list(template.permissions),
"system_required": template.required,
}
if template.required:
changes["is_assignable"] = template.is_active
changed = any(getattr(role, field) != value for field, value in changes.items())
if changed and not dry_run:
for field, value in changes.items():
setattr(role, field, value)
return _outcome(command, status="updated" if changed else "unchanged", resource_id=role.id)
def _managed_groups(
session: Session,
commands: tuple[GovernanceProjectionCommand, ...],
) -> tuple[dict[tuple[str, str], Group], set[tuple[str, str]]]:
if not commands:
return {}, set()
tenants = {item.template.tenant_id for item in commands}
templates = {item.template.template_id for item in commands}
rows = session.query(Group).filter(
Group.tenant_id.in_(tenants),
Group.system_template_id.in_(templates),
).all()
return _indexed_managed(rows)
def _managed_roles(
session: Session,
commands: tuple[GovernanceProjectionCommand, ...],
) -> tuple[dict[tuple[str, str], Role], set[tuple[str, str]]]:
if not commands:
return {}, set()
tenants = {item.template.tenant_id for item in commands}
templates = {item.template.template_id for item in commands}
rows = session.query(Role).filter(
Role.tenant_id.in_(tenants),
Role.system_template_id.in_(templates),
).all()
return _indexed_managed(rows)
def _indexed_managed(rows):
indexed = {}
duplicates = set()
for row in rows:
key = (row.tenant_id, row.system_template_id)
if key in indexed:
duplicates.add(key)
else:
indexed[key] = row
return indexed, duplicates
def _used_slugs(
session: Session,
model: type[Group] | type[Role],
commands: tuple[GovernanceProjectionCommand, ...],
) -> dict[str, set[str]]:
used: dict[str, set[str]] = defaultdict(set)
tenants = {item.template.tenant_id for item in commands}
if tenants:
for tenant_id, slug in session.query(model.tenant_id, model.slug).filter(model.tenant_id.in_(tenants)).all():
used[str(tenant_id)].add(slug)
for tenant_id in tenants:
used[tenant_id]
return used
def _assignment_counts(session: Session, model, column, resource_ids: set[str]) -> dict[str, int]:
if not resource_ids:
return {}
return {
resource_id: count
for resource_id, count in session.query(column, func.count(model.id))
.filter(column.in_(resource_ids))
.group_by(column)
.all()
}
def _available_slug(used: set[str], base: str) -> str:
candidate = base
suffix = 2
while candidate in used:
candidate = f"{base}-{suffix}"
suffix += 1
used.add(candidate)
return candidate
def _outcome(
command: GovernanceProjectionCommand,
*,
status: str,
resource_id: str | None = None,
blocker_codes: tuple[str, ...] = (),
message: str | None = None,
) -> GovernanceProjectionOutcome:
template = command.template
return GovernanceProjectionOutcome(
assignment_id=command.assignment_id,
template_id=template.template_id,
tenant_id=template.tenant_id,
kind=template.kind,
operation=command.operation,
status=status, # type: ignore[arg-type]
resource_id=resource_id,
blocker_codes=blocker_codes,
message=message,
provenance=dict(command.provenance),
)
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise TypeError("Access governance materializer requires a SQLAlchemy Session")
return session
def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, resource_id: str) -> None:
registry = get_registry()
if registry is None or not hasattr(registry, "delete_veto_providers"):
return
for provider in registry.delete_veto_providers(resource_type):
try:
provider(session, tenant_id, resource_id)
except AdminConflictError:
raise
except Exception as exc:
raise AdminConflictError(str(exc)) from exc