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
|
||||
|
||||
@@ -12,6 +12,7 @@ from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_EXPLANATION,
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
@@ -474,6 +475,44 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.operator.governance-projection",
|
||||
title="Reconcile managed groups and roles in bulk",
|
||||
summary="Access projects centrally assigned governance templates into tenant groups and roles through one bounded, versioned reconciliation contract.",
|
||||
body=(
|
||||
"Admin supplies stable template and tenant-assignment inputs to the Access governance projection v1 capability. Access bulk-loads managed groups, roles, memberships, and role mappings, then returns one created, updated, unchanged, removed, absent, blocked, or failed outcome per assignment. Repeating the same request is idempotent. A removal remains blocked while the managed resource has members or role assignments, and protected or unrelated tenant resources are never adopted. Dry runs calculate the same outcomes without changing Access data; applied and preview runs retain source and assignment-mode provenance in the Admin audit event."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("system_admin", "operator"),
|
||||
order=31,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access", "admin"),
|
||||
any_scopes=("access:governance:read", "access:governance:write"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Governance templates", href="/admin?section=system-role-templates", kind="runtime"),
|
||||
DocumentationLink(label="Bulk synchronization API", href="/api/v1/admin/system/governance-templates/synchronize", kind="api"),
|
||||
DocumentationLink(label="Access module boundary", href="docs/ACCESS_MODULE_BOUNDARY.md", kind="repository"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "operator_workflow",
|
||||
"help_contexts": ["admin.governance-templates"],
|
||||
"capability": "access.governanceProjection.v1",
|
||||
"batch_limit": 500,
|
||||
"outcome_statuses": [
|
||||
"created",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"removed",
|
||||
"absent",
|
||||
"blocked",
|
||||
"failed",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.admin-access-fields",
|
||||
title="Access administration fields",
|
||||
@@ -1180,6 +1219,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER: _first_admin_provisioner,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1: _governance_materializer,
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search,
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
|
||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
|
||||
Reference in New Issue
Block a user