feat: reconcile governance assignments in bulk
This commit is contained in:
@@ -1,26 +1,186 @@
|
||||
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
|
||||
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, GovernanceTemplateMaterialization
|
||||
from govoplan_core.core.access import (
|
||||
AccessGovernanceMaterializer,
|
||||
AccessGovernanceProjectionV1,
|
||||
GovernanceProjectionBatch,
|
||||
GovernanceProjectionCommand,
|
||||
GovernanceProjectionOutcome,
|
||||
GovernanceProjectionResult,
|
||||
GovernanceTemplateMaterialization,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
|
||||
|
||||
class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
||||
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
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)
|
||||
if template.kind == "group":
|
||||
group = (
|
||||
db.query(Group)
|
||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
||||
.first()
|
||||
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=_available_slug(db, Group, template.tenant_id, template.slug),
|
||||
slug=slug,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
is_active=template.is_active,
|
||||
@@ -28,78 +188,199 @@ class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
||||
system_required=template.required,
|
||||
)
|
||||
db.add(group)
|
||||
else:
|
||||
group.name = template.name
|
||||
group.description = template.description
|
||||
group.system_required = template.required
|
||||
if template.required:
|
||||
group.is_active = template.is_active
|
||||
db.flush()
|
||||
return
|
||||
existing[key] = group
|
||||
return _outcome(command, status="created", resource_id=resource_id)
|
||||
|
||||
role = (
|
||||
db.query(Role)
|
||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
||||
.first()
|
||||
)
|
||||
if role is None:
|
||||
role = Role(
|
||||
tenant_id=template.tenant_id,
|
||||
slug=_available_slug(db, Role, template.tenant_id, template.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)
|
||||
else:
|
||||
role.name = template.name
|
||||
role.description = template.description
|
||||
role.permissions = list(template.permissions)
|
||||
role.system_required = template.required
|
||||
if template.required:
|
||||
role.is_assignable = template.is_active
|
||||
db.flush()
|
||||
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 remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
db = _session(session)
|
||||
if template.kind == "group":
|
||||
group = (
|
||||
db.query(Group)
|
||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
||||
.first()
|
||||
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 group is None:
|
||||
return
|
||||
membership_count = db.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count()
|
||||
role_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count()
|
||||
if membership_count or role_count:
|
||||
raise AdminConflictError(
|
||||
f"Cannot remove {template.name!r} from the tenant while its managed group has members or roles."
|
||||
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.",
|
||||
)
|
||||
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
|
||||
db.delete(group)
|
||||
db.flush()
|
||||
return
|
||||
if not dry_run:
|
||||
db.delete(role)
|
||||
return _outcome(command, status="removed", resource_id=role.id)
|
||||
|
||||
role = (
|
||||
db.query(Role)
|
||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
||||
.first()
|
||||
)
|
||||
if role is None:
|
||||
return
|
||||
user_count = db.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
|
||||
group_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
|
||||
if user_count or group_count:
|
||||
raise AdminConflictError(
|
||||
f"Cannot remove {template.name!r} from the tenant while its managed role is assigned to users or groups."
|
||||
)
|
||||
db.delete(role)
|
||||
db.flush()
|
||||
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:
|
||||
@@ -119,12 +400,3 @@ def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, res
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AdminConflictError(str(exc)) from exc
|
||||
|
||||
|
||||
def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str:
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first():
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
Reference in New Issue
Block a user