403 lines
14 KiB
Python
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
|