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, )