"""Privacy-safe batch facts for the deterministic leaver-risk kernel.""" from __future__ import annotations from collections import defaultdict from dataclasses import asdict, dataclass from datetime import UTC, datetime from typing import Any from zoneinfo import ZoneInfo from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.offboarding.models import ( PROC_CANCELLED, RESO_PENDING, OffboardingBlocker, OffboardingProcess, ) from app.identity.person_models import IdentityPerson from app.intelligence.leaver_risk import LeaverRiskInput, build_leaver_risk from app.intelligence.privacy import EvidenceObject, assert_llm_safe, build_reference_map _APP_TZ = ZoneInfo("Asia/Shanghai") _EPOCH = datetime(1970, 1, 1, tzinfo=UTC) _KIND_BY_RESOURCE = { "hardware": "asset", "software_seat": "seat", "software_permission": "permission", "api_key": "api_key", "cloud_scope_owner": "cloud_scope", "cloud_server": "server", "domain": "domain", "dns_record": "domain", "email_group": "email_group", "data_handover": "data_handover", "cloud_iam_user": "cloud_scope", "cloud_access_grant": "permission", "sso_grant": "permission", "identity_consumer_owner": "account", "software_owner": "seat", "software_it_contact": "seat", "google_orphan_owner": "account", "identity_consumer": "account", "software_account": "account", "load_balancer": "server", "cdn_domain": "domain", "domain_registration": "domain", "google_orphan": "account", "vpn_application": "permission", "ai_tool_application": "seat", "system_owner": "system", } _HANDOVER_RESOURCE_TYPES = frozenset({ "domain", "dns_record", "cloud_server", "load_balancer", "cdn_domain", "software_owner", "software_it_contact", "email_group", "cloud_scope_owner", "domain_registration", "google_orphan_owner", "cloud_iam_user", "identity_consumer_owner", "sso_grant", "system_owner", }) @dataclass(frozen=True, slots=True) class LeaverRiskFactPackage: leavers: tuple[dict[str, Any], ...] summary: dict[str, int] excluded_counts: dict[str, int] reference_map: dict[str, Any] data_as_of: datetime | None def aggregate_facts(self) -> dict[str, Any]: payload = { "leavers": list(self.leavers), "summary": dict(sorted(self.summary.items())), "excluded_counts": dict(sorted(self.excluded_counts.items())), } assert_llm_safe(payload) return payload def _utc(value: datetime | None) -> datetime: if value is None: return _EPOCH return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) def _kind(resource_type: str) -> str | None: return _KIND_BY_RESOURCE.get(resource_type) async def collect_leaver_risk_facts( db: AsyncSession, *, now: datetime | None = None, ) -> LeaverRiskFactPackage: """Read current offboarding processes in two bounded queries. Blockers are the existing Leaver Blocked source of truth. Resolved, cancelled, and unknown resource rows never silently become a risk kind: unknown rows are counted as excluded so the snapshot's confidence can explain why the input is incomplete. """ observed_at = _utc(now or datetime.now(UTC)) today = observed_at.astimezone(_APP_TZ).date() processes = list((await db.execute(select(OffboardingProcess).where( OffboardingProcess.status != PROC_CANCELLED, ).order_by(OffboardingProcess.id))).scalars()) process_ids = [row.id for row in processes] blockers = list((await db.execute(select(OffboardingBlocker).where( OffboardingBlocker.process_id.in_(process_ids), ).order_by(OffboardingBlocker.process_id, OffboardingBlocker.id))).scalars()) if process_ids else [] blockers_by_process: defaultdict[Any, list[OffboardingBlocker]] = defaultdict(list) for blocker in blockers: blockers_by_process[blocker.process_id].append(blocker) successor_ids = { process.default_handover_person_id for process in processes if process.default_handover_person_id is not None } successors = list((await db.execute(select(IdentityPerson).where( IdentityPerson.id.in_(successor_ids), IdentityPerson.deleted_at.is_(None), IdentityPerson.employment_status == "active", ))).scalars()) if successor_ids else [] eligible_successor_ids = {person.id for person in successors} _refs, reference_map = build_reference_map( EvidenceObject("offboarding", str(process.id)) for process in processes ) reverse_refs = { str(value["object_id"]): ref for ref, value in reference_map.items() } excluded: defaultdict[str, int] = defaultdict(int) rows: list[dict[str, Any]] = [] results = [] for process in processes: counts: defaultdict[str, int] = defaultdict(int) pending = overdue = missing_handover = privileged = 0 for blocker in blockers_by_process[process.id]: kind = _kind(blocker.resource_type) if kind is None: excluded["unknown_resource_type"] += 1 continue counts[kind] += 1 if blocker.resolution == RESO_PENDING: pending += 1 overdue += process.leaver_date < today missing_handover += ( blocker.resource_type in _HANDOVER_RESOURCE_TYPES and blocker.resolution_ref is None ) privileged += blocker.resource_type in {"software_permission", "cloud_access_grant", "sso_grant"} value = LeaverRiskInput( resource_counts=tuple(sorted(counts.items())), pending_count=pending, overdue_count=overdue, missing_handover_count=missing_handover, privileged_count=privileged, successor_available=( process.status in {"ready", "completed"} or process.default_handover_person_id in eligible_successor_ids ), data_handover_complete=process.status == "completed", blocked=process.status == "blocked", ) result = build_leaver_risk(value) results.append(result) rows.append({"ref": reverse_refs[str(process.id)], **asdict(result)}) summary = { "leaver_count": len(results), "critical_count": sum(row.level == "critical" for row in results), "high_count": sum(row.level == "high" for row in results), "blocked_count": sum(row.blocked for row in results), "pending_count": sum(row.pending_count for row in results), "overdue_count": sum(row.overdue_count for row in results), "blast_radius": sum(row.blast_radius for row in results), "missing_handover_count": sum(row.missing_handover_count for row in results), "privileged_count": sum(row.privileged_count for row in results), } data_times = [_utc(row.updated_at) for row in processes] data_times.extend(_utc(row.updated_at) for row in blockers) return LeaverRiskFactPackage( leavers=tuple(rows), summary=summary, excluded_counts=dict(excluded), reference_map=reference_map, data_as_of=max(data_times, default=None), )