"""Department → platform-role auto-derivation. User direction 2026-06-17: a person who belongs to the **Finance** BU should automatically hold the ``finance`` role; a person in **HR** should hold ``hr``. The grant must be automatic (no admin clicks) and must backfill people who lack it today. This module is the single source of truth for that derivation. It mirrors the proven ``location_admin_sync`` pattern exactly: * :func:`sync_department_roles_for_person` — idempotent per-person sync, callable on any mutation that changes a person's org membership (HRIS sync apply). * :func:`reconcile_department_roles` — full reconciliation, run daily by the ``identity.department_role_reconcile`` cron as a backstop and used for the one-time backfill. **Provenance is the safety rail.** finance/hr can also be granted manually (the RBAC page). The reconciler only ever touches grants it created itself (``auth_user_roles.grant_source = 'derived'``): it grants ``derived`` to matching people who hold neither a manual nor a derived grant, and revokes ``derived`` grants from people who no longer match. A manual grant is never inserted-over nor revoked here — an admin's explicit decision always wins. **Matching.** A person matches a role if any segment of their primary org membership's ``org_path`` (tenant root stripped — see ``IdentityPerson.department_chain`` / ``bu_helpers``) equals, case- insensitively, a configured department name. The mapping defaults to Finance/HR (English + Chinese aliases) and is overridable without a redeploy via the ``identity.department_role_map`` platform setting. """ from __future__ import annotations import logging import uuid from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthRole, AuthUserRole from app.core.rbac import FINANCE_ROLE, HR_ROLE from app.identity.person_models import IdentityOrgMembership, IdentityPerson logger = logging.getLogger(__name__) # Department-name (lower-cased, tenant-root-stripped org_path segment) → # canonical role. Covers the obvious English + Chinese spellings the # SuccessFactors org tree is known to use. Admins extend / override this # at runtime via the ``identity.department_role_map`` platform setting # (a JSON object {"": ""}) — values are merged on # top of these defaults, never replacing them, so a typo'd override # can't silently disable Finance/HR derivation. DEFAULT_DEPARTMENT_ROLE_MAP: dict[str, str] = { "finance": FINANCE_ROLE, "财务": FINANCE_ROLE, "财务部": FINANCE_ROLE, "hr": HR_ROLE, "human resources": HR_ROLE, "人力资源": HR_ROLE, "人力资源部": HR_ROLE, } _SETTING_KEY = "identity.department_role_map" # Tenant-root labels stripped from ``org_path`` before matching — mirrors # ``IdentityPerson.department_chain`` and ``bu_helpers._TENANT_ROOTS``. _TENANT_ROOTS = {"pingcap", "pingcap-ati"} # The full set of roles this module owns. Only ``grant_source='derived'`` # rows for THESE roles are ever revoked by the reconciler. _DERIVABLE_ROLES = frozenset({FINANCE_ROLE, HR_ROLE}) def _path_segments(org_path: str | None, fallback_name: str | None) -> list[str]: """Lower-cased, tenant-root-stripped segments of an ``org_path``.""" raw = (org_path or "").strip() parts = [p.strip() for p in raw.split("/") if p.strip()] if not parts and fallback_name: parts = [fallback_name.strip()] if parts and parts[0].lower() in _TENANT_ROOTS: parts = parts[1:] return [p.lower() for p in parts if p] def _roles_for_segments( segments: list[str], role_map: dict[str, str], ) -> set[str]: """Roles a person with these org-path segments should hold.""" out: set[str] = set() for seg in segments: role = role_map.get(seg) if role in _DERIVABLE_ROLES: out.add(role) return out async def load_department_role_map(db: AsyncSession) -> dict[str, str]: """Resolve the effective department→role map (defaults + override). The platform-setting override is merged ON TOP of the defaults and only entries whose value is a known derivable role survive — a bad override row can extend the map but cannot break Finance/HR. """ from app.integrations.models import PlatformSetting # noqa: PLC0415 role_map = dict(DEFAULT_DEPARTMENT_ROLE_MAP) row = await db.get(PlatformSetting, _SETTING_KEY) if row is not None and isinstance(row.value, dict): for dept, role in row.value.items(): if not isinstance(dept, str) or not isinstance(role, str): continue if role in _DERIVABLE_ROLES: role_map[dept.strip().lower()] = role return role_map async def _role_ids_by_name(db: AsyncSession) -> dict[str, uuid.UUID]: """``{role_name: role_id}`` for the derivable roles present in DB.""" rows = await db.execute( select(AuthRole.name, AuthRole.id).where( AuthRole.name.in_(tuple(_DERIVABLE_ROLES)) ) ) return {name: rid for name, rid in rows.all()} async def _desired_roles_for_person( db: AsyncSession, person_id: uuid.UUID, role_map: dict[str, str], ) -> set[str]: """The derivable roles an active person should hold from their dept. Empty for departed / deleted / membership-less people, so the reconciler revokes their derived grants (departures handled here in addition to the leaver-role cleanup).""" person = await db.get(IdentityPerson, person_id) if person is None or person.deleted_at is not None: return set() if (person.employment_status or "active") != "active": return set() membership = (await db.execute( select(IdentityOrgMembership) .where( IdentityOrgMembership.person_id == person_id, IdentityOrgMembership.is_primary.is_(True), IdentityOrgMembership.deleted_at.is_(None), ) .limit(1) )).scalar_one_or_none() if membership is None: return set() segments = _path_segments(membership.org_path, membership.org_node_name) return _roles_for_segments(segments, role_map) async def sync_department_roles_for_person( db: AsyncSession, person_id: uuid.UUID, ) -> None: """Idempotently align a single person's derived finance/hr grants. Caller commits. Grants a ``derived`` row for each role the person should hold but doesn't (manual grants count — never double-insert); deletes ``derived`` rows for derivable roles the person should no longer hold. Manual grants are never touched. """ role_ids = await _role_ids_by_name(db) if not role_ids: return # roles not seeded yet; nothing to do desired = await _desired_roles_for_person( db, person_id, await load_department_role_map(db), ) existing = (await db.execute( select(AuthRole.name, AuthUserRole.grant_source) .join(AuthRole, AuthRole.id == AuthUserRole.role_id) .where( AuthUserRole.user_id == person_id, AuthRole.name.in_(tuple(_DERIVABLE_ROLES)), ) )).all() held = {name for name, _src in existing} derived_held = {name for name, src in existing if src == "derived"} # Grant what's missing (only if not already held by ANY source). for role_name in desired - held: db.add(AuthUserRole( user_id=person_id, role_id=role_ids[role_name], grant_source="derived", )) # Revoke derived grants no longer warranted (never manual ones). stale = derived_held - desired if stale: # ORM delete so the access-cycle ledger listener closes each # holding interval — a Core-level DELETE bypasses the unit of # work and would leave the person looking like a permanent # finance/hr role holder in every point-in-time query. stale_rows = (await db.execute( select(AuthUserRole).where( AuthUserRole.user_id == person_id, AuthUserRole.role_id.in_([role_ids[r] for r in stale]), AuthUserRole.grant_source == "derived", ) )).scalars().all() for row in stale_rows: await db.delete(row) async def reconcile_department_roles(db: AsyncSession) -> dict[str, Any]: """Full reconciliation of derived finance/hr grants for everyone. Backstop for the in-process per-person sync and the one-time backfill engine. Returns counters per role plus totals. Caller commits. """ role_ids = await _role_ids_by_name(db) missing_roles = sorted(_DERIVABLE_ROLES - set(role_ids)) if missing_roles: # Mirror the asset reconciler: surface a missing role row so the # handler can raise and page admins instead of silently no-op'ing. return {"role_missing": missing_roles, "granted": 0, "revoked": 0} role_map = await load_department_role_map(db) # 1) Compute the desired holder set per role from active people's # primary org membership in one pass. rows = (await db.execute( select( IdentityOrgMembership.person_id, IdentityOrgMembership.org_path, IdentityOrgMembership.org_node_name, ) .join(IdentityPerson, IdentityPerson.id == IdentityOrgMembership.person_id) .where( IdentityOrgMembership.is_primary.is_(True), IdentityOrgMembership.deleted_at.is_(None), IdentityPerson.deleted_at.is_(None), (IdentityPerson.employment_status == "active") | (IdentityPerson.employment_status.is_(None)), ) )).all() desired: dict[str, set[uuid.UUID]] = {r: set() for r in _DERIVABLE_ROLES} for person_id, org_path, leaf in rows: for role in _roles_for_segments(_path_segments(org_path, leaf), role_map): desired[role].add(person_id) summary: dict[str, Any] = {"granted": 0, "revoked": 0, "by_role": {}} for role_name in sorted(_DERIVABLE_ROLES): role_id = role_ids[role_name] should = desired[role_name] # Who currently holds this role, and how was it granted. current = (await db.execute( select(AuthUserRole.user_id, AuthUserRole.grant_source) .where(AuthUserRole.role_id == role_id) )).all() held_any = {uid for uid, _src in current} held_derived = {uid for uid, src in current if src == "derived"} to_grant = should - held_any # never double-insert over manual to_revoke = held_derived - should # never revoke manual if to_revoke: # Load-then-ORM-delete: the access-cycle ledger listener only # sees unit-of-work deletes. Bounded by the derived holder # count for one role (finance / hr — tens of people). stale_rows = (await db.execute( select(AuthUserRole).where( AuthUserRole.role_id == role_id, AuthUserRole.user_id.in_(to_revoke), AuthUserRole.grant_source == "derived", ) )).scalars().all() for row in stale_rows: await db.delete(row) for uid in to_grant: db.add(AuthUserRole( user_id=uid, role_id=role_id, grant_source="derived", )) summary["granted"] += len(to_grant) summary["revoked"] += len(to_revoke) summary["by_role"][role_name] = { "expected": len(should), "granted": len(to_grant), "revoked": len(to_revoke), } return summary __all__ = [ "DEFAULT_DEPARTMENT_ROLE_MAP", "load_department_role_map", "reconcile_department_roles", "sync_department_roles_for_person", ]