Files
govoplan-access/src/govoplan_access/backend/configuration_provider.py

377 lines
18 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import slugify
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role
from govoplan_core.core.configuration_packages import (
ConfigurationApplyResult,
ConfigurationDiagnostic,
ConfigurationExportResult,
ConfigurationExportSelection,
ConfigurationPackageFragment,
ConfigurationPlanItem,
ConfigurationPreflightContext,
ConfigurationPreflightResult,
ConfigurationProvider,
ConfigurationProviderDescription,
)
from govoplan_core.db.session import get_database
ACCESS_CONFIGURATION_CAPABILITY = "access.configuration"
class SqlAccessConfigurationProvider(ConfigurationProvider):
module_id = "access"
def describe(self) -> ConfigurationProviderDescription:
return ConfigurationProviderDescription(
module_id=self.module_id,
fragment_types=("roles", "groups", "group_role_assignments"),
schema_refs={
"roles": "govoplan/access/configuration/roles.v1",
"groups": "govoplan/access/configuration/groups.v1",
"group_role_assignments": "govoplan/access/configuration/group-role-assignments.v1",
},
exported_scopes=("system", "tenant"),
)
def preflight(self, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
with get_database().session() as session:
return _preflight_fragment(session, fragment, context)
def apply(self, fragment: ConfigurationPackageFragment, supplied_data: Mapping[str, Any], context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
del supplied_data
with get_database().session() as session:
result = _apply_fragment(session, fragment, context)
if not any(item.severity == "blocker" for item in result.diagnostics):
session.commit()
return result
def export(self, selection: ConfigurationExportSelection, context: ConfigurationPreflightContext) -> ConfigurationExportResult:
del context
with get_database().session() as session:
return _export_access_configuration(session, selection)
def health(self, import_result: ConfigurationApplyResult, context: ConfigurationPreflightContext) -> tuple[ConfigurationDiagnostic, ...]:
del context
if import_result.diagnostics:
return tuple(item for item in import_result.diagnostics if item.severity == "blocker")
return ()
def _preflight_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
if fragment.fragment_type == "roles":
return _preflight_roles(session, fragment, context)
if fragment.fragment_type == "groups":
return _preflight_groups(session, fragment, context)
if fragment.fragment_type == "group_role_assignments":
return _preflight_group_role_assignments(session, fragment, context)
return ConfigurationPreflightResult(diagnostics=(_unsupported(fragment),))
def _apply_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
if fragment.fragment_type == "roles":
return _apply_roles(session, fragment, context)
if fragment.fragment_type == "groups":
return _apply_groups(session, fragment, context)
if fragment.fragment_type == "group_role_assignments":
return _apply_group_role_assignments(session, fragment, context)
return ConfigurationApplyResult(diagnostics=(_unsupported(fragment),))
def _preflight_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
diagnostics: list[ConfigurationDiagnostic] = []
plan: list[ConfigurationPlanItem] = []
for item in _payload_items(fragment):
slug = slugify(_required(item, "slug"))
level = str(item.get("level") or "tenant").strip().casefold()
tenant_id = _tenant_id(context, item, level=level)
if level == "tenant" and tenant_id is None:
diagnostics.append(_tenant_required(fragment, slug))
plan.append(_plan("blocked", fragment, slug, "Tenant role needs a tenant_id."))
continue
existing = _role_by_slug(session, slug, tenant_id)
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} role {slug}."))
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
def _apply_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
diagnostics: list[ConfigurationDiagnostic] = []
created: dict[str, str] = {}
updated: dict[str, str] = {}
for item in _payload_items(fragment):
slug = slugify(_required(item, "slug"))
level = str(item.get("level") or "tenant").strip().casefold()
tenant_id = _tenant_id(context, item, level=level)
if level == "tenant" and tenant_id is None:
diagnostics.append(_tenant_required(fragment, slug))
continue
role = _role_by_slug(session, slug, tenant_id)
target = updated if role else created
if role is None:
role = Role(tenant_id=tenant_id, slug=slug, name=_required(item, "name"), permissions=[])
session.add(role)
session.flush()
role.name = str(item.get("name") or role.name).strip()
role.description = _optional(item, "description")
role.permissions = _string_list(item.get("permissions"))
role.is_assignable = _bool(item.get("is_assignable"), default=True)
role.system_required = _bool(item.get("required"), default=role.system_required)
target[slug] = f"role:{role.id}"
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
def _preflight_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
diagnostics: list[ConfigurationDiagnostic] = []
plan: list[ConfigurationPlanItem] = []
for item in _payload_items(fragment):
slug = slugify(_required(item, "slug"))
tenant_id = _tenant_id(context, item, level="tenant")
if tenant_id is None:
diagnostics.append(_tenant_required(fragment, slug))
plan.append(_plan("blocked", fragment, slug, "Group needs a tenant_id."))
continue
existing = _group_by_slug(session, slug, tenant_id)
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} group {slug}."))
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
def _apply_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
diagnostics: list[ConfigurationDiagnostic] = []
created: dict[str, str] = {}
updated: dict[str, str] = {}
for item in _payload_items(fragment):
slug = slugify(_required(item, "slug"))
tenant_id = _tenant_id(context, item, level="tenant")
if tenant_id is None:
diagnostics.append(_tenant_required(fragment, slug))
continue
group = _group_by_slug(session, slug, tenant_id)
target = updated if group else created
if group is None:
group = Group(tenant_id=tenant_id, slug=slug, name=_required(item, "name"))
session.add(group)
session.flush()
group.name = str(item.get("name") or group.name).strip()
group.description = _optional(item, "description")
group.is_active = _bool(item.get("is_active"), default=True)
group.system_required = _bool(item.get("required"), default=group.system_required)
target[slug] = f"group:{group.id}"
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
def _preflight_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
diagnostics: list[ConfigurationDiagnostic] = []
plan: list[ConfigurationPlanItem] = []
for item in _payload_items(fragment):
tenant_id = _tenant_id(context, item, level="tenant")
group_slug = slugify(_required(item, "group"))
role_slug = slugify(_required(item, "role"))
if tenant_id is None:
diagnostics.append(_tenant_required(fragment, f"{group_slug}:{role_slug}"))
plan.append(_plan("blocked", fragment, f"{group_slug}:{role_slug}", "Group-role assignment needs a tenant_id."))
continue
group = _group_by_slug(session, group_slug, tenant_id)
role = _role_by_slug(session, role_slug, tenant_id)
if group is None or role is None:
missing = ", ".join(ref for ref, exists in ((f"group:{group_slug}", group is not None), (f"role:{role_slug}", role is not None)) if not exists)
diagnostics.append(ConfigurationDiagnostic(
severity="info",
code="access_reference_pending",
message=f"Access assignment references are not present yet and may be created by earlier package fragments: {missing}.",
module_id=fragment.module_id,
object_ref=f"{group_slug}:{role_slug}",
resolution="Keep role and group fragments before assignment fragments in the package.",
))
plan.append(_plan("bind", fragment, f"{group_slug}:{role_slug}", f"Bind {group_slug} to {role_slug} after referenced objects exist."))
continue
exists = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).count()
plan.append(_plan("skip" if exists else "bind", fragment, f"{group_slug}:{role_slug}", f"{'Keep' if exists else 'Bind'} {group_slug} to {role_slug}."))
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
def _apply_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
diagnostics: list[ConfigurationDiagnostic] = []
created: dict[str, str] = {}
for item in _payload_items(fragment):
tenant_id = _tenant_id(context, item, level="tenant")
group_slug = slugify(_required(item, "group"))
role_slug = slugify(_required(item, "role"))
object_ref = f"{group_slug}:{role_slug}"
if tenant_id is None:
diagnostics.append(_tenant_required(fragment, object_ref))
continue
group = _group_by_slug(session, group_slug, tenant_id)
role = _role_by_slug(session, role_slug, tenant_id)
if group is None:
diagnostics.append(_missing_ref(fragment, f"group:{group_slug}"))
if role is None:
diagnostics.append(_missing_ref(fragment, f"role:{role_slug}"))
if group is None or role is None:
continue
assignment = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).one_or_none()
if assignment is None:
assignment = GroupRoleAssignment(tenant_id=tenant_id, group_id=group.id, role_id=role.id)
session.add(assignment)
session.flush()
created[object_ref] = f"group_role_assignment:{assignment.id}"
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created)
def _export_access_configuration(session: Session, selection: ConfigurationExportSelection) -> ConfigurationExportResult:
tenant_id = selection.tenant_id
diagnostics: list[ConfigurationDiagnostic] = []
fragments: list[ConfigurationPackageFragment] = []
if tenant_id is None and "system" not in selection.scopes:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="tenant_required",
message="Access configuration export needs selection.tenant_id unless exporting system scope.",
module_id="access",
resolution="Choose a tenant before exporting tenant roles and groups.",
))
return ConfigurationExportResult(diagnostics=tuple(diagnostics))
if tenant_id is not None:
roles = session.query(Role).filter(Role.tenant_id == tenant_id).order_by(Role.slug.asc()).all()
groups = session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.slug.asc()).all()
assignments = (
session.query(GroupRoleAssignment, Group, Role)
.join(Group, Group.id == GroupRoleAssignment.group_id)
.join(Role, Role.id == GroupRoleAssignment.role_id)
.filter(GroupRoleAssignment.tenant_id == tenant_id)
.order_by(Group.slug.asc(), Role.slug.asc())
.all()
)
fragments.extend((
ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role) for role in roles]}),
ConfigurationPackageFragment(module_id="access", fragment_type="groups", payload={"items": [_group_payload(group) for group in groups]}),
ConfigurationPackageFragment(module_id="access", fragment_type="group_role_assignments", payload={"items": [{"group": group.slug, "role": role.slug} for _assignment, group, role in assignments]}),
))
if "system" in selection.scopes:
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.slug.asc()).all()
fragments.append(ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role, level="system") for role in system_roles]}))
return ConfigurationExportResult(fragments=tuple(fragments), diagnostics=tuple(diagnostics))
def _payload_items(fragment: ConfigurationPackageFragment) -> list[Mapping[str, Any]]:
raw_items = fragment.payload.get("items")
if raw_items is None:
raw_items = fragment.payload.get(fragment.fragment_type)
if raw_items is None and fragment.payload:
raw_items = [fragment.payload]
if not isinstance(raw_items, list):
raise ValueError(f"Access configuration fragment {fragment.fragment_type!r} requires an items list.")
return [item for item in raw_items if isinstance(item, Mapping)]
def _tenant_id(context: ConfigurationPreflightContext, item: Mapping[str, Any], *, level: str) -> str | None:
if level == "system":
return None
value = item.get("tenant_id") or context.tenant_id
return str(value).strip() if value is not None and str(value).strip() else None
def _role_by_slug(session: Session, slug: str, tenant_id: str | None) -> Role | None:
query = session.query(Role).filter(Role.slug == slug)
query = query.filter(Role.tenant_id.is_(None)) if tenant_id is None else query.filter(Role.tenant_id == tenant_id)
return query.one_or_none()
def _group_by_slug(session: Session, slug: str, tenant_id: str) -> Group | None:
return session.query(Group).filter(Group.tenant_id == tenant_id, Group.slug == slug).one_or_none()
def _role_payload(role: Role, *, level: str = "tenant") -> dict[str, object]:
return {
"slug": role.slug,
"name": role.name,
"description": role.description,
"permissions": list(role.permissions or []),
"level": "system" if role.tenant_id is None else level,
"is_assignable": role.is_assignable,
"required": role.system_required,
}
def _group_payload(group: Group) -> dict[str, object]:
return {
"slug": group.slug,
"name": group.name,
"description": group.description,
"is_active": group.is_active,
"required": group.system_required,
}
def _required(item: Mapping[str, Any], key: str) -> str:
value = item.get(key)
if value is None or not str(value).strip():
raise ValueError(f"Access configuration item requires {key!r}.")
return str(value).strip()
def _optional(item: Mapping[str, Any], key: str) -> str | None:
value = item.get(key)
if value is None:
return None
text = str(value).strip()
return text or None
def _string_list(value: object) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("Access configuration permissions must be a list.")
return [str(item).strip() for item in value if str(item).strip()]
def _bool(value: object, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
def _plan(action: str, fragment: ConfigurationPackageFragment, object_ref: str, summary: str) -> ConfigurationPlanItem:
return ConfigurationPlanItem(action=action, module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=object_ref, summary=summary) # type: ignore[arg-type]
def _tenant_required(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="tenant_required",
message="Access tenant configuration requires a tenant_id.",
module_id=fragment.module_id,
object_ref=object_ref,
resolution="Run the package for a selected tenant or set tenant_id on the fragment item.",
)
def _missing_ref(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="access_reference_missing",
message=f"Access configuration references missing object {object_ref!r}.",
module_id=fragment.module_id,
object_ref=object_ref,
resolution="Create referenced groups and roles earlier in the package plan.",
)
def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="fragment_type_unsupported",
message=f"Access configuration does not support fragment type {fragment.fragment_type!r}.",
module_id=fragment.module_id,
object_ref=fragment.fragment_id or fragment.fragment_type,
)