"""Offboarding service. High-level flow, driven by HRIS offboard events and admin actions: 1. HRIS confirm_batch sees an `offboard` event → calls `start_process(person, leaver_date)`. A new OffboardingProcess is created and immediately populates blockers by scanning every resource the person currently holds. 2. Admin (or leaver) resolves each blocker. Each blocker has a resolution path: - 'transferred' — an AssetTransferRequest was accepted. The underlying resource ownership has been handed off; blocker auto-marked resolved. - 'revoked_auto' — a provisioning task was created and executed to revoke the resource at the external system. - 'revoked_manual' — admin directly marked resolved (no external call, e.g. physical device returned to IT). - 'skipped' — blocker ignored (out of scope / already handled). 3. Once every blocker is resolved (no pending), the process auto-transitions to 'ready'. An admin then runs `suspend_sso(process)` (on or after the leaver_date). Feishu + Google user-level connectors are fired. 4. `complete(process)` finalises the record. This service does NOT auto-execute provisioning tasks — that stays with ProvisioningService.execute_task / bulk_execute so admins have full control. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from datetime import date as date_type from datetime import time as _time from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 from sqlalchemy import and_, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.core.exceptions import ( AppException, NotFoundError, format_exception_for_log, is_transaction_aborting_db_error, ) from app.core.safe_logging import get_safe_logger from app.core.txn import best_effort from app.core.working_hours import resolve_timezone from app.domains.apikey.models import ApiKeyAssignment from app.domains.asset.models import ( AssetItem, AssetSoftwareCatalog, SoftwareLicensePlan, SoftwareSeatAllocation, ) from app.domains.offboarding.models import ( POLICY_AUTO_REVOKE, POLICY_IDENTITY_SYNC_MANAGED, POLICY_OWNER_NOTIFY_MANUAL, POLICY_SSO_CASCADE, PROC_BLOCKED, PROC_CANCELLED, PROC_COMPLETED, PROC_READY, PROC_SCANNING, RES_API_KEY, RES_CDN_DOMAIN, RES_CLOUD_SERVER, RES_DNS_RECORD, RES_DOMAIN, RES_EMAIL_GROUP, RES_HARDWARE, RES_LOAD_BALANCER, RES_SOFTWARE_ACCOUNT, RES_SOFTWARE_IT_CONTACT, RES_SOFTWARE_OWNER, RES_SOFTWARE_PERMISSION, RES_SOFTWARE_SEAT, RESO_IDENTITY_SYNC_MANAGED, RESO_PENDING, RESO_REVOKED_AUTO, RESO_REVOKED_MANUAL, RESO_SKIPPED, RESO_SSO_CASCADE, RESO_TRANSFERRED, SEVERITY_ADVISORY, SEVERITY_BLOCKER, XFER_CANCELLED, XFER_COMPLETED, XFER_PENDING, XFER_REJECTED, AssetTransferRequest, OffboardingBlocker, OffboardingProcess, compute_triage_bucket, ) from app.domains.offboarding.schemas import ( AssetTransferResponse, OffboardingBlockerResponse, OffboardingProcessResponse, ) from app.domains.provisioning.models import ( ACTION_REVOKE, SOURCE_OFFBOARDING, ProvisioningTask, ) from app.domains.provisioning.models import ( STATUS_CANCELLED as TASK_CANCELLED, ) from app.domains.provisioning.models import ( STATUS_COMPLETED as TASK_COMPLETED, ) from app.domains.provisioning.models import ( STATUS_FAILED as TASK_FAILED, ) from app.domains.provisioning.models import ( STATUS_IN_PROGRESS as TASK_IN_PROGRESS, ) from app.domains.provisioning.models import ( STATUS_NOTIFIED as TASK_NOTIFIED, ) from app.domains.provisioning.models import ( STATUS_PENDING as TASK_PENDING, ) from app.domains.provisioning.schemas import ProvisioningTaskCreate from app.domains.provisioning.service import ProvisioningService from app.identity.person_models import IdentityPerson if TYPE_CHECKING: from app.notifications.models import NotificationOutbox from app.notifications.service import LocalizedText, NotificationEnvelope logger = get_safe_logger(__name__) # Canonical set of ApiKeyAssignment statuses for which a transfer is # meaningful — the key is genuinely held by the initiator AND # ApiKeyService.apply_transfer will actually re-point it. MUST stay in # sync with the accept-time guard (_initiator_still_holds_asset) and # ApiKeyService.apply_transfer. Notably EXCLUDES "approved" (assignment # approved but no key value filled yet — not transferable): allowing it # at create time produced transfers that were permanently un-acceptable # (every accept 409'd ASSET_NO_LONGER_HELD_BY_INITIATOR), stranding the # linked offboarding blocker. _API_KEY_TRANSFERABLE_STATUSES = ("active", "expired", "pending_release") # Default gap between the leaver's last working day and the terminal Google # account DELETE (2026-07-08 ADR「离职号最终删除」). Suspend fires at # leaver_date + 1 local day; delete fires at leaver_date + this many local # days. 2 days leaves a deliberate suspend→delete gap, and Google's ~20-day # post-delete recovery window (``users.undelete``) is the mistake safety net. # Overridable per-deploy via the scheduled job's ``config.delete_lead_days``. DEFAULT_GOOGLE_DELETE_LEAD_DAYS = 2 # Default gap between the leaver's last working day and the terminal Zoom # account DELETE (2026-08-04 — Zoom two-stage lifecycle, parity with Google). # Deactivate fires at leaver_date + 1 local day; delete fires at leaver_date + # this many local days. ⚠️ Unlike Google, Zoom's ``action=delete`` is PERMANENT # — there is NO ``users.undelete`` recovery window — so this suspend→delete gap # is the ONLY mistake window. Kept at 2 for Google parity per the product # decision; overridable per-deploy via the scheduled job's # ``config.delete_lead_days``. DEFAULT_ZOOM_DELETE_LEAD_DAYS = 2 # Feishu terminal-delete deferral (2026-07-16). A leaver's in-flight Feishu # approval instance can park on the SUBMITTER'S OWN node — the 回购 template's # second step (上传恢复出厂设置视频 / 转账成功截图) is exactly that. Deleting # the submitter's Feishu account strands that node TERMINALLY: nobody can # operate it natively (user gone), and the Helix-side supplement fallback # (asset supplement-uploads endpoint) needs the requester's Feishu user to # EXIST — it resolves their user_id by email and approves the task in their # name. Recovery would mean tenant recycle-bin restore. So while the leaver # has open self-submitted instances the terminal delete is DEFERRED (the # account is FROZEN instead — access still cut, node still operable) and # retried daily by ``sweep_deferred_feishu_account_deletions``. After this # many local days past leaver_date a still-open instance is treated as # abandoned and the delete proceeds anyway (audited with the override) so a # forever-pending request can't park the account out of the recycle bin # indefinitely. Overridable via the scheduled job's ``config.defer_max_days``. FEISHU_DELETE_DEFER_MAX_DAYS = 30 # ``session_enforcement.feishu_account`` status while the delete is deferred. # Deliberately NOT in ``_SESSION_CHANNEL_SUCCESS`` — a later re-run (sweep / # manual reenforce) must be allowed to overwrite it with the real outcome. FEISHU_DELETE_DEFERRED = "delete_deferred_open_approvals" def _leaver_revoke_not_before( person: IdentityPerson | None, leaver_date: date_type, ) -> datetime: """UTC moment at which an offboarding auto_revoke task becomes eligible for dispatch. Semantics: revoke fires AFTER the leaver's last working day ends in their wall-clock zone — i.e. the first moment of ``leaver_date + 1`` in their local TZ. A leaver in Asia/Shanghai with ``leaver_date=2026-06-29`` gets ``not_before = 2026-06-29 16:00 UTC`` (= 2026-06-30 00:00 +08:00). A leaver in America/Los_Angeles with the same nominal date gets the UTC instant that maps to 2026-06-30 00:00 -07:00 — about 7 hours later. Mirrors the local-TZ refinement in ``app.hr_lifecycle.advance_service.run_tick`` (which uses ``local_today > termination_date`` for force-transfer eligibility). ``person`` may be None when the lookup raced a hard-delete — fall back to UTC (HQ default for legacy data without a country field is already Asia/Shanghai per ``resolve_timezone``, but a missing person forces us into the safest default). The returned datetime is timezone-aware UTC — directly comparable to ``datetime.now(UTC)`` and to ``provisioning_tasks.not_before``. """ next_day = leaver_date + timedelta(days=1) if person is None: return datetime.combine(next_day, _time(0, 0), tzinfo=UTC) try: tz = resolve_timezone(person) except Exception: # noqa: BLE001 — TZ resolution must never break offboarding return datetime.combine(next_day, _time(0, 0), tzinfo=UTC) local_midnight = datetime.combine(next_day, _time(0, 0), tzinfo=tz) return local_midnight.astimezone(UTC) def _local_today_for_person(person: IdentityPerson | None) -> date_type: """Today's date in ``person``'s local TZ. Used by guards that must answer "has the leaver's last working day ended in their wall-clock?" — comparing UTC date alone misjudges UTC-12 / UTC+14 leavers by up to a full calendar day. Fallback to UTC date when person/TZ resolution fails — same conservative shape as ``_leaver_revoke_not_before`` above. """ now_utc = datetime.now(UTC) if person is None: return now_utc.date() try: tz = resolve_timezone(person) except Exception: # noqa: BLE001 return now_utc.date() return now_utc.astimezone(tz).date() # Per-channel ``session_enforcement`` outcomes that represent a genuine # success or a benign permanent no-op. A later transient RETRY failure must # never downgrade one of these in the forensic ledger — signOut / suspend are # idempotent, so a recorded success still holds. (Used by the merge in # ``_push_force_logout`` to honour the "合并不覆盖已成功" contract that a plain # ``dict.update`` silently violated by replacing each channel wholesale.) _SESSION_CHANNEL_SUCCESS = frozenset( # feishu_account terminal successes (deleted → recycle bin; frozen_fallback # = delete refused but access cut) MUST be here so a later failed re-run # (e.g. a transient token error on reenforce) can't clobber a recorded # success into "failed" and re-alert IT for an account that is already gone. { "ok", "already_absent", "suspended", "retained", "no_public_api", "deleted", "frozen_fallback", } ) def _is_session_channel_success(status: object) -> bool: """True if a per-channel session_enforcement status is a success/no-op that a subsequent failed retry must not clobber.""" return isinstance(status, str) and status in _SESSION_CHANNEL_SUCCESS # Round-14 severity classification. Resource types listed here have a # known auto-handler in ProcessAdvanceService._handle_one_blocker - at # T+0+24h the platform either revokes the access or force-transfers # ownership up the manager cascade. They surface as "advisory" in the # admin UI so IT only sees real exceptions in the red banner. # # Anything NOT in this map (or scanned with an unknown resource_type) # falls back to ``SEVERITY_BLOCKER`` so a freshly added scanner branch # without an explicit map entry surfaces in the IT queue rather than # silently slipping past - fail-closed by design. Migration 136 backfills # existing rows using the same list; the two MUST stay in sync. _SEVERITY_BY_RESOURCE: dict[str, str] = { # Auto-revoke at T+0+24h via provisioning task / connector. "software_seat": SEVERITY_ADVISORY, "software_permission": SEVERITY_ADVISORY, # Full Jira access revoke (remove from all groups) — driver.revoke_all_access at T+0+24h. "software_account": SEVERITY_ADVISORY, "cloud_access_grant": SEVERITY_ADVISORY, "vpn_application": SEVERITY_ADVISORY, "ai_tool_application": SEVERITY_ADVISORY, # Force-transfer to manager fallback at T+0+24h. "hardware": SEVERITY_ADVISORY, "api_key": SEVERITY_ADVISORY, "software_owner": SEVERITY_ADVISORY, "software_it_contact": SEVERITY_ADVISORY, "email_group": SEVERITY_ADVISORY, "domain": SEVERITY_ADVISORY, "dns_record": SEVERITY_ADVISORY, "cloud_server": SEVERITY_ADVISORY, "load_balancer": SEVERITY_ADVISORY, "cdn_domain": SEVERITY_ADVISORY, "cloud_scope_owner": SEVERITY_ADVISORY, "cloud_iam_user": SEVERITY_ADVISORY, "sso_grant": SEVERITY_ADVISORY, "domain_registration": SEVERITY_ADVISORY, "google_orphan_owner": SEVERITY_ADVISORY, # Force-transfer to the registry's backup owner, else the manager # fallback, at T+0+24h. "system_owner": SEVERITY_ADVISORY, # Same shape, one step lighter: an approval flow with no owner is a # dead end for everyone reading the published list, but nothing is # exposed by it. Force-transferred to the manager at T+0+24h. "approval_flow_owner": SEVERITY_ADVISORY, # Auto-handled at T+0: hand to successor, else DISABLE the consumer. "identity_consumer_owner": SEVERITY_ADVISORY, # GitHub 组织成员资格 —— 人绑权限,T+0 第一个 tick 直接在源头撤销。 # 与 sso_grant / vpn_application / cloud_access_grant 同族。 "github_org_membership": SEVERITY_ADVISORY, # DAX 个人数据集授权(migration 606)—— 同族:T+0 在源头撤销,走 DAX 自己的 # revoke → apply-absent reconciler。 "data_access_grant": SEVERITY_ADVISORY, # 2026-09-05(PR3):对象存储桶 / 手动指定的证书负责人 —— 与 LB / CDN 同族的 # 「负责人 FK 转给交接人」;邮件组静态成员是人绑权限,T+0 在源头移除。 "object_storage": SEVERITY_ADVISORY, "certificate": SEVERITY_ADVISORY, "email_group_member": SEVERITY_ADVISORY, } def severity_for_resource(resource_type: str) -> str: """Map ``resource_type`` to a severity bucket. Fail-closed. See ``_SEVERITY_BY_RESOURCE`` and migration 136 for the full rationale; both must stay in sync - the migration backfills using the same list when this column is first added to the table. """ return _SEVERITY_BY_RESOURCE.get(resource_type, SEVERITY_BLOCKER) RESOURCE_LOCK_STATUSES = {"active", "approved"} # ``_dispatch_offboarding_start_notifications`` retroactive-open # threshold. A process whose ``created_at`` is more than this many days # AFTER ``leaver_date`` is treated as a backfill / forensic open and # the start notifications are suppressed (see service.py around # the guard for full incident context). 1 day grace covers HR # recording a leaver the morning after their last working day; tighter # would false-suppress same-day late-entry, looser would surface stale # "process just started" FYIs for week-old backfilled leavers. RETROACTIVE_OPEN_GRACE_DAYS = 1 class OffboardingService: def __init__(self, db: AsyncSession) -> None: self.db = db self.audit = AuditService(db) self.provisioning = ProvisioningService(db) # Notification outbox rows are enqueued inside the transfer # transaction, but external channels are drained only after its # caller commits and releases every FOR UPDATE lock. self._pending_transfer_notifications: list[ tuple[NotificationOutbox, NotificationEnvelope] ] = [] # ───────────────────────── Process lifecycle ───────────────────────── async def start_process( self, person_id: UUID, leaver_date: date_type, *, trace_id: str, actor_user_id: UUID | None = None, ) -> OffboardingProcess: """Create or reopen an offboarding process for a person. Idempotent re-entry semantics: * existing non-cancelled process with the SAME leaver_date — return unchanged so HRIS re-syncs are no-ops. * existing non-cancelled process with a DIFFERENT leaver_date — update leaver_date in place + audit (HRIS path can correct a typed-wrong termination_date without losing the in-flight blocker work). * existing cancelled process — reopen with fresh scan; re-pending every non-terminal blocker so resource state is re-evaluated. Side effects done here (in-transaction): * persist OffboardingProcess + blockers + per-policy provisioning tasks * audit ``offboarding.process_started`` Side effects deferred to AFTER the caller's commit (call ``dispatch_post_commit_actions`` from the router): * Google force-logout (external API) * leaver / manager notifications Persisting first means a tx rollback never leaves a "Google session killed but Helix has no record" state. """ existing = ( await self.db.execute( select(OffboardingProcess) .where(OffboardingProcess.person_id == person_id) .with_for_update() ) ).scalar_one_or_none() # Boomerang re-hire: a person whose previous offboarding ran to # PROC_COMPLETED and who was later re-hired (HRIS reactivate flips # employment_status back to 'active', clearing termination_date) # can be terminated again. Without an explicit reopen, the second # ``start_process`` call would hit the "non-cancelled, same-or- # different leaver_date" branch which calls ``_recompute_status`` # — and ``_recompute_status`` short-circuits on PROC_COMPLETED. # The new blockers persist as PENDING but the process stays # COMPLETED, the daily advance cron skips it, and the leaver is # stuck in ``leaver_pending`` forever. Treat COMPLETED+rehire the # same as CANCELLED: cycle-reset + fresh scan. if existing and existing.status != PROC_CANCELLED and existing.status != PROC_COMPLETED: if existing.leaver_date != leaver_date: prior_date = existing.leaver_date existing.leaver_date = leaver_date await self.db.flush() # #1771: a leaver_date correction must also move the # ``not_before`` gate on any ALREADY-created, not-yet- # executed auto-revoke tasks. They were stamped with the # OLD date's ``_leaver_revoke_not_before`` at creation, and # ``_rescan`` skips blockers that already carry a # ``provisioning_task_id`` — so without this, an ACTIVE # employee whose leaver_date slipped LATER has their Gmail / # Jira / seat revoked on the OLD (earlier) date, exactly the # Thalie-class early-revoke the gate exists to prevent. # Recompute and push the gate on every non-terminal deferred # revoke for this person (both directions: a later date # defers, an earlier date is also honoured — the T+0 # suspend path still clears not_before at cutover). from app.identity.person_models import ( # noqa: PLC0415 IdentityPerson as _IdentityPerson, ) _person_for_tz = await self.db.get(_IdentityPerson, person_id) _new_not_before = _leaver_revoke_not_before( _person_for_tz, leaver_date, ) await self.db.execute( update(ProvisioningTask) .where( ProvisioningTask.user_id == person_id, ProvisioningTask.action == ACTION_REVOKE, ProvisioningTask.status.notin_( (TASK_COMPLETED, TASK_CANCELLED), ), ProvisioningTask.not_before.isnot(None), ) .values(not_before=_new_not_before) ) await self.db.flush() # Re-scan on date correction. The HRIS path that fires # this branch (typed-wrong terminationDate corrected on # the next sync) also implies "use the latest snapshot" # — the operator wants the corrected date AND the # current resource state. Without the rescan, blockers # remain frozen at the prior scan time and the 5-minute # reconcile cron is the only safety net (acceptable but # not great if the corrected date is imminent). await self._rescan_with_status_guard( existing, trace_id=trace_id, actor_user_id=actor_user_id, ) await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.leaver_date_updated", status="success", resource_type="offboarding_process", resource_id=str(existing.id), detail={ "prior_leaver_date": prior_date.isoformat(), "new_leaver_date": leaver_date.isoformat(), }, ) return existing if existing and existing.status in (PROC_CANCELLED, PROC_COMPLETED): prior_status_for_audit = existing.status existing.status = PROC_SCANNING existing.leaver_date = leaver_date existing.completed_at = None existing.sso_suspended_at = None existing.ready_at = None # Round 2 P2 fix (LG2-002): clear ``force_logout_at`` on # the person too. Mirrors ``cancel_process``'s P0-1 fix # (line ~580). Without this, a re-hired-then-re-leaving # person enters cycle 2 with cycle 1's force_logout_at # still set — the leaver-blocked dashboard renders "session # severed at " for an active employee # post-rehire, and any pre-cycle-1 PAT remains rejected by # ``_enforce_force_logout`` even after rehire. The new # cycle's ``suspend_sso`` will set ``force_logout_at`` # again at the right moment. # # Round 2 P1 fix (LG2-003 prep): also clear SLA breach # level so cycle 2's SLA service starts at the un-breached # baseline. Without this, a cycle-1-breached leaver who # re-leaves enters cycle 2 already at ``sla_breach_level`` # = 'breach' and bypasses WARN/URGENT escalation. from app.identity.person_models import ( # noqa: PLC0415 IdentityPerson, ) _person_for_reopen = await self.db.get( IdentityPerson, existing.person_id, ) if _person_for_reopen is not None: _person_for_reopen.force_logout_at = None from app.domains.offboarding.models import ( # noqa: PLC0415 SLA_NONE, ) existing.sla_breach_level = SLA_NONE existing.first_blocked_at = None # Pre-launch P1 fix (Round 45, CR-413): clear prior cycle's # hardware_disposition map on reopen. Without this, a # rehired-then-re-leaving person's cycle-2 T+0 handler # silently inherits cycle-1's "keep / recycle / transfer" # decisions against asset IDs that no longer match the new # scan — admin sees a fresh process and doesn't think to # set disposition; the system uses stale decisions. existing.hardware_disposition = None # Review 2026-07-08 fix (#1749): clear the prior cycle's # ``session_enforcement`` ledger on reopen. Without this, the # per-channel merge in ``_push_force_logout`` (the "合并不覆盖 # 已成功" contract at ~line 763) preserves cycle-1's # ``google_account.status='suspended'`` / ``google_session. # status='ok'`` success records — so if cycle-2's T+0 cutoff # FAILS (e.g. a 403 on the re-leaver's account), the failure is # discarded and never lands in the ledger. The evidence panel # would keep showing cycle-1's stale "suspended" proof and the # ``offboarding_session_not_cut`` data-quality scan (which reads # this JSON) would stay blind to the live failure. Reset to NULL # so cycle-2's evidence accumulates from scratch (mirrors the # force_logout_at / sla_breach_level / hardware_disposition # resets in this same reopen branch). existing.session_enforcement = None # Reopen-cycle notification dedupe reset. The post-commit # dispatcher dedupes via OffboardingReminderLog rows keyed # by (process_id, kind). On reopen the process_id is the # same, so without this clear, the leaver and manager # would never get a re-notification — even though the # offboarding has effectively re-started and they need to # know. # # Round 2 P0 fix (LG2-001): the prior implementation only # cleared ``process_started_*`` rows, leaving cycle-1's # T-7/T-3/T-1/escalation/SLA-warn/urgent/breach rows in # place. After reopen, the new cycle's escalation + # sla_service ticks would see ``_already_logged()`` return # True for every band and silently SKIP them — the # boomerang leaver would get the start-of-cycle ping but # zero follow-ups, escalation, or SLA notifications. # Worst possible failure mode for the cohort whose # offboarding most needs follow-through. Clear ALL log # rows for the reused process_id so the new cycle's # idempotency keys start fresh. from sqlalchemy import delete as _sa_delete # noqa: PLC0415 from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) await self.db.execute( _sa_delete(OffboardingReminderLog).where( OffboardingReminderLog.process_id == existing.id, ) ) # Cancel-restart blocker reset (P1-6 fix). When a process is # reopened after cancel, every non-terminal blocker (PENDING, # SSO_CASCADE) MUST be re-pended so the fresh scan re-evaluates # it against current state. Without this, a previously # sso_cascade-resolved blocker for a software the leaver still # holds (because cancel happened before suspend_sso fired) # stays "resolved" and the process slips into READY without # the seat actually being touched. Terminal resolutions # (TRANSFERRED / REVOKED_AUTO / REVOKED_MANUAL) are kept — # those represent real state changes that already happened. reopen_reset_count = 0 old_blockers = ( await self.db.execute( select(OffboardingBlocker).where( OffboardingBlocker.process_id == existing.id, ) ) ).scalars().all() for b in old_blockers: if b.resolution in ( RESO_PENDING, RESO_SSO_CASCADE, RESO_IDENTITY_SYNC_MANAGED, ): if b.resolution in ( RESO_SSO_CASCADE, RESO_IDENTITY_SYNC_MANAGED, ): b.resolution = RESO_PENDING b.notes = ( (b.notes or "") + "\n[reopen_reset] cancel→reopen: re-pending " "for fresh policy evaluation" ).strip() b.updated_at = datetime.now(UTC) reopen_reset_count += 1 await self.db.flush() await self._rescan_with_status_guard( existing, trace_id=trace_id, actor_user_id=actor_user_id, ) await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.process_reopened", status="success", resource_type="offboarding_process", resource_id=str(existing.id), detail={ "leaver_date": leaver_date.isoformat(), "reopen_reset_blocker_count": reopen_reset_count, "prior_status": prior_status_for_audit, }, ) return existing # Default handover successor prefill (migration 211). HRIS-derived # manager_id is the common case: when the leaver's direct manager # is the org's convention for transfer destinations, the admin # gets it for free and can still override per-blocker. NULL is # acceptable — the modal just opens with an empty UserSelect. from app.identity.person_models import ( # noqa: PLC0415 IdentityPerson, ) default_handover_person_id: UUID | None = None leaver_for_prefill = await self.db.get(IdentityPerson, person_id) if leaver_for_prefill is not None and leaver_for_prefill.manager_id: # Only prefill if the manager row still exists in # identity_persons — a stale manager_id (manager hard-deleted # via the platform people-cleanup flow) would otherwise fail # the FK on flush. Cheap single-row GET. mgr = await self.db.get( IdentityPerson, leaver_for_prefill.manager_id, ) if mgr is not None and mgr.deleted_at is None: default_handover_person_id = leaver_for_prefill.manager_id proc = OffboardingProcess( person_id=person_id, leaver_date=leaver_date, status=PROC_SCANNING, default_handover_person_id=default_handover_person_id, ) self.db.add(proc) await self.db.flush() await self._rescan_with_status_guard( proc, trace_id=trace_id, actor_user_id=actor_user_id, ) await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.process_started", status="success", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "person_id": str(person_id), "leaver_date": leaver_date.isoformat(), "default_handover_person_id": ( str(default_handover_person_id) if default_handover_person_id else None ), }, ) # NOTE: the prefilled handover person IS notified — but from # ``dispatch_post_commit_actions``, not here. This method's own # contract (see the docstring) is that notifications are deferred # until the caller has committed: a rollback here would otherwise # leave a colleague told "you are X's handover person" for a # process that no longer exists. The first cut of this push (2026- # 08-13, #2669) got that wrong and sent it in-transaction. return proc async def _rescan_with_status_guard( self, proc: OffboardingProcess, *, trace_id: str, actor_user_id: UUID | None, ) -> None: """Wrap ``_rescan`` so an exception during scan doesn't strand the process in PROC_SCANNING forever. Pre-launch P0 fix: previously a SELECT failure halfway through ``_rescan`` would leave ``status='scanning'`` with no path to recover — the cron skips SCANNING (assumes another tx is mid-flight) and the admin UI has no "rescan" button. Now: any exception flips the process to BLOCKED with a marker note so the operator sees a clear error and can manually retry via reconcile. """ try: await self._rescan(proc, trace_id=trace_id, actor_user_id=actor_user_id) except Exception as exc: # noqa: BLE001 logger.exception( "offboarding._rescan failed for process %s — flipping to " "BLOCKED with error marker so operator can retry", proc.id, ) proc.status = PROC_BLOCKED proc.scanned_at = datetime.now(UTC) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.scan_failed", status="failure", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "error": format_exception_for_log( exc, max_len=500, include_type=False, ), "error_type": type(exc).__name__, }, ) raise async def _push_force_logout( self, person_id: UUID, *, trace_id: str, ) -> None: """Best-effort push the person out of Google SSO sessions and Feishu sessions. Sets ``IdentityPerson.force_logout_at`` on success of either push, so ``/admin/offboarding`` can show the moment the leaver's existing sessions were cut.""" from datetime import UTC as _UTC from datetime import datetime as _dt from app.identity.person_models import IdentityPerson person = await self.db.get(IdentityPerson, person_id) if person is None or person.deleted_at is not None: # Review round-1 fix (2026-07-04): this early-return used to be # fully silent — a person hard/soft-deleted between process start # and the post-commit dispatch left session_enforcement NULL, # indistinguishable from "never attempted". Stamp a skip marker # so the proof panel + reconciliation reads the truth. logger.warning( "offboarding._push_force_logout: person %s is deleted/" "missing — skipping session cutoff, stamping skip marker", person_id, ) process = await self.db.scalar( select(OffboardingProcess).where( OffboardingProcess.person_id == person_id, ) ) if process is not None: merged = dict(process.session_enforcement or {}) merged["skipped_person_deleted"] = True merged["updated_at"] = _dt.now(_UTC).isoformat() process.session_enforcement = merged await self.db.flush() return # Per-channel enforcement snapshot (migration 383). Each cutoff # channel records a structured outcome so IT gets a durable answer # to "is this leaver's access actually cut?" — persisted on the # OffboardingProcess and surfaced on the detail page + person-detail # 离职 tab, and re-recorded on a suspend-sso re-run. now_iso = _dt.now(_UTC).isoformat() enforcement: dict[str, object] = {} any_pushed = False def _safe_err(v: object) -> str | None: # Security round-1 (SEC-MED): vendor error strings land in a # persisted JSON column AND are served to the frontend tooltip. # A raw googleapiclient HttpError repr can embed request context; # hard-cap it so a pathological error body can never park tokens/ # PII in the DB. 200 chars matches the SEC2-002 API-surface cap. if v is None: return None return str(v)[:200] # (1) Google session revoke (users.signOut). try: g = await self._push_google_logout(person) except Exception as exc: # noqa: BLE001 logger.exception( "offboarding.start_process: Google force_logout failed for %s", person.work_email, ) g = {"status": "failed", "error": f"{type(exc).__name__}: {exc}"} g = {**g, "error": _safe_err(g.get("error"))} if "error" in g else g enforcement["google_session"] = {**g, "at": now_iso} if g.get("status") in ("ok", "already_absent"): any_pushed = True elif g.get("status") == "failed": # Forensic trail — a failed session cutoff must never be silent # again (P0051: signOut 403'd for weeks unnoticed). The # hris.state_reconciliation cron re-reads this row and alerts IT. await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_force_logout_failed", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "error": g.get("error"), "http_status": g.get("http_status"), }, ) # (2) Feishu session revoke (honest no-op today — no public API). try: f_ok = await self._push_feishu_logout(person) enforcement["feishu_session"] = { "status": "ok" if f_ok else "no_public_api", "at": now_iso, } any_pushed = any_pushed or f_ok except Exception: # noqa: BLE001 logger.exception( "offboarding.start_process: Feishu force_logout failed for %s", person.work_email, ) enforcement["feishu_session"] = {"status": "failed", "at": now_iso} # Account-level disposition (2026-07-01 ADR 翻案「离职自动挂起 Google # 号」): auto-SUSPEND the leaver's Google account, not just kill the # session. This method only runs after the leaver's last working day # (caller gate `local_today > leaver_date`), so timing is correct. # Retention-exempt mailboxes (service / shared / CBG-retained, marked # in external_account_registry) are LEFT ACTIVE — a deliberate keep is # never auto-clobbered. Routed through the account-lifecycle # coordinator (single owner); idempotent + best-effort. # SAVEPOINT-isolate each channel (2026-08-06): the audit writes # inside this block run on the caller's transaction, so a DB-level # failure here used to deactivate it — the REMAINING channels then # failed at their first DB touch and the session_enforcement ledger # write at the end raised, losing the record of what was actually # cut. Per-channel isolation is what makes 'best-effort' true. async with best_effort( self.db, label=f"offboarding: suspend google account for {person.work_email}", log=logger, ) as google_step: if await self._is_google_account_retained(person): enforcement["google_account"] = { "status": "retained", "at": now_iso, } await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_account_suspend_skipped_retained", status="success", resource_type="identity_person", resource_id=str(person.id), detail={"work_email": person.work_email}, ) else: from app.core.coordinators import account_lifecycle_coordinator sus = await account_lifecycle_coordinator.suspend_google_account( self.db, person, ) enforcement["google_account"] = { "status": sus.get("status"), "at": now_iso, "error": _safe_err(sus.get("error")), "already_absent": sus.get("already_absent"), } if sus.get("status") == "suspended": await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_account_suspended", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "already_absent": sus.get("already_absent"), }, ) elif sus.get("status") == "failed": await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_account_suspend_failed", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "error": sus.get("error"), }, ) if not google_step.ok: logger.error( "offboarding: Google account suspend failed for %s: %s", person.work_email, google_step.reason(), ) enforcement["google_account"] = {"status": "failed", "at": now_iso} # Zoom account disposition (2026-08-04 — leaver Zoom two-stage, parity # with the Google block above): auto-DEACTIVATE the leaver's Zoom # account (day-of). The terminal hard-DELETE is fired later by the # ``hr.offboarding_zoom_account_delete`` sweep at leaver_date + # delete_lead_days, gated on THIS deactivate being confirmed. Same # last-working-day gate as the Google block (caller only dispatches # after ``local_today > leaver_date``). Retention-exempt accounts # (service / shared / contractor in external_account_registry) are LEFT # ACTIVE. Routed through the account-lifecycle coordinator (single # owner); idempotent + best-effort. Reaches the vendor by work_email, # so it does NOT depend on the identity_sync ``external_id`` (NULL for # ~all baseline no_meetings accounts). # SAVEPOINT-isolate each channel (2026-08-06): the audit writes # inside this block run on the caller's transaction, so a DB-level # failure here used to deactivate it — the REMAINING channels then # failed at their first DB touch and the session_enforcement ledger # write at the end raised, losing the record of what was actually # cut. Per-channel isolation is what makes 'best-effort' true. async with best_effort( self.db, label=f"offboarding: suspend zoom account for {person.work_email}", log=logger, ) as zoom_step: if await self._is_zoom_account_retained(person): enforcement["zoom_account"] = { "status": "retained", "at": now_iso, } await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.zoom_account_suspend_skipped_retained", status="success", resource_type="identity_person", resource_id=str(person.id), detail={"work_email": person.work_email}, ) else: from app.core.coordinators import account_lifecycle_coordinator zsus = await account_lifecycle_coordinator.suspend_zoom_account( self.db, person, ) enforcement["zoom_account"] = { "status": zsus.get("status"), "at": now_iso, "error": _safe_err(zsus.get("error")), "already_absent": zsus.get("already_absent"), } if zsus.get("status") == "suspended": await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.zoom_account_suspended", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "already_absent": zsus.get("already_absent"), }, ) elif zsus.get("status") == "failed": await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.zoom_account_suspend_failed", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "error": zsus.get("error"), }, ) if not zoom_step.ok: logger.error( "offboarding: Zoom account suspend failed for %s: %s", person.work_email, zoom_step.reason(), ) enforcement["zoom_account"] = {"status": "failed", "at": now_iso} # Feishu account disposition (2026-07-14 — leaver 飞书 terminal state): # DELETE the account → Feishu recycle bin (restorable in-window), freeing # the seat + removing from the directory — parity with the predecessor # Authing 飞书离职删除工作流 (chosen over freeze). Runs on the SAME # last-working-day gate as the Google block (caller only dispatches this # after ``local_today > leaver_date``). Retention-exempt accounts # (service / shared / contractor in external_account_registry) are LEFT # INTACT. Owned by the account-lifecycle coordinator; idempotent # (already-absent = success), business-refusal falls back to freeze. # SAVEPOINT-isolate each channel (2026-08-06): the audit writes # inside this block run on the caller's transaction, so a DB-level # failure here used to deactivate it — the REMAINING channels then # failed at their first DB touch and the session_enforcement ledger # write at the end raised, losing the record of what was actually # cut. Per-channel isolation is what makes 'best-effort' true. async with best_effort( self.db, label=f"offboarding: delete feishu account for {person.work_email}", log=logger, ) as feishu_step: if await self._is_feishu_account_retained(person): enforcement["feishu_account"] = { "status": "retained", "at": now_iso, } await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_skipped_retained", status="success", resource_type="identity_person", resource_id=str(person.id), detail={"work_email": person.work_email}, ) elif open_approvals := await self._feishu_open_self_approvals( person.id, ): # In-flight approval instances submitted by the leaver (回购 # second step parks on the submitter's OWN node) — deleting # the account now would strand them terminally. Defer the # delete and FREEZE instead (access still cut); the daily # sweep retries once the instances close. See # FEISHU_DELETE_DEFER_MAX_DAYS for the full rationale. fd_defer = await self._defer_feishu_delete( person, open_approvals, trace_id=trace_id, ) enforcement["feishu_account"] = {**fd_defer, "at": now_iso} if fd_defer.get("freeze_status") == "deactivated": any_pushed = True else: from app.core.coordinators import account_lifecycle_coordinator fd = await account_lifecycle_coordinator.delete_feishu_account( self.db, person, trace_id=trace_id, ) fd_status = fd.get("status") if fd_status == "retained_cbg_department": # The live pre-delete check found the account under the # configured CBG root department — a deliberate keep, # recorded on the ledger with the SAME "retained" status # as the registry-driven gate above (so the success-merge # protects it), with the reason in detail. fd_status = "retained" enforcement["feishu_account"] = { "status": fd_status, "at": now_iso, "error": _safe_err(fd.get("error")), "external_id": fd.get("external_id"), } if fd.get("status") == "retained_cbg_department": # FIX 1: durably record the CBG retention in the external- # account registry so #1983's leaver-assurance (and our own # _is_feishu_account_retained) see the keep on every future # run instead of paging IT daily for this documented keep. await self._record_cbg_retention_registry( person, trace_id=trace_id, ) await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_skipped_retained", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "reason": "retained: CBG department", "external_id": fd.get("external_id"), }, ) elif fd.get("status") == "skipped_target_keep": # Every feishu_contact sync target is on_offboard='keep' # — an explicit admin decision; the delete was skipped # entirely (previously the email fallback bypassed it). await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_skipped_target_keep", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "reason": fd.get("error"), }, ) elif fd.get("status") in ("deleted", "frozen_fallback", "already_absent"): any_pushed = True await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_deleted", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "disposition": fd.get("status"), "external_id": fd.get("external_id"), }, ) elif fd.get("status") == "failed": await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_failed", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "error": fd.get("error"), }, ) elif fd.get("status") == "skipped_identity_mismatch": # SAFETY refusal — the resolved Feishu account couldn't be # positively identified as this leaver (employee_no didn't # match), so we did NOT delete. Audit it so IT sees WHY the # leaver's Feishu account wasn't touched (else it's silent). await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_skipped_identity_mismatch", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "external_id": fd.get("external_id"), "error": fd.get("error"), }, ) if not feishu_step.ok: logger.error( "offboarding: Feishu account delete failed for %s: %s", person.work_email, feishu_step.reason(), ) enforcement["feishu_account"] = {"status": "failed", "at": now_iso} # Persist the per-channel enforcement snapshot onto the process. # Merge so a partial re-run (e.g. only Google failed last time) # never clobbers a channel that already succeeded. enforcement["updated_at"] = now_iso # #1793: lock the row for this read-modify-write of the # ``session_enforcement`` JSON ledger. The vendor pushes above are # already done, so the lock is held only for the DB merge (no lock- # across-HTTP). Without it, this dispatch/re-notify path races the # daily Google delete sweep (``_delete_google_account_if_due``, which # DOES lock + writes ``session_enforcement.google_account_deletion``) # → a plain read-merge-write here loses whichever commit landed # between our read and write (drops the deletion ledger, or the # sweep drops our just-recorded google_account channel — the # offboarding_session_not_cut data-quality scan then reads a false # ledger). ``populate_existing`` so we see the sweep's commit rather # than a stale identity-map row. dispatch was the only unlocked hole; # reenforce/catchup already lock. process = await self.db.scalar( select(OffboardingProcess) .where(OffboardingProcess.person_id == person_id) .with_for_update() .execution_options(populate_existing=True) ) if process is not None: merged = dict(process.session_enforcement or {}) # Per-channel merge (NOT a blanket dict.update): a partial re-run # rebuilds EVERY channel key from scratch, so ``update`` would # replace a previously-succeeded channel wholesale. If a prior run # recorded a channel as a success/no-op and THIS run failed it # (transient signOut/suspend 5xx), keep the prior success — the # ledger must record the best outcome achieved, not the latest # attempt, and re-firing the failure alert for an already-cut # channel is noise. Scalars (updated_at, skipped_* markers) always # take the fresh value. for chan, new_val in enforcement.items(): prior = merged.get(chan) if ( isinstance(prior, dict) and isinstance(new_val, dict) and _is_session_channel_success(prior.get("status")) and not _is_session_channel_success(new_val.get("status")) ): continue merged[chan] = new_val process.session_enforcement = merged await self.db.flush() if any_pushed: person.force_logout_at = _dt.now(_UTC) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.force_logout_pushed", status="success", resource_type="identity_person", resource_id=str(person.id), detail={"work_email": person.work_email}, ) async def _is_google_account_retained(self, person) -> bool: """True if the leaver's Google mailbox is marked keep/retained in the external-account registry (``shared_service`` / ``external_guest`` / ``contractor`` — the KNOWN classifications). Such accounts are NOT auto-suspended on offboarding: a deliberate retention decision (e.g. a CBG-retained or shared service mailbox) must never be clobbered by the blanket 翻案 auto-suspend. No registry row → not retained → suspend.""" email = (getattr(person, "work_email", None) or "").strip().lower() if not email: return False from app.domains.asset.models import ( # noqa: PLC0415 EXTERNAL_ACCOUNT_KNOWN_CLASSIFICATIONS, ExternalAccountRegistry, ) row = ( await self.db.execute( select(ExternalAccountRegistry).where( func.lower(ExternalAccountRegistry.email) == email, ) ) ).scalars().first() return bool( row and row.classification in EXTERNAL_ACCOUNT_KNOWN_CLASSIFICATIONS ) async def _is_zoom_account_retained(self, person) -> bool: """True if the leaver's Zoom account is a deliberately-retained service / shared / contractor account (marked in the external-account registry, keyed on ``work_email`` — the same natural key the Zoom account uses). Such accounts are NEVER auto-deactivated/deleted on offboarding — the same keep semantics that protect the Google mailbox. No registry row → not retained → deactivate. Mirrors ``_is_google_account_retained`` (Zoom accounts share the work_email key, so the lookup is identical).""" email = (getattr(person, "work_email", None) or "").strip().lower() if not email: return False from app.domains.asset.models import ( # noqa: PLC0415 EXTERNAL_ACCOUNT_KNOWN_CLASSIFICATIONS, ExternalAccountRegistry, ) row = ( await self.db.execute( select(ExternalAccountRegistry).where( func.lower(ExternalAccountRegistry.email) == email, ) ) ).scalars().first() return bool( row and row.classification in EXTERNAL_ACCOUNT_KNOWN_CLASSIFICATIONS ) async def _is_feishu_account_retained(self, person) -> bool: """True if the leaver's Feishu account is a deliberately-retained service / shared / contractor account (marked in the external-account registry). Such accounts are NEVER auto-deleted on offboarding — the same keep semantics that protect the Google mailbox. Keyed on the registry's ``email`` column, which holds EITHER the real ``work_email`` OR — for no-email Feishu accounts (2026-07-02) — the synthetic ``software_external_holder.holder_key`` ``feishu-user:``. A retention row keyed on the synthetic key could never match a work_email-only lookup, so the leaver's linked Feishu ``user_id`` (IdentitySyncState of any enabled ``feishu_contact`` target — the same id the delete path resolves) is checked too. No matching registry row → not retained → delete. NB: the registry is only HALF the retention authority — a fresh leaver destined for CBG retention has no registry row yet at cutover. The live Feishu CBG-department check runs pre-delete in ``account_lifecycle_coordinator.delete_feishu_account`` (status ``retained_cbg_department``).""" keys: list[str] = [] email = (getattr(person, "work_email", None) or "").strip().lower() if email: keys.append(email) feishu_user_id = await self._resolve_linked_feishu_user_id(person) if feishu_user_id: keys.append(f"feishu-user:{feishu_user_id}".lower()) if not keys: return False from app.domains.asset.models import ( # noqa: PLC0415 EXTERNAL_ACCOUNT_KNOWN_CLASSIFICATIONS, ExternalAccountRegistry, ) row = ( await self.db.execute( select(ExternalAccountRegistry).where( func.lower(ExternalAccountRegistry.email).in_(keys), ) ) ).scalars().first() return bool( row and row.classification in EXTERNAL_ACCOUNT_KNOWN_CLASSIFICATIONS ) async def _resolve_linked_feishu_user_id(self, person) -> str | None: """The leaver's linked Feishu ``user_id`` (``IdentitySyncState. external_id`` of any enabled ``feishu_contact`` target), or None. Mirrors the resolution order of ``delete_feishu_account`` but WITHOUT the ``on_offboard != 'keep'`` filter — retention lookups want ANY linked id, keep-or-not. Best-effort (returns None on any lookup error): the retention gate must never blow up dispatch.""" # SAVEPOINT-isolate (2026-08-06): read-only, but a transaction- # aborting errno (1205/1213) still has to propagate rather than be # swallowed into a dead session that fails the caller two statements # later. ``best_effort`` gives both halves. state = None async with best_effort( self.db, label="offboarding: resolve linked Feishu user_id", log=logger, ) as lookup_step: from app.domains.identity_sync.models import ( # noqa: PLC0415 IdentitySyncState, IdentitySyncTarget, ) state = ( await self.db.execute( select(IdentitySyncState) .join( IdentitySyncTarget, IdentitySyncTarget.id == IdentitySyncState.target_id, ) .where( IdentitySyncTarget.kind == "feishu_contact", IdentitySyncTarget.deleted_at.is_(None), IdentitySyncTarget.enabled.is_(True), IdentitySyncState.person_id == person.id, ) .order_by(IdentitySyncTarget.created_at) ) ).scalars().first() if not lookup_step.ok: logger.error( "offboarding: linked Feishu user_id lookup failed for %s: %s", getattr(person, "work_email", None), lookup_step.reason(), ) return None ext = (getattr(state, "external_id", None) or "").strip() return ext or None async def _record_cbg_retention_registry(self, person, *, trace_id: str) -> None: """Persist a durable ``external_guest`` retention row for a leaver retained via the LIVE Feishu CBG-department check. The live check (``retained_cbg_department``) is ephemeral — without a registry row #1983's leaver-assurance (which exempts leavers whose ``work_email`` carries a KNOWN registry classification) fires DAILY for every CBG-retained leaver, and its vendor-snapshot-refresh cron guarantees the vendor-side signal keeps firing. Writing the row makes the registry the shared contract both the assurance check AND our own ``_is_feishu_account_retained`` read on every future run. Keyed on ``work_email`` (leaver-assurance matches on work_email and every processed leaver has one — no synthetic ``feishu-user:`` row, which would only duplicate the registry entry). Idempotent + never clobbers an admin's explicit ``shared_service`` / ``contractor`` (see ``ExternalAccountRegistryService.ensure_retained_classification``). Best-effort: a registry-write failure must not fail the offboarding disposition (the account is already correctly retained on Feishu).""" email = (getattr(person, "work_email", None) or "").strip().lower() if not email: logger.warning( "offboarding: CBG-retained leaver %s has no work_email — " "skipping retention registry write (cannot key the row)", getattr(person, "id", None), ) return from app.domains.asset.external_account_service import ( # noqa: PLC0415 ExternalAccountRegistryService, ) today = datetime.now(UTC).date().isoformat() # SAVEPOINT, not a bare ``except``: the registry write flushes, and a # swallowed flush failure deactivates the transaction the OFFBOARDING # dispatch lives in — so "the retention write must not fail dispatch" # would fail dispatch, on the caller's next statement rather than here. async with best_effort( self.db, label=f"CBG retention registry row for {email}", log=logger, ) as retain_step: await ExternalAccountRegistryService( self.db, ).ensure_retained_classification( email, classification="external_guest", note=f"auto: CBG department retention at offboarding ({today})", trace_id=trace_id, actor_user_id=None, ) if not retain_step.ok: logger.warning( "offboarding: failed to write CBG retention registry row " "for %s: %s", email, retain_step.reason(), ) async def _unfreeze_retained_after_defer( self, person, *, trace_id: str, ) -> dict[str, Any]: """UNFREEZE a leaver's Feishu account when a DEFERRED delete resolves to *retained* (FIX 2). The defer path FROZE the account (``is_frozen=true``) so access was cut while open self-approvals stayed operable. If the retry now resolves to retained (live CBG check or a registry row that appeared) the account must be kept ACTIVE — a retained CBG collaborator left frozen is permanently locked out. Mirrors the freeze inverted via the coordinator's ``reactivate_feishu_account``; audited so IT sees the unfreeze. Best-effort (never raises): a failed unfreeze is audited + returned so the sweep can surface it, but must not abort the retry.""" from app.core.coordinators import account_lifecycle_coordinator try: result = await account_lifecycle_coordinator.reactivate_feishu_account( self.db, person, trace_id=trace_id, ) except Exception as exc: # noqa: BLE001 — never abort the retry logger.exception( "offboarding: unfreeze of retained leaver %s raised", getattr(person, "work_email", None), ) result = {"status": "failed", "error": f"{type(exc).__name__}: {exc}"} await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_unfrozen_retained", status="success" if result.get("status") != "failed" else "failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "unfreeze_status": result.get("status"), "external_id": result.get("external_id"), "error": result.get("error"), }, ) return result async def _feishu_open_self_approvals(self, person_id: UUID) -> list[str]: """Request numbers of the leaver's own PENDING employee requests that are linked to a live Feishu approval instance. These are the instances the terminal Feishu delete would strand: the 回购 template parks its second step on the SUBMITTER'S OWN node, and the Helix supplement fallback needs the submitter's Feishu user to exist (it resolves their user_id by email and approves the task in their name). ``status='pending'`` mirrors the supplement endpoint's own gate — approved/rejected/cancelled instances are terminal on the Feishu side and no longer need the account. Requests without a linked ``detail.feishu.instance_code`` never reached Feishu and don't block. """ from app.domains.asset.models import EmployeeRequest # noqa: PLC0415 rows = ( await self.db.execute( select(EmployeeRequest).where( EmployeeRequest.requester_id == person_id, EmployeeRequest.status == "pending", ) ) ).scalars().all() out: list[str] = [] for req in rows: feishu = (req.detail or {}).get("feishu") or {} if isinstance(feishu, dict) and feishu.get("instance_code"): out.append(req.request_number) return sorted(out) async def _defer_feishu_delete( self, person, open_requests: list[str], *, trace_id: str, ) -> dict[str, Any]: """Defer the terminal Feishu delete because the leaver has open self-submitted approval instances, FREEZING the account instead so access is still cut while the instances stay operable. Returns the ``feishu_account`` channel value (caller stamps ``at``); audited so IT can see WHY the leaver's Feishu account is frozen-not-deleted. The freeze rides the account-backstop primitive (idempotent, never raises); the daily sweep retries the delete once the instances close or the defer window expires.""" from app.core.coordinators import account_lifecycle_coordinator freeze = await account_lifecycle_coordinator.deactivate_account( self.db, person, trace_id=trace_id, ) feishu = (freeze or {}).get("feishu") or {} freeze_status = feishu.get("status") # The freeze is the ONLY lever that cuts a leaver's Feishu access # (no session-revoke API). If it did NOT verify as cut, this must # NOT read as a clean success — audit it as failed so the leaver # data-quality checks / IT see that access is still live while the # delete waits on the open approval. The identity-sync feishu sweep # + account_backstop independently retry the (now honest) freeze. cut = freeze_status == "deactivated" await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_deferred_open_approvals", status="success" if cut else "failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "open_requests": open_requests[:20], "freeze_status": freeze_status, "freeze_error": feishu.get("error"), "access_cut": cut, }, ) return { "status": FEISHU_DELETE_DEFERRED, "open_requests": open_requests[:20], "freeze_status": freeze_status, "defer_count": 1, } async def _push_google_logout(self, person) -> dict[str, object]: """Force-logout the leaver's Google Workspace sessions, returning the structured outcome ``{"status": ok|already_absent|failed| skipped_no_integration, "error"?, "http_status"?}``. Delegates to the account-lifecycle coordinator — vendor session revocation has one home there, not an inline driver build in the offboarding domain.""" from app.core.coordinators import account_lifecycle_coordinator return await account_lifecycle_coordinator.google_force_logout_detailed( self.db, person, ) async def _push_feishu_logout(self, person) -> bool: """Delegates to the account-lifecycle coordinator (honest logged no-op today — Feishu exposes no public per-user session-revoke API; activates without a schema change when it does).""" from app.core.coordinators import account_lifecycle_coordinator return await account_lifecycle_coordinator.feishu_force_logout( self.db, person, ) async def reenforce_session_cutoff( self, process_id: UUID, *, trace_id: str, actor_user_id: UUID | None, ) -> OffboardingProcess: """Re-run the identity-plane session/account cutoff for a leaver (Google ``signOut`` + account suspend, Feishu) and re-record the per-channel ``session_enforcement`` snapshot. This is the operator "retry" for a channel that previously failed — e.g. the ``users.signOut`` 403 scope gap the P0051 incident exposed. Idempotent: succeeded channels return ``ok``/``already_absent`` again; the merge in ``_push_force_logout`` never clobbers a prior success. Refuses on a cancelled process — a mis-flagged leaver (PROC_CANCELLED) must never have their live sessions/account cut (mirrors the post-commit dispatch guard). Deliberately ALLOWED on PROC_COMPLETED (review round-1 decision, 2026-07-04): a completed offboarding whose ``google_session`` proof says ``failed`` is precisely the case the operator must be able to fix — the person is genuinely gone, ``signOut``/suspend are idempotent, and blocking here would force an out-of-band script. The ``hris_status_divergence``/``offboarding_session_not_cut`` data-quality checks route the operator to this retry. Refuses (409) on two ACTIVE-employee states the cancelled check misses — a rehired person (``employment_status='active'`` with a stale COMPLETED row) and a future-dated leaver (before their local last working day) — because both would otherwise let this endpoint suspend a still-employed person's account.""" proc = await self._get_process(process_id, lock=True) if proc.status == PROC_CANCELLED: raise AppException( code="PROCESS_CANCELLED", message=( "Cannot re-enforce session cutoff on a cancelled " "offboarding process." ), status_code=409, ) # Two ACTIVE-employee guards mirroring dispatch_post_commit_actions # (the automated path hardened these; the manual retry must not be a # back door around them — _push_force_logout SUSPENDS the Google # account, not just kills a session): # (1) Rehire / boomerang — a COMPLETED offboarding row survives a # rehire (start_process only opens a NEW process on the next # termination), so a person can be employment_status='active' # again while this row is still COMPLETED and retry is allowed. # Suspending them locks a working employee out of Google+Helix. # (2) Future-dated leaver — HR set termination_date weeks out; # force-logout is deferred until the leaver's local last working # day (the Thalie incident guard, ~post_commit leaver_date gate). # The retry button renders while session_enforcement is NULL, so # an operator click would cut a still-employed person early. from app.identity.person_models import IdentityPerson # noqa: PLC0415 person = await self.db.get(IdentityPerson, proc.person_id) if person is not None and person.employment_status == "active": raise AppException( code="PERSON_ACTIVE", message=( "Refusing to re-enforce session cutoff: this person is an " "active employee (rehired / mis-flagged leaver). Cutting " "their live sessions would lock out a working employee." ), status_code=409, ) if ( person is not None and proc.leaver_date is not None and _local_today_for_person(person) <= proc.leaver_date ): raise AppException( code="LEAVER_DATE_NOT_REACHED", message=( "Refusing to re-enforce session cutoff before the leaver's " "last working day (leaver_date, in their local timezone). " "The cutoff runs automatically once that day is reached." ), status_code=409, ) await self._push_force_logout(proc.person_id, trace_id=trace_id) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.session_reenforced", status="success", resource_type="offboarding_process", resource_id=str(process_id), detail={"person_id": str(proc.person_id)}, ) await self.db.refresh(proc, ["session_enforcement"]) return proc async def catchup_deferred_session_cutoff( self, process_id: UUID, *, trace_id: str, ) -> bool: """Replay the deferred identity-plane session/account cutoff for a leaver whose ``leaver_date`` has now arrived but whose ``session_enforcement`` ledger is still NULL (the cutoff was deferred at post-commit time and never replayed). Review 2026-07-08 fix (#1739). ``dispatch_post_commit_actions`` returns early with ``deferred_until_leaver_date`` for a future-dated leaver and leaves ``session_enforcement`` NULL, trusting "the T+0 flow will call this again after leaver_date". But nothing did: the HRIS confirm / MCP mark-leaver dispatch fires exactly once (events are idempotent, a T+0 status flip mints no new event), and ``ProcessAdvanceService._advance_single`` only runs suspend_sso → flip_to_left → complete_process — it never invoked ``_push_force_logout``. Result: the leaver's Google ``users.signOut`` + account suspend silently never happened AND the NULL ledger made the ``offboarding_session_not_cut`` scan blind to it. This method is the missing replay, invoked from the advance finalisation tail. Returns ``True`` if it attempted a cutoff replay (a ledger was written), ``False`` if it skipped (already attempted, cancelled, rehired, or not yet at leaver_date). Idempotent + safe to call every tick: the ``session_enforcement is None`` guard means a process that already has a ledger (success OR failure) is left untouched — a recorded failure is owned by the data-quality scan + the manual ``reenforce_session_cutoff`` retry, not re-fired here. """ proc = await self._get_process(process_id, lock=True) # Only genuinely-deferred processes: a NULL ledger means the cutoff # was never attempted. Any non-NULL value (even a failed channel) is # left to reenforce_session_cutoff / the data-quality scan. if proc.session_enforcement is not None: return False if proc.status == PROC_CANCELLED: return False from app.identity.person_models import IdentityPerson # noqa: PLC0415 person = await self.db.get(IdentityPerson, proc.person_id) if person is None or person.deleted_at is not None: # _push_force_logout stamps a skip marker for this case; but for # the catch-up we simply skip — a deleted person has no session. return False # Mirror the reenforce / dispatch guards: never cut an active # (rehired / mis-flagged) employee, and never cut before the # leaver's local last working day. if person.employment_status == "active": return False if ( proc.leaver_date is not None and _local_today_for_person(person) <= proc.leaver_date ): return False await self._push_force_logout(proc.person_id, trace_id=trace_id) await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.session_cutoff_catchup_replayed", status="success", resource_type="offboarding_process", resource_id=str(process_id), detail={ "person_id": str(proc.person_id), "work_email": person.work_email, "leaver_date": ( proc.leaver_date.isoformat() if proc.leaver_date else None ), "reason": ( "deferred future-dated leaver reached leaver_date with " "NULL session_enforcement — replaying force-logout" ), }, ) return True # ── Terminal-delete data-handover gate (2026-08-13) ─────────────── # # The hard delete is where a leaver's vendor-side DATA dies: Google # Drive/Calendar content and Zoom cloud recordings live under the # account being removed. Until this gate existed, both delete sweeps # ran on the assumption that "the blocker cascade already transferred # every owned resource before the process completed". That assumption # is false in two routine cases, and both bit production: # # * ``google.seat_sync`` auto-revokes the Helix Google Workspace seat # the morning after the account stops being active in the tenant. # A leaver whose account was suspended by hand before Helix's # offboarding ran therefore has NO seat row when the scan builds # blockers — so the ``user_suspend`` revoke (the ONLY path that # transferred Drive/Calendar) never fires. alison.u@pingcap.com was # hard-deleted on 2026-08-12 with zero transfer. # * A blocker that DID exist is ``[stale_after_rescan]``-skipped when # the seat vanishes mid-flight. hice@pingcap.com, 2026-08-02. # # Zoom is worse: its seat binding is ``action_on_offboard=disassociate``, # which never transfers anything, and Zoom deletes are PERMANENT (no # ~20-day undelete window like Google's). Every hosted recording died # with the account. # # So: prove the handover, or perform it here, or refuse to delete. # "Better a stuck account than silently destroyed data." # ``offboarding_reminder_log.reminder_kind`` values (String(32), unique # per process) used to make the IT page-out idempotent — one alert per # process per channel, not one per cron tick forever. _REMINDER_DELETE_BLOCKED_GOOGLE = "del_blocked_google" _REMINDER_DELETE_BLOCKED_ZOOM = "del_blocked_zoom" # A recipient Zoom itself rejects is a DIFFERENT block from "nobody # resolved", and gets its own kind so a process already paged for one # still pages for the other. (process_id, reminder_kind) is unique. _REMINDER_DELETE_BLOCKED_ZOOM_RECIPIENT = "del_blocked_zoom_recipient" # Raised once per process when the daily reconciler still cannot find # anyone to hand the leaver's data to. _REMINDER_HANDOVER_UNRESOLVED = "handover_unresolved" # Consecutive Google data-transfer failures before IT is paged. One # failure is often a transient vendor blip and the next nightly run # retries with a fresh transfer; a run of them is a real block that # nobody would otherwise see (the per-run signal is an audit row). _GOOGLE_TRANSFER_FAILURE_ALERT_AFTER = 3 async def _terminal_successor(self, proc, person): """Resolve who inherits ``person``'s data before a hard delete.""" from app.domains.offboarding.successor_resolver import ( # noqa: PLC0415 resolve_terminal_successor, ) return await resolve_terminal_successor( self.db, leaver=person, default_handover_person_id=proc.default_handover_person_id, ) @staticmethod def _zoom_transfer_refusal(result: dict[str, Any]) -> str | None: """Which transfer-recipient refusal, if any, a failed Zoom delete carries — ``ZOOM_TRANSFER_RECIPIENT_NO_WEBINAR``, ``ZOOM_TRANSFER_RECIPIENT_INVALID``, or ``None``. Reads the driver-stamped tag the coordinator now forwards. The message fallback below is defence in depth for a result built by a path that predates the tag; it is deliberately narrow, and it is NOT the primary signal. Why a tag and not a substring: until 2026-09-06 this method was a hand-written match on one sentence (``"data receiver does not have zoom webinar"``), so it recognised exactly the one refusal production had already shown us. Every OTHER recipient refusal — chiefly "the data receiver does not exist", which any successor without a Zoom account produces — fell through to the generic ``failed`` branch and re-failed the cron every night, forever, with no path to a terminal state. The tag makes the whole family legible at the seam where it is generated. """ tag = result.get("transfer_refusal") if isinstance(tag, str) and tag: return tag from app.integrations.drivers.zoom_software import ( # noqa: PLC0415 _classify_zoom_transfer_error, ) return _classify_zoom_transfer_error(str(result.get("error") or "")) async def _notify_delete_blocked( self, proc, person, *, channel: str, reminder_kind: str, reason_zh: str, reason_en: str, reason_ja: str, trace_id: str, headline_zh: str = "缺少数据交接人", ) -> None: """Page IT once when a terminal delete is refused for lack of a handover, and record the page so later ticks stay quiet. Deliberately IT-only (never the leaver, never their manager): this is an operational block, and the fan-out discipline for new pushes is "IT-only unless the employee can act on it". Failures here are swallowed — a notification problem must not convert a *safe* refusal into an unhandled exception that aborts the sweep for everyone else. The refusal diagnosis arrives as a locale-fragment triple rather than one string: it is prose, not data, so splicing a single-language ``reason`` into the ``offboarding_delete_blocked_no_successor`` template would put a Chinese clause inside a Japanese admin's card. ``reason_zh`` is what the ledger and the log keep — those are read by whoever is debugging, not by a notification recipient. """ from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) # Notification delivery and its idempotency receipt share this session # with the terminal-delete sweep. Isolate the entire optional block so # a failed outbox flush/delivery rolls back only its own writes; the # refusal ledger and audit record must remain committable. # # The ENQUEUE belongs in the savepoint; the PUSH cannot be — see # ``deliver_enqueued``: ``deliver()`` commits, and a commit inside # ``begin_nested()`` closes the block's transaction and strands the # row in ``delivering`` → ``delivery_uncertain`` (never sent, never # auto-resent). This alert has not fired in production yet, but it # had the identical shape to the handover push that lost all 8 of # its rows between 2026-08-13 and 08-28. from app.notifications import deliver_enqueued # noqa: PLC0415 queued: list[tuple[Any, Any]] = [] async with best_effort( self.db, label=f"notify blocked {channel} terminal delete", log=logger, ) as outcome: already = ( await self.db.execute( select(OffboardingReminderLog.id).where( OffboardingReminderLog.process_id == proc.id, OffboardingReminderLog.reminder_kind == reminder_kind, ) ) ).scalars().first() if already is not None: return admins = await self._active_admin_recipients() if not admins: logger.warning( "offboarding: %s delete blocked for %s (%s) but no " "active admin to notify", channel, person.work_email, reason_zh, ) return from app.notifications import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) notify = NotificationService(self.db) # ``headline_zh`` names WHICH block this is. The default is the # original no-successor wording; a recipient the vendor rejects # is a different problem with a different fix (name someone # else / give them an account), and a title that called it # "缺少数据交接人" would send IT looking for a person who is # already there. The body copy stays in the template. title = ( f"[离职删号已暂停] {person.display_name or person.work_email} " f"的 {channel} 账号{headline_zh}" ) for admin in admins: env = NotificationEnvelope( recipient=admin, title=title, # The copy lives in the template (zh/en/ja) — the sentinel # tells the bell to re-render it in the reader's language # instead of showing this producer's Chinese literal. body_text="(see template)", channels=["feishu", "email"], template_key="offboarding_delete_blocked_no_successor", context={ "leaver_name": ( person.display_name or person.work_email or "" ), "leaver_email": person.work_email or "", "channel": channel, "reason_zh": reason_zh, "reason_en": reason_en, "reason_ja": reason_ja, }, url=f"/admin/offboarding/process/{proc.id}", related_resource_type="offboarding_process", related_resource_id=proc.id, ) queued.append((await notify.enqueue(env), env)) self.db.add( OffboardingReminderLog( id=uuid4(), process_id=proc.id, reminder_kind=reminder_kind, channels=["feishu", "email"], detail={ "channel": channel, "reason": reason_zh, "work_email": person.work_email, }, ) ) await self.db.flush() if outcome.error is None: await deliver_enqueued( self.db, queued, trace_id=trace_id, log=logger, ) async def _active_admin_recipients(self) -> list[IdentityPerson]: """Active, non-deleted global_admin / it_admin persons. A departed admin can still hold a role row; sending leaver-naming operational alerts to them is both noise and a small disclosure. """ from app.auth.models import AuthRole, AuthUserRole # noqa: PLC0415 admin_ids = list(( await self.db.execute( select(AuthUserRole.user_id) .join(AuthRole, AuthRole.id == AuthUserRole.role_id) .where(AuthRole.name.in_(("global_admin", "it_admin"))) .distinct() ) ).scalars().all()) admins: list[IdentityPerson] = [] seen: set[UUID] = set() for aid in admin_ids: if aid in seen: continue seen.add(aid) admin = await self.db.get(IdentityPerson, aid) if ( admin is not None and admin.deleted_at is None and (admin.employment_status or "").lower() == "active" and (admin.work_email or "").strip() ): admins.append(admin) return admins # ── Default-handover reconciler (2026-08-13) ───────────────────── # # ``start_process`` prefills ``default_handover_person_id`` from the # leaver's HRIS manager ONCE, at creation. If the manager link isn't # resolved yet at that moment — a routine gap, since # ``backfill_manager_links`` re-links late-arriving managers on a # later sync — the prefill comes up empty and NOTHING ever revisits # it. Production carried 12 completed and 2 in-flight processes with # no handover person, including ``hice@pingcap.com`` (manager NULL), # whose Google account was then deleted with nobody to hand the data # to. This sweep closes the loop: every actionable process gets # another chance at a handover person every day, and the cases that # genuinely cannot resolve become a ONE-TIME page-out to IT instead # of silence. async def reconcile_default_handovers( self, *, trace_id: str, batch_limit: int = 500, ) -> dict[str, Any]: """Backfill ``default_handover_person_id`` on processes that still have none, and page IT once for the ones that cannot resolve. Scope is deliberately narrow — a process is *actionable* when a handover person can still change an outcome: * open processes (scanning / blocked / ready), where the value prefills the per-blocker 「指定接手人」 modal, and * completed processes whose terminal vendor deletes have NOT all reached a terminal ledger state — those are exactly the accounts the delete gate is holding back for want of a successor. Cancelled processes, and completed ones whose deletes are all done, are skipped: writing a handover person there would be noise (and, once handover notifications ship, would ping a colleague about a leaver whose data disposition finished months ago). Idempotent and safe on every tick: it only ever fills a NULL, and the unresolved page-out is deduped per process via ``offboarding_reminder_log``. """ summary: dict[str, Any] = { "scanned": 0, "backfilled": 0, "unresolved": 0, "skipped": 0, # Backfill arm: handover people who already held one but were # never told (set before the notification existed, or by the # then-silent prefill). "notified_existing": 0, "errors": [], } candidates = ( await self.db.execute( select(OffboardingProcess) .where( OffboardingProcess.default_handover_person_id.is_(None), OffboardingProcess.status != PROC_CANCELLED, ) .order_by(OffboardingProcess.leaver_date.desc()) .limit(batch_limit) ) ).scalars().all() newly_unresolved: list[tuple[OffboardingProcess, IdentityPerson]] = [] just_backfilled: set[UUID] = set() # Each candidate is reconciled inside its own savepoint, and # ``deliver()`` commits — the two cannot be nested (a commit under an # open SAVEPOINT closes the block's transaction, which is how every # handover push between 2026-08-13 and 08-28 was lost). So arm 1 # only ENQUEUES; the pushes drain below, after the loop. pending_pushes: list[tuple[Any, Any]] = [] for proc in candidates: async with best_effort( self.db, label=f"reconcile default handover for process {proc.id}", log=logger, ) as outcome: if not self._handover_still_matters(proc): summary["skipped"] += 1 continue person = await self.db.get(IdentityPerson, proc.person_id) if person is None or person.deleted_at is not None: summary["skipped"] += 1 continue # A rehired / mis-flagged employee needs no handover — the # cancel path restores their access instead. if (person.employment_status or "").lower() == "active": summary["skipped"] += 1 continue summary["scanned"] += 1 resolution = await self._terminal_successor(proc, person) if not resolution.ok: summary["unresolved"] += 1 newly_unresolved.append((proc, person)) continue proc.default_handover_person_id = resolution.person_id proc.updated_at = datetime.now(UTC) await self.db.flush() summary["backfilled"] += 1 await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.default_handover_backfilled", status="success", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "work_email": person.work_email, "leaver_date": ( proc.leaver_date.isoformat() if proc.leaver_date else None ), "process_status": proc.status, **resolution.as_detail(), }, ) just_backfilled.add(proc.id) await self.notify_handover_assigned( proc, person, successor_id=resolution.person_id, assigned_reason_key=( "auto_manager" if resolution.tier in ("manager", "manager_chain") else "auto_fallback" ), trace_id=trace_id, queue_into=pending_pushes, ) if not outcome.ok: summary["errors"].append(f"{proc.id}: {outcome.reason()}") if pending_pushes: from app.notifications import deliver_enqueued # noqa: PLC0415 await deliver_enqueued( self.db, pending_pushes, trace_id=trace_id, log=logger, ) if newly_unresolved: await self._notify_handover_unresolved( newly_unresolved, trace_id=trace_id, ) # ``just_backfilled`` excludes the rows arm 1 just handled. # # Strictly redundant once the receipt is durable (that fix is what # actually stopped the production double-push — mark.hedderman@ and # xianming.dou@ were each pinged twice in one run on 2026-08-13, # because arm 1's receipt was rolled back by a delivery failure and # arm 2 then saw "has handover, no receipt"). Kept as a cheap # second guard: it costs one set lookup, and the failure it covers # is spamming a colleague. Deliberately NOT claimed as # test-verified — with the receipt fix in place no honest test can # distinguish its presence, and a test that passes either way would # be worse than none. summary["notified_existing"] = await self._notify_unnotified_handovers( trace_id=trace_id, batch_limit=batch_limit, exclude_process_ids=just_backfilled, ) return summary async def _notify_unnotified_handovers( self, *, trace_id: str, batch_limit: int, exclude_process_ids: set[UUID] | None = None, ) -> int: """Push to handover people who were never told they hold one. The backfill arm of the reconciler. Two populations need it: * every process whose handover person was set BEFORE the notification existed (2026-08-13), and * every process prefilled by ``start_process`` while that path was still silent. Production example: 施宇航 / Yuhang Shi (leaver_date 2026-08-18) had 白瑜庆 prefilled as handover person and only a ``reminder_7d`` row to show for it — his lead had no idea the Drive / Calendar / email-group / API-key handover was pointed at him. Runs on the same daily tick as the NULL backfill and reuses ``notify_handover_assigned``'s per-(process, recipient) receipt, so a person who has already been told stays quiet. Only processes where the handover still matters are considered — a leaver whose disposition finished months ago must not generate a fresh ping. """ sent = 0 candidates = ( await self.db.execute( select(OffboardingProcess) .where( OffboardingProcess.default_handover_person_id.is_not(None), OffboardingProcess.status != PROC_CANCELLED, ) .order_by(OffboardingProcess.leaver_date.desc()) .limit(batch_limit) ) ).scalars().all() skip = exclude_process_ids or set() for proc in candidates: try: if proc.id in skip: continue # arm 1 already notified this one this run if not self._handover_still_matters(proc): continue person = await self.db.get(IdentityPerson, proc.person_id) if person is None or person.deleted_at is not None: continue # A rehired / mis-flagged employee is not being handed over. if (person.employment_status or "").lower() == "active": continue before = await self._handover_receipt_exists( proc.id, proc.default_handover_person_id, ) if before: continue await self.notify_handover_assigned( proc, person, successor_id=proc.default_handover_person_id, assigned_reason_key="existing", trace_id=trace_id, ) if await self._handover_receipt_exists( proc.id, proc.default_handover_person_id, ): sent += 1 except Exception: # noqa: BLE001 — one row must not stop the rest logger.exception( "offboarding: backfill handover notification failed for %s", proc.id, ) return sent async def _handover_receipt_exists( self, process_id: UUID, person_id: UUID | None, ) -> bool: """Has this (process, recipient) pair already been notified?""" if person_id is None: return False from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) row = ( await self.db.execute( select(OffboardingReminderLog.id).where( OffboardingReminderLog.process_id == process_id, OffboardingReminderLog.reminder_kind == f"hoa_{person_id.hex[:24]}", ) ) ).scalars().first() return row is not None # ── "You are the handover person" notification (2026-08-13) ────── # # Being named someone's handover person used to be entirely silent. # The value was set (prefill at process start, or IT picking a name in # the console), data started routing to that colleague — Drive and # Calendar ownership, email-group ownership, API keys — and they were # never told. Two failure modes followed from that: the recipient # doesn't go look at what they inherited, and a *wrong* recipient has # no way to notice and hand it to the right person. The push closes # both, and deliberately points at the self-service reassign page so # the fix does not have to route through IT. # Localised, because these are interpolated into ``{assigned_reason}`` of a # fully three-language template: as bare Chinese they put one Chinese line # in the middle of an otherwise-English card, which is what an ``en_US`` # handover person actually received. Built lazily so the module keeps its # deferred ``LocalizedText`` import (avoids a notifications import cycle). @staticmethod def _handover_reason_texts() -> dict[str, Any]: from app.notifications.service import LocalizedText # noqa: PLC0415 return { "admin_set": LocalizedText( zh="IT 在离职控制台指定", en="Assigned by IT in the offboarding console", ja="IT が退職コンソールで指定", ), "auto_manager": LocalizedText( zh="系统按直属上级自动指定", en="Auto-assigned by the system as the direct manager", ja="システムが直属の上長として自動指定", ), "auto_fallback": LocalizedText( zh="系统按组织兜底交接人自动指定", en=( "Auto-assigned by the system as the org fallback handover " "person" ), ja="システムが組織の既定引き継ぎ担当者として自動指定", ), "reassigned": LocalizedText( zh="由上一位交接人改派给你", en="Reassigned to you by the previous handover person", ja="前任の引き継ぎ担当者から引き継がれました", ), # ``start_process`` prefills the leaver's HRIS manager the moment # the offboarding opens — the single most common way anyone # becomes a handover person. "prefill": LocalizedText( zh="系统按直属上级自动指定(离职流程建立时)", en=( "Auto-assigned by the system as the direct manager when " "offboarding opened" ), ja="退職プロセス開始時にシステムが直属の上長として自動指定", ), # Backfill for processes whose handover person predates the # notification (they were set before 2026-08-13, or by the # prefill while it was still silent). "existing": LocalizedText( zh="你已是该离职流程的默认交接人", en=( "You were already the default handover person for this " "offboarding" ), ja="すでにこの退職プロセスの既定引き継ぎ担当者でした", ), } async def notify_handover_assigned( self, proc, leaver, *, successor_id: UUID | None, assigned_reason_key: str, trace_id: str, queue_into: list[tuple[Any, Any]] | None = None, ) -> None: """Tell ``successor_id`` they are now ``leaver``'s handover person. Never raises and never re-notifies the same (process, person) pair: the reconciler re-runs daily and IT may re-save the same name, and neither should re-ping a colleague who already knows. Skips the leaver themselves and inactive recipients. ``queue_into`` is for callers that are THEMSELVES inside a savepoint (``reconcile_default_handovers`` wraps each candidate in ``best_effort``): the enqueued ``(outbox, env)`` pair is appended to the list instead of delivered here, and the caller pushes it with ``deliver_enqueued`` once its block has closed. Without that, the push cannot run inline at all — ``deliver()`` commits, and a commit under an open SAVEPOINT closes the block's transaction — so it would fall back to the hourly retry worker. """ if successor_id is None: return from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) # Outbox row + idempotency receipt are written in the CALLER's # session — the admin's set-handover request, or the reconcile # sweep. Isolate them in a savepoint: a flush failure here would # otherwise poison that session and roll back the assignment the # notification is merely announcing, and a half-written receipt # would silence the retry. # # ``queued`` carries the enqueue out of the savepoint so delivery # can run after it commits; it stays None when any gate above # short-circuits, and nothing is delivered in that case. queued: tuple[Any, Any, Any] | None = None async with best_effort( self.db, label="notify offboarding handover assignee", log=logger, ): successor = await self.db.get(IdentityPerson, successor_id) if ( successor is None or successor.deleted_at is not None or (successor.employment_status or "").lower() != "active" or not (successor.work_email or "").strip() or successor.id == proc.person_id ): return # Dedupe per (process, recipient): ``reminder_kind`` is # String(32) and unique per process, so the recipient's id is # folded in as a short hex suffix. A reassign to a NEW person # therefore still notifies, while re-saving the same person # stays quiet. kind = f"hoa_{successor.id.hex[:24]}" already = ( await self.db.execute( select(OffboardingReminderLog.id).where( OffboardingReminderLog.process_id == proc.id, OffboardingReminderLog.reminder_kind == kind, ) ) ).scalars().first() if already is not None: return from app.notifications import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) from app.notifications.service import ( # noqa: PLC0415 LocalizedText, ) reason = self._handover_reason_texts().get( assigned_reason_key, assigned_reason_key, ) ctx = { "leaver_name": leaver.display_name or leaver.work_email or "", "leaver_email": leaver.work_email or "", "leaver_date": ( proc.leaver_date.isoformat() if proc.leaver_date else "-" ), "assigned_reason": reason, "receives_summary": LocalizedText( zh=( "Google Drive / 日历内容、邮件组 owner、API Key 等" "需要有人接手的资源" ), en=( "Google Drive / calendar content, mail-group " "ownership, API keys and anything else that needs a " "new owner" ), ja=( "Google Drive / カレンダーの内容、メーリングリストの " "owner、API キーなど、引き継ぎ先が必要なリソース" ), ), } notify = NotificationService(self.db) env = NotificationEnvelope( recipient=successor, title=f"[数据交接] 你是 {ctx['leaver_name']} 的交接人", body_text="(see template)", channels=["feishu", "email"], template_key="offboarding.handover_assigned", context=ctx, url="/me/handovers", related_resource_type="offboarding_process", related_resource_id=proc.id, ) # ⚠️ Order matters, and getting it wrong shipped to prod. # # The receipt records "we have DECIDED to tell this person and # durably queued the message" — not "delivery confirmed". The # outbox row is the durable artefact and # ``notification.outbox_retry`` owns redelivery, so the receipt # must land with the enqueue, BEFORE delivery is attempted. # # Writing it after ``deliver()`` (the original order) meant a # delivery hiccup rolled the receipt back to this savepoint # while the already-committed outbox row still went out — so # the message was sent and no receipt existed, and the daily # reconciler re-sent it every morning forever. Observed in # production 2026-08-13: 5 pushes, 0 receipts, two recipients # pinged twice inside a single run. outbox = await notify.enqueue(env) queued = (notify, outbox, env) self.db.add( OffboardingReminderLog( id=uuid4(), process_id=proc.id, reminder_kind=kind, channels=["feishu", "email"], detail={ "successor_person_id": str(successor.id), "successor_email": successor.work_email, "reason": assigned_reason_key, }, ) ) await self.db.flush() # Delivery is deliberately OUTSIDE the savepoint: a vendor failure # here must not undo the receipt (the outbox retry worker will # redeliver), and it must not undo the caller's own write either. # # ⚠️ "Outside the savepoint" means outside EVERY savepoint — not # inside a second ``best_effort``. ``deliver()`` commits, and a # commit inside ``begin_nested()`` closes the transaction the block # owns: the very next statement raised InvalidRequestError, the # claim was already durable, and the row rotted in ``delivering`` # until the reconciler marked it ``delivery_uncertain`` — which # never auto-resends. Result: from 2026-08-13 (when this delivery # moved into its own ``best_effort``) to 2026-08-28, EVERY handover # assignee push was silently lost — 8 rows in production, 0 # delivered. ``deliver_enqueued`` is the corrected shape. if queued is None: return _notify, _outbox, _env = queued if queue_into is not None: # The caller owns the push — it is still inside its own # savepoint and will drain this list after the block closes. queue_into.append((_outbox, _env)) return from app.notifications import deliver_enqueued # noqa: PLC0415 await deliver_enqueued( self.db, [(_outbox, _env)], trace_id=trace_id, log=logger, ) # ── Self-service: 「我的交接」 (2026-08-13) ────────────────────── # # The handover person is usually the leaver's lead, chosen by a rule # rather than by them. The lead frequently knows a better recipient # (the teammate who actually took over the work), but until now the # only way to change it was to ask IT to edit the process — so in # practice nobody did, and data landed on whoever the org chart said. # These two endpoints give the current handover person a first-class # way to see what they hold and hand it on themselves. async def list_my_handovers(self, user_id: UUID) -> list[dict[str, Any]]: """Offboarding processes where ``user_id`` is the handover person and the handover can still change something. Ordered soonest-deadline-first: processes whose vendor deletes are still pending are what the recipient can actually act on today. """ rows = ( await self.db.execute( select(OffboardingProcess) .where( OffboardingProcess.default_handover_person_id == user_id, OffboardingProcess.status != PROC_CANCELLED, ) .order_by(OffboardingProcess.leaver_date.desc()) ) ).scalars().all() items: list[dict[str, Any]] = [] for proc in rows: if not self._handover_still_matters(proc): continue leaver = await self.db.get(IdentityPerson, proc.person_id) if leaver is None or leaver.deleted_at is not None: continue items.append({ "process_id": proc.id, "leaver_person_id": leaver.id, "leaver_name": leaver.display_name, "leaver_email": leaver.work_email, "leaver_date": proc.leaver_date, "process_status": proc.status, # Surfaced so the recipient can see WHY it is still open — # "the Google account is waiting on me" is actionable in a # way that a bare status is not. # 「删号在等你」 must mean the delete can genuinely fire # today — see ``_delete_channel_pending``. An open process # has no delete scheduled at all, and telling a lead their # colleague's account is "waiting on you" months before the # last working day is exactly how a banner earns being # ignored. "google_delete_pending": self._delete_channel_pending( proc, "google_account_deletion", "google_account", ), "zoom_delete_pending": self._delete_channel_pending( proc, "zoom_account_deletion", "zoom_account", ), }) return items async def reassign_my_handover( self, process_id: UUID, *, current_user_id: UUID, new_person_id: UUID, trace_id: str, is_admin: bool = False, ) -> dict[str, Any]: """Hand a handover you hold to a colleague. Authorisation is deliberately narrow: only the CURRENT handover person may reassign (or a global_admin, who already has the console). Anything else 403s — this endpoint is reachable by every authenticated employee, so without that check any employee could redirect a departing colleague's Drive content to themselves. """ proc = await self._get_process(process_id) if not is_admin and proc.default_handover_person_id != current_user_id: raise AppException( code="NOT_HANDOVER_OWNER", message=( "Only the current handover person can reassign this " "handover." ), status_code=403, ) if proc.status == PROC_CANCELLED: raise AppException( code="PROCESS_CANCELLED", message="This offboarding was cancelled — nothing to hand over.", status_code=409, ) if new_person_id == proc.person_id: raise AppException( code="SELF_HANDOVER_NOT_ALLOWED", message=( "Cannot hand the leaver's data back to the leaver " "themselves." ), status_code=400, ) new_person = await self.db.get(IdentityPerson, new_person_id) if new_person is None or new_person.deleted_at is not None: raise AppException( code="SUCCESSOR_NOT_FOUND", message=f"Person {new_person_id} not found", status_code=400, ) if (new_person.employment_status or "").lower() != "active": raise AppException( code="SUCCESSOR_NOT_ACTIVE", message=( f"{new_person.work_email} is " f"{new_person.employment_status!r} — pick an active " "colleague." ), status_code=409, ) if not (new_person.work_email or "").strip(): raise AppException( code="SUCCESSOR_NO_EMAIL", message=( "That person has no work email — vendor data transfers " "address the recipient by email." ), status_code=409, ) prior_id = proc.default_handover_person_id proc.default_handover_person_id = new_person_id proc.updated_at = datetime.now(UTC) await self.db.flush() leaver = await self.db.get(IdentityPerson, proc.person_id) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=current_user_id, action="offboarding.handover_reassigned", status="success", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "leaver_email": leaver.work_email if leaver else None, "prior_handover_person_id": str(prior_id) if prior_id else None, "new_handover_person_id": str(new_person_id), "via": "admin" if is_admin else "self_service", }, ) if leaver is not None: await self.notify_handover_assigned( proc, leaver, successor_id=new_person_id, assigned_reason_key="reassigned", trace_id=trace_id, ) await self._notify_reassign_ack( proc, leaver, prior_id, new_person, trace_id=trace_id, ) return { "process_id": proc.id, "new_handover_person_id": new_person_id, "new_handover_person_name": new_person.display_name, "new_handover_person_email": new_person.work_email, } async def _notify_reassign_ack( self, proc, leaver, prior_id: UUID | None, new_person, *, trace_id: str, ) -> None: """Confirm to the PREVIOUS handover person that the reassign landed. Without it, the person who just handed something off has no record that it actually moved — and a mis-click silently routes a colleague's data somewhere they never intended. """ if prior_id is None or prior_id == new_person.id: return from app.notifications import deliver_enqueued # noqa: PLC0415 # Enqueue inside the savepoint, push after it — ``deliver()`` # commits and cannot run inside ``begin_nested`` (see # ``deliver_enqueued``). queued: list[tuple[Any, Any]] = [] async with best_effort( self.db, label="ack offboarding handover reassign", log=logger, ) as outcome: prior = await self.db.get(IdentityPerson, prior_id) if ( prior is None or prior.deleted_at is not None or (prior.employment_status or "").lower() != "active" ): return from app.notifications import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) leaver_name = leaver.display_name or leaver.work_email or "" ctx = { "leaver_name": leaver_name, "new_owner_name": ( new_person.display_name or new_person.work_email or "" ), "new_owner_email": new_person.work_email or "", } notify = NotificationService(self.db) env = NotificationEnvelope( recipient=prior, title=( f"[数据交接] 已改派 {leaver_name} 的交接给 " f"{ctx['new_owner_name']}" ), body_text="(see template)", channels=["feishu", "email"], template_key="offboarding.handover_reassigned_ack", context=ctx, url="/me/handovers", related_resource_type="offboarding_process", related_resource_id=proc.id, ) queued.append((await notify.enqueue(env), env)) if outcome.error is None: await deliver_enqueued( self.db, queued, trace_id=trace_id, log=logger, ) # Account channel that each deletion channel depends on. A terminal # delete only ever fires for an account the two-stage flow confirmed # DARK (``suspended``) — see the ``acct_status != "suspended"`` gate in # both ``_delete_*_if_due``. _DELETION_CHANNELS: tuple[tuple[str, str], ...] = ( ("google_account_deletion", "google_account"), ("zoom_account_deletion", "zoom_account"), ) def _delete_channel_pending(self, proc, deletion_key: str, account_key: str) -> bool: """True when ``deletion_key``'s hard delete can still actually fire. Three states are NOT pending, and conflating them was a real bug: * a terminal ledger (deleted / already_absent / retained); * an OPEN process — the delete is not scheduled at all until the process completes, so nothing is waiting on anyone yet; * a completed process whose account-level SUSPEND never ran. The delete gate refuses to act without `` == 'suspended'``, so such a channel will never fire. Production carries 9 such processes (leaver dates 2026-04-21 … 2026-07-03) left by the pre-two-stage flow, all stamped ``skipped_not_suspended``. Counting those as "pending" put long-closed offboardings back in scope and would have pushed "you are the handover person for " to 9 colleagues on the first reconcile after deploy. """ if proc.status != PROC_COMPLETED: return False enforcement = proc.session_enforcement or {} ledger = enforcement.get(deletion_key) status = ledger.get("status") if isinstance(ledger, dict) else None if status in self._DELETION_TERMINAL: return False acct = enforcement.get(account_key) acct_status = acct.get("status") if isinstance(acct, dict) else None return acct_status == "suspended" def _handover_still_matters(self, proc) -> bool: """True when naming a handover person can still change an outcome. Open processes always qualify — the value prefills the per-blocker 「指定接手人」 modal. A completed process qualifies only while a terminal vendor delete can still genuinely fire against it (see ``_delete_channel_pending``); a legacy process whose accounts were never put through the suspend→delete flow is finished business. """ if proc.status != PROC_COMPLETED: return True return any( self._delete_channel_pending(proc, deletion_key, account_key) for deletion_key, account_key in self._DELETION_CHANNELS ) async def _notify_handover_unresolved( self, pairs: list[tuple[Any, Any]], *, trace_id: str, ) -> None: """One digest to IT listing leavers with no resolvable handover person — each process appears at most once, ever. Deliberately a single digest rather than one push per process: a bulk historical import would otherwise fan out dozens of pushes in one tick. IT-only; the leaver's colleagues are not paged about an internal data-routing gap they cannot fix. """ from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) # The digest + its per-process receipts share this session with the # reconcile sweep. Isolate the whole optional block in a savepoint so # a failed outbox flush/delivery rolls back only its own writes — a # bare try/except here would leave the session poisoned and take the # backfills (the actual work) down with it, and a half-written # receipt would silence tomorrow's retry. from app.notifications import deliver_enqueued # noqa: PLC0415 # Enqueue inside the savepoint, push after it — ``deliver()`` # commits and cannot run inside ``begin_nested`` (see # ``deliver_enqueued``). queued: list[tuple[Any, Any]] = [] async with best_effort( self.db, label="notify unresolved offboarding handovers", log=logger, ) as outcome: process_ids = [p.id for p, _ in pairs] already = set(( await self.db.execute( select(OffboardingReminderLog.process_id).where( OffboardingReminderLog.process_id.in_(process_ids), OffboardingReminderLog.reminder_kind == self._REMINDER_HANDOVER_UNRESOLVED, ) ) ).scalars().all()) fresh = [(p, person) for p, person in pairs if p.id not in already] if not fresh: return admins = await self._active_admin_recipients() if not admins: logger.warning( "offboarding.reconcile_default_handovers: %d leavers " "without a resolvable handover person and no active " "admin to notify", len(fresh), ) return from app.notifications import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) # Language-neutral detail block: name, email and the ISO leaver # date only. The prose around it is the template's job, so the # same block drops into the zh / en / ja body without leaving a # Chinese fragment inside a Japanese card (same discipline as # ``jira.*``'s ``sample_block``). listed = "\n".join( f" · {person.display_name or person.work_email} " f"({person.work_email}) — " f"{proc.leaver_date.isoformat() if proc.leaver_date else '-'}" for proc, person in fresh[:20] ) if len(fresh) > 20: listed += f"\n · ... (+{len(fresh) - 20} more)" notify = NotificationService(self.db) for admin in admins: env = NotificationEnvelope( recipient=admin, title=f"[离职交接] {len(fresh)} 位离职者缺少数据交接人", body_text="(see template)", channels=["feishu", "email"], template_key="offboarding_handover_unresolved", context={"count": len(fresh), "leaver_list": listed}, url="/admin/offboarding?tab=progress", related_resource_type="offboarding_process", related_resource_id=fresh[0][0].id, ) queued.append((await notify.enqueue(env), env)) for proc, person in fresh: self.db.add( OffboardingReminderLog( id=uuid4(), process_id=proc.id, reminder_kind=self._REMINDER_HANDOVER_UNRESOLVED, channels=["feishu", "email"], detail={"work_email": person.work_email}, ) ) await self.db.flush() if outcome.error is None: await deliver_enqueued( self.db, queued, trace_id=trace_id, log=logger, ) async def _prior_google_transfer_evidence(self, proc, person) -> dict[str, Any]: """Evidence that this leaver's Drive/Calendar already moved. Two sources, because the ledger post-dates the fix: 1. ``session_enforcement.google_data_transfer`` — written by this gate; also carries an in-flight ``transfer_id`` to resume. 2. A completed Google Workspace seat-revoke provisioning task (``external_ref='gw-user:'``) whose vendor response recorded a ``data_transfer_status='completed'``. This is how every leaver processed through the normal seat path before 2026-08-13 proved their handover — without reading it we would re-run a redundant transfer for them (harmless but slow and confusing in the Google console). """ ledger = (proc.session_enforcement or {}).get("google_data_transfer") if isinstance(ledger, dict): if ledger.get("status") in ("completed", "source_absent"): return { "ok": True, "source": "ledger", "transfer_id": ledger.get("transfer_id"), "successor_email": ledger.get("successor_email"), } # Resume ONLY an in-flight transfer. Google's async transfer # can outlive our poll budget, and re-initiating would queue a # duplicate — that is what the resume exists for. A transfer # Google has already declared ``failed`` is dead: polling it # again returns ``failed`` forever, so resuming one wedged the # account permanently (never deleted, never re-transferred, # never escalated). A failed ledger therefore falls through and # a FRESH transfer is initiated below. if ( ledger.get("status") == "in_progress" and ledger.get("transfer_id") ): return { "ok": False, "resume_transfer_id": str(ledger["transfer_id"]), "successor_email": ledger.get("successor_email"), } if ledger.get("status") == "failed": return { "ok": False, "prior_failures": int(ledger.get("failure_count") or 1), } from app.domains.provisioning.models import ( # noqa: PLC0415 STATUS_COMPLETED, ProvisioningTask, ) email = (person.work_email or "").strip() if email: tasks = ( await self.db.execute( select(ProvisioningTask).where( ProvisioningTask.user_id == person.id, ProvisioningTask.action == "revoke", ProvisioningTask.status == STATUS_COMPLETED, ProvisioningTask.external_ref == f"gw-user:{email}", ) ) ).scalars().all() for task in tasks: ctx = task.context_data or {} vendor = ctx.get("vendor_response") or {} if vendor.get("data_transfer_status") == "completed": return { "ok": True, "source": "seat_revoke_task", "transfer_id": vendor.get("data_transfer_id"), "successor_email": ctx.get("successor_email"), } if vendor.get("skipped") == "source_user_already_gone": # Account was already gone at seat-revoke time — nothing # was ever there to transfer. return {"ok": True, "source": "source_already_gone"} return {"ok": False} async def _ensure_google_handover( self, proc, person, *, trace_id: str, ) -> dict[str, Any]: """Gate the Google hard delete on a completed Drive/Calendar handover. Returns ``{"ok": True, "audit_detail": {...}}`` when the delete may proceed, or ``{"ok": False, "ledger_status": ..., "error": ...}`` when it must not. Every non-ok status is NON-terminal on purpose: the account stays suspended (already dark, so no access risk) and the next sweep retries once IT supplies a successor or Google finishes the transfer. """ now_iso = datetime.now(UTC).isoformat() def _record_transfer(entry: dict[str, Any]) -> None: merged = dict(proc.session_enforcement or {}) merged["google_data_transfer"] = {**entry, "at": now_iso} merged["updated_at"] = now_iso proc.session_enforcement = merged evidence = await self._prior_google_transfer_evidence(proc, person) if evidence.get("ok"): return { "ok": True, "audit_detail": { "data_transfer": "verified", "data_transfer_source": evidence.get("source"), "data_transfer_id": evidence.get("transfer_id"), "successor_email": evidence.get("successor_email"), }, } resolution = await self._terminal_successor(proc, person) if not resolution.ok: _record_transfer({ "status": "blocked_no_successor", **resolution.as_detail(), }) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_account_delete_blocked_no_successor", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "process_id": str(proc.id), **resolution.as_detail(), }, ) await self._notify_delete_blocked( proc, person, channel="Google Workspace", reminder_kind=self._REMINDER_DELETE_BLOCKED_GOOGLE, reason_zh=( "未能解析出有效交接人" "(默认交接人 / 上级链 / 平台兜底人均为空)" ), reason_en=( "no successor resolved — default handover person, manager " "chain and platform fallback are all empty" ), reason_ja=( "引き継ぎ担当者を特定できません" "(デフォルト担当者・上長チェーン・全社フォールバックのいずれも未設定)" ), trace_id=trace_id, ) return { "ok": False, "ledger_status": "skipped_no_successor", "error": "no successor resolved for Drive/Calendar handover", } from app.core.coordinators import account_lifecycle_coordinator outcome = await account_lifecycle_coordinator.ensure_google_data_transfer( self.db, person, successor_email=resolution.email or "", existing_transfer_id=evidence.get("resume_transfer_id"), ) status = outcome.get("status") if status in ("completed", "source_absent"): _record_transfer({ "status": status, "transfer_id": outcome.get("transfer_id"), **resolution.as_detail(), }) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_data_transfer_completed", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "process_id": str(proc.id), "transfer_id": outcome.get("transfer_id"), "outcome": status, **resolution.as_detail(), }, ) return { "ok": True, "audit_detail": { "data_transfer": status, "data_transfer_source": "delete_gate", "data_transfer_id": outcome.get("transfer_id"), "successor_email": resolution.email, "successor_tier": resolution.tier, }, } # in_progress / failed / skipped — do NOT delete. An ``in_progress`` # transfer id is persisted so the next tick resumes the SAME # transfer rather than queueing a duplicate; a ``failed`` one is # persisted with a failure count so the next tick starts a fresh # transfer and so repeated failures become visible instead of # looping silently. failure_count = int(evidence.get("prior_failures") or 0) if status == "failed": failure_count += 1 _record_transfer({ "status": status, "transfer_id": outcome.get("transfer_id"), "error": str(outcome.get("error"))[:300] if outcome.get("error") else None, "failure_count": failure_count or None, **resolution.as_detail(), }) await self.db.flush() if status == "failed": await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_data_transfer_failed", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "process_id": str(proc.id), "error": str(outcome.get("error"))[:300], "failure_count": failure_count, **resolution.as_detail(), }, ) # An audit row is not a signal anybody watches. Once the # transfer has failed repeatedly it is not a transient vendor # blip — page IT, once, exactly as the no-successor refusal # does. Without this, a permanently-failing transfer left the # account suspended forever with nobody told. if failure_count >= self._GOOGLE_TRANSFER_FAILURE_ALERT_AFTER: await self._notify_delete_blocked( proc, person, channel="Google Workspace", reminder_kind=self._REMINDER_DELETE_BLOCKED_GOOGLE, # Vendor error text stays verbatim in all three variants — # it is Google's own string, not ours to translate. reason_zh=( f"Drive / 日历转移已连续失败 {failure_count} 次:" f"{str(outcome.get('error'))[:160]}" ), reason_en=( f"the Drive / calendar transfer has failed " f"{failure_count} times in a row: " f"{str(outcome.get('error'))[:160]}" ), reason_ja=( f"Drive・カレンダーの移管が {failure_count} 回連続で" f"失敗しました:{str(outcome.get('error'))[:160]}" ), trace_id=trace_id, ) return { "ok": False, "ledger_status": "skipped_transfer_incomplete", "error": outcome.get("error"), } # ── T-1 terminal-delete preview digest (2026-08-13) ────────────── # # The two delete sweeps run at 03:00 / 03:10 UTC and, on success, tell # nobody: an account is permanently gone (Zoom) or entering a ~20-day # recovery window (Google) with no human ever having been given a # chance to say "wait". The failure notifier only fires on errors, so # the *correct* path was the silent one — which is backwards for an # irreversible action. # # This job is the heads-up: one IT-only digest, the day before, naming # each account and — the part that matters — whether its handover is # actually in place. It also states the thing no API can fix: # # ⚠️ Gmail message content is NOT transferable. Google's Data # Transfer API moves Drive and Calendar only. Whatever is in the # mailbox dies with the account. If a mailbox must be preserved, # that decision has to be made HERE, by a human, before the # delete — by marking the account retained in the external # account registry. # # One heads-up per account, ever (not per day): a delete that stays # blocked already has its own one-time page-out, and repeating this # digest daily would train people to ignore it. _REMINDER_DELETE_PREVIEW = "del_preview" async def preview_pending_terminal_deletes( self, *, trace_id: str, google_lead_days: int = DEFAULT_GOOGLE_DELETE_LEAD_DAYS, zoom_lead_days: int = DEFAULT_ZOOM_DELETE_LEAD_DAYS, ) -> dict[str, Any]: """Warn IT about vendor-account deletes that come due TOMORROW. Returns a counter summary. Never raises for a per-row problem — a heads-up job that dies takes the warning down with it, which is strictly worse than the pre-2026-08-13 silence. """ from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) summary: dict[str, Any] = { "scanned": 0, "due_tomorrow": 0, "notified": 0, "already_warned": 0, "errors": [], } utc_today = datetime.now(UTC).date() # Widest possible prefilter: a leaver in UTC+14 can be a calendar # day ahead of UTC, so include one extra day on each side and let # the per-person local-date check below be the exact gate. max_lead = max(google_lead_days, zoom_lead_days) candidate_ids = ( await self.db.execute( select(OffboardingProcess.id).where( OffboardingProcess.status == PROC_COMPLETED, OffboardingProcess.leaver_date >= utc_today - timedelta(days=max_lead + 2), OffboardingProcess.leaver_date <= utc_today + timedelta(days=2), ).order_by(OffboardingProcess.leaver_date) ) ).scalars().all() rows: list[dict[str, Any]] = [] for pid in candidate_ids: async with best_effort( self.db, label=f"prepare terminal-delete preview for process {pid}", log=logger, ) as outcome: proc = await self.db.get(OffboardingProcess, pid) if proc is None or proc.leaver_date is None: continue person = await self.db.get(IdentityPerson, proc.person_id) if person is None or person.deleted_at is not None: continue if (person.employment_status or "").lower() == "active": continue # rehired — the delete will not fire summary["scanned"] += 1 local_tomorrow = _local_today_for_person(person) + timedelta(days=1) enforcement = proc.session_enforcement or {} channels: list[str] = [] if self._delete_due_on( proc, enforcement, "google_account_deletion", google_lead_days, local_tomorrow, ): channels.append("Google") if self._delete_due_on( proc, enforcement, "zoom_account_deletion", zoom_lead_days, local_tomorrow, ): channels.append("Zoom") if not channels: continue summary["due_tomorrow"] += 1 already = ( await self.db.execute( select(OffboardingReminderLog.id).where( OffboardingReminderLog.process_id == proc.id, OffboardingReminderLog.reminder_kind == self._REMINDER_DELETE_PREVIEW, ) ) ).scalars().first() if already is not None: summary["already_warned"] += 1 continue resolution = await self._terminal_successor(proc, person) evidence = await self._prior_google_transfer_evidence(proc, person) rows.append({ "proc": proc, "person": person, "channels": channels, "successor": resolution, "drive_verified": bool(evidence.get("ok")), }) if not outcome.ok: summary["errors"].append(f"{pid}: {outcome.reason()}") if rows: sent = await self._notify_delete_preview(rows, trace_id=trace_id) summary["notified"] = sent return summary def _delete_due_on( self, proc: OffboardingProcess, enforcement: dict[str, Any], ledger_key: str, lead_days: int, target_local_date: date_type, ) -> bool: """True when ``ledger_key``'s delete is scheduled for ``target_local_date`` and has not already reached a terminal state.""" ledger = enforcement.get(ledger_key) status = ledger.get("status") if isinstance(ledger, dict) else None if status in self._DELETION_TERMINAL: return False return proc.leaver_date + timedelta(days=lead_days) == target_local_date async def _notify_delete_preview( self, rows: list[dict[str, Any]], *, trace_id: str, ) -> int: """Send the digest and stamp one receipt per account. IT-only.""" from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) from app.notifications import deliver_enqueued # noqa: PLC0415 sent = 0 # Enqueue inside the savepoint, push after it — ``deliver()`` # commits and cannot run inside ``begin_nested`` (see # ``deliver_enqueued``). queued: list[tuple[Any, Any]] = [] async with best_effort( self.db, label="notify terminal-delete preview", log=logger, ) as outcome: admins = await self._active_admin_recipients() if not admins: logger.warning( "offboarding.preview_pending_terminal_deletes: %d accounts " "delete tomorrow but no active admin to warn", len(rows), ) return 0 # Unlike the other digests in this file, each line carries a # readiness VERDICT — three-way enum prose, not data — so the # block is built once per language and handed to the template as # a locale fragment. Name / email / channel / successor address # are identical in all three; only the verdict clause differs. readiness_by_lang = { "zh": { "none": "❗ 无交接人 —— 会被拒绝删除并告警", "done": "✅ 交接人 {email}(Drive 转移已完成)", "pending": "⏳ 交接人 {email}(Drive 转移将在删除前执行)", }, "en": { "none": "❗ no successor — the delete will be refused and alerted", "done": "✅ successor {email} (Drive transfer already done)", "pending": "⏳ successor {email} (Drive transfer runs before the delete)", }, "ja": { "none": "❗ 引き継ぎ担当者なし —— 削除は拒否され警告されます", "done": "✅ 引き継ぎ担当者 {email}(Drive 移管は完了済み)", "pending": "⏳ 引き継ぎ担当者 {email}(Drive 移管は削除前に実行)", }, } lines_by_lang: dict[str, list[str]] = {"zh": [], "en": [], "ja": []} for row in rows: person = row["person"] resolution = row["successor"] if not resolution.ok: verdict = "none" elif row["drive_verified"]: verdict = "done" else: verdict = "pending" head = ( f" · {person.display_name or person.work_email} " f"<{person.work_email}> — {'/'.join(row['channels'])}" ) for lang, phrases in readiness_by_lang.items(): lines_by_lang[lang].append( f"{head}\n " + phrases[verdict].format(email=resolution.email) ) listed = "\n".join(lines_by_lang["zh"]) from app.notifications import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) notify = NotificationService(self.db) for admin in admins: env = NotificationEnvelope( recipient=admin, title=f"[离职删号预告] 明天将删除 {len(rows)} 个账号", body_text="(see template)", channels=["feishu", "email"], template_key="offboarding_delete_preview", context={ "count": len(rows), "account_list_zh": listed, "account_list_en": "\n".join(lines_by_lang["en"]), "account_list_ja": "\n".join(lines_by_lang["ja"]), }, url="/admin/offboarding?tab=progress", related_resource_type="offboarding_process", related_resource_id=rows[0]["proc"].id, ) queued.append((await notify.enqueue(env), env)) for row in rows: self.db.add( OffboardingReminderLog( id=uuid4(), process_id=row["proc"].id, reminder_kind=self._REMINDER_DELETE_PREVIEW, channels=["feishu", "email"], detail={ "work_email": row["person"].work_email, "channels": row["channels"], "successor_email": row["successor"].email, "drive_verified": row["drive_verified"], }, ) ) sent += 1 await self.db.flush() if outcome.error is None: await deliver_enqueued( self.db, queued, trace_id=trace_id, log=logger, ) return sent # ── Terminal Google-account DELETE sweep (2026-07-08 ADR) ───────── # # After the 2026-07-01 翻案 auto-SUSPENDS a leaver's Google account at # leaver_date+1, the account previously sat suspended forever. This sweep # is the terminal disposition: ``delete_lead_days`` (default 2) local days # past leaver_date, the suspended account is hard-DELETED. Google keeps a # deleted user recoverable for ~20 days, so this is reversible in a mistake # window. Retention-exempt mailboxes are never deleted. Since 2026-08-13 # the delete is gated on a PROVEN Drive/Calendar handover (see # ``_ensure_google_handover``) — the sweep performs the transfer itself # when the seat path never did, and refuses to delete when no successor # resolves. # Ledger statuses on ``session_enforcement.google_account_deletion`` that # mean "done — never retry": the account is gone or was a deliberate keep. _DELETION_TERMINAL = frozenset({ "deleted", "already_absent", "retained", }) # NOTE: ``manual_handoff`` was removed from this set on 2026-09-06. # It became unreachable when #3152 replaced the software-owner task # with the no-transfer retry, and prod carried zero rows with that # status — but while it sat here it was a TERMINAL status no code # could ever clear, i.e. a latent way to strand an account. async def sweep_google_account_deletions( self, *, trace_id: str, delete_lead_days: int = DEFAULT_GOOGLE_DELETE_LEAD_DAYS, ) -> dict[str, Any]: """Scan COMPLETED offboarding processes and hard-DELETE the leaver's Google account once it has been suspended and ``delete_lead_days`` local days have passed since ``leaver_date``. Idempotent + safe to run every tick: a process whose deletion ledger is already terminal (deleted / already_absent / retained) is skipped, and the per-process helper re-checks every gate (rehire, retention, suspended-state, local due-date) under a row lock before touching the vendor. Returns a counter summary; ``errors`` carries genuine vendor failures so the scheduled-job wrapper can land ``runs.status='failed'`` (feedback_handler_status_must_raise) — a leaver whose delete 403s every run must not read as a green tick. """ summary: dict[str, Any] = { "scanned": 0, "deleted": 0, "already_absent": 0, "retained": 0, "skipped": 0, "failed": 0, # Handover-gate outcomes, counted separately from generic # ``skipped`` so an operator reading the cron summary can see # "N accounts are waiting on a handover person" instead of a # number that also contains benign not-suspended-yet rows. "blocked_no_successor": 0, "transfer_incomplete": 0, "errors": [], } # Prefilter (cheap, indexed on leaver_date): COMPLETED processes whose # leaver_date is old enough that leaver_date+lead COULD have arrived for # some timezone. Use a generous UTC cutoff (lead-1) so no UTC±14 leaver # is excluded a day early; the per-person LOCAL due-date check inside # the helper is the exact gate. No upper bound → catch-up is automatic. utc_today = datetime.now(UTC).date() prefilter_cutoff = utc_today - timedelta(days=max(delete_lead_days - 1, 0)) candidate_ids = ( await self.db.execute( select(OffboardingProcess.id).where( OffboardingProcess.status == PROC_COMPLETED, OffboardingProcess.leaver_date <= prefilter_cutoff, ).order_by(OffboardingProcess.leaver_date) ) ).scalars().all() for pid in candidate_ids: try: outcome = await self._delete_google_account_if_due( pid, trace_id=trace_id, delete_lead_days=delete_lead_days, ) except Exception as exc: # noqa: BLE001 — one bad row must not abort the sweep logger.exception( "offboarding.sweep_google_account_deletions: process %s " "raised", pid, ) # Release the FOR UPDATE lock ``_delete_google_account_if_due`` # may have taken before raising, so the next candidate isn't # blocked behind a dead lock. await self.db.rollback() summary["errors"].append(f"{pid}: {type(exc).__name__}: {exc}") continue status = outcome.get("status") # Per-process commit: ``_delete_google_account_if_due`` locks the # offboarding_processes row FOR UPDATE and calls the Google delete # over HTTP; commit each iteration so the scan never holds a row # lock across the NEXT candidate's vendor round-trip. A single # trailing commit (the prior behaviour) serialized the whole scan # behind every external delete and one hung call stalled all of it. await self.db.commit() if status == "not_actionable": # Not yet due / cancelled / rehired / already-done / person # gone — not a candidate this run, don't count. continue summary["scanned"] += 1 if status == "deleted": if outcome.get("already_absent"): summary["already_absent"] += 1 else: summary["deleted"] += 1 elif status == "retained": summary["retained"] += 1 elif status == "failed": summary["failed"] += 1 summary["errors"].append( f"{pid}: {outcome.get('error')}" ) elif status == "skipped_no_successor": # Deliberate refusal, not an error: the account stays # suspended (already dark) until IT names a handover # person. Counted, never added to ``errors`` — a blocked # delete must not turn the whole cron run red. summary["blocked_no_successor"] += 1 elif status == "skipped_transfer_incomplete": summary["transfer_incomplete"] += 1 else: # skipped_not_suspended / no_integration / skipped summary["skipped"] += 1 return summary async def _delete_google_account_if_due( self, process_id: UUID, *, trace_id: str, delete_lead_days: int, ) -> dict[str, Any]: """Per-process terminal Google delete, under a row lock. Returns a ``{"status": ...}`` verdict; ``"not_actionable"`` means a gate held (not due / cancelled / rehired / person gone / already terminal) and the caller must not count it.""" from datetime import UTC as _UTC from datetime import datetime as _dt proc = await self._get_process(process_id, lock=True) # Re-assert the COMPLETED invariant under lock: a reopen/cancel could # have moved it between the prefilter select and here. if proc.status != PROC_COMPLETED: return {"status": "not_actionable", "reason": "not_completed"} # Already terminal — never re-fire a delete on a gone/retained account. prior = (proc.session_enforcement or {}).get("google_account_deletion") if ( isinstance(prior, dict) and prior.get("status") in self._DELETION_TERMINAL ): return {"status": "not_actionable", "reason": "already_terminal"} person = await self.db.get(IdentityPerson, proc.person_id) if person is None or person.deleted_at is not None: return {"status": "not_actionable", "reason": "person_gone"} # Never delete a rehired / mis-flagged (now-active) employee's account. if person.employment_status == "active": return {"status": "not_actionable", "reason": "rehired_active"} # Local due-date gate: delete only once leaver_date + lead has arrived # in the leaver's wall-clock zone. if proc.leaver_date is None: return {"status": "not_actionable", "reason": "no_leaver_date"} due_date = proc.leaver_date + timedelta(days=delete_lead_days) if _local_today_for_person(person) < due_date: return {"status": "not_actionable", "reason": "not_due"} now_iso = _dt.now(_UTC).isoformat() def _record(ledger: dict[str, Any]) -> None: merged = dict(proc.session_enforcement or {}) merged["google_account_deletion"] = {**ledger, "at": now_iso} merged["updated_at"] = now_iso proc.session_enforcement = merged # Retention re-check at delete time (registry classification may have # changed since suspend): a retained mailbox is NEVER deleted. if await self._is_google_account_retained(person): _record({"status": "retained"}) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_account_delete_skipped_retained", status="success", resource_type="identity_person", resource_id=str(person.id), detail={"work_email": person.work_email}, ) return {"status": "retained"} # Only delete an account we CONFIRMED dark (suspended) — never delete # one whose suspend failed / was skipped / never ran, so a delete can # never outrun the suspend it depends on. ``already_absent`` from the # suspend step means the account is already gone → terminal, no call. acct = (proc.session_enforcement or {}).get("google_account") or {} acct_status = acct.get("status") if isinstance(acct, dict) else None if acct_status == "already_absent": _record({"status": "already_absent", "already_absent": True}) await self.db.flush() return {"status": "deleted", "already_absent": True} if acct_status != "suspended": # Suspend not confirmed — record a non-terminal skip so a later # reenforce_session_cutoff (which sets google_account=suspended) # lets the next sweep delete. Do NOT stamp a terminal status. _record({"status": "skipped_not_suspended"}) await self.db.flush() return {"status": "skipped_not_suspended"} # ── Data-handover gate (2026-08-13). ───────────────────────── # The delete is where Drive/Calendar content dies. Prove the # handover happened — or perform it here — before touching the # vendor. See ``_ensure_google_handover`` for why "the blocker # cascade already did it" was not a safe assumption. handover = await self._ensure_google_handover( proc, person, trace_id=trace_id, ) if not handover["ok"]: _record({"status": handover["ledger_status"]}) await self.db.flush() return { "status": handover["ledger_status"], "error": handover.get("error"), } from app.core.coordinators import account_lifecycle_coordinator result = await account_lifecycle_coordinator.delete_google_account( self.db, person, ) r_status = result.get("status") if r_status == "deleted": _record({ "status": "deleted", "already_absent": bool(result.get("already_absent")), }) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_account_deleted", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "leaver_date": proc.leaver_date.isoformat(), "delete_lead_days": delete_lead_days, "already_absent": bool(result.get("already_absent")), # Handover receipt — the audit row now answers "who got # their Drive/Calendar?" without a Google console trip. **handover.get("audit_detail", {}), }, ) return { "status": "deleted", "already_absent": bool(result.get("already_absent")), } if r_status == "skipped": # No work_email / no Google integration — non-terminal, retry next # run (integration may be enabled later). _record({"status": "no_integration", "error": str(result.get("reason"))[:200]}) await self.db.flush() return {"status": "no_integration"} # failed — record non-terminal + audit so the failure notifier surfaces # it and the next sweep retries. _record({"status": "failed", "error": str(result.get("error"))[:200]}) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.google_account_delete_failed", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "error": str(result.get("error"))[:200], }, ) return {"status": "failed", "error": result.get("error")} # ── Terminal Zoom-account DELETE sweep (2026-08-04) ─────────────── # # Parity with the Google delete sweep above. After ``_push_force_logout`` # auto-DEACTIVATES a leaver's Zoom account at leaver_date+1, this sweep is # the terminal disposition: ``delete_lead_days`` (default 2) local days past # leaver_date, the deactivated account is hard-DELETED. ⚠️ Zoom deletes are # PERMANENT (no ``users.undelete``), so the suspend→delete gap is the only # mistake window. Retention-exempt accounts are never deleted. No data # transfer (the blocker cascade already handled owned resources, and the # account has been dark since deactivate). async def sweep_zoom_account_deletions( self, *, trace_id: str, delete_lead_days: int = DEFAULT_ZOOM_DELETE_LEAD_DAYS, ) -> dict[str, Any]: """Scan COMPLETED offboarding processes and hard-DELETE the leaver's Zoom account once it has been deactivated and ``delete_lead_days`` local days have passed since ``leaver_date``. Idempotent + safe to run every tick: a process whose deletion ledger is already terminal (deleted / already_absent / retained) is skipped, and the per-process helper re-checks every gate (rehire, retention, deactivated-state, local due-date) under a row lock before touching the vendor. Returns a counter summary; ``errors`` carries genuine vendor failures so the scheduled-job wrapper can land ``runs.status='failed'`` (feedback_handler_status_must_raise) — a leaver whose delete errors every run must not read as a green tick. """ summary: dict[str, Any] = { "scanned": 0, "deleted": 0, "already_absent": 0, "retained": 0, "skipped": 0, "failed": 0, # See the Google sweep: refusals get their own counter so # "waiting on a handover person" is legible in the run summary. "blocked_no_successor": 0, # Same rationale one level down: Zoom rejected the recipient we # named, so the delete did not happen. That is a safe refusal # awaiting a human decision, not a fault — DECISIONS 2026-08-13 # (d): a refusal in ``errors`` would drown the real faults. "blocked_transfer_recipient": 0, "errors": [], } # Prefilter (cheap, indexed on leaver_date), mirroring the Google sweep: # COMPLETED processes whose leaver_date is old enough that # leaver_date+lead COULD have arrived for some timezone (generous UTC # cutoff, lead-1, so no UTC±14 leaver is excluded a day early); the # per-person LOCAL due-date check inside the helper is the exact gate. utc_today = datetime.now(UTC).date() prefilter_cutoff = utc_today - timedelta(days=max(delete_lead_days - 1, 0)) candidate_ids = ( await self.db.execute( select(OffboardingProcess.id).where( OffboardingProcess.status == PROC_COMPLETED, OffboardingProcess.leaver_date <= prefilter_cutoff, ).order_by(OffboardingProcess.leaver_date) ) ).scalars().all() for pid in candidate_ids: try: outcome = await self._delete_zoom_account_if_due( pid, trace_id=trace_id, delete_lead_days=delete_lead_days, ) except Exception as exc: # noqa: BLE001 — one bad row must not abort the sweep logger.exception( "offboarding.sweep_zoom_account_deletions: process %s " "raised", pid, ) # Release the FOR UPDATE lock ``_delete_zoom_account_if_due`` # may have taken before raising, so the next candidate isn't # blocked behind a dead lock. await self.db.rollback() summary["errors"].append(f"{pid}: {type(exc).__name__}: {exc}") continue status = outcome.get("status") # Per-process commit (mirrors the Google sweep): never hold a row # lock across the NEXT candidate's vendor round-trip. await self.db.commit() if status == "not_actionable": continue summary["scanned"] += 1 if status == "deleted": if outcome.get("already_absent"): summary["already_absent"] += 1 else: summary["deleted"] += 1 elif status == "retained": summary["retained"] += 1 elif status == "failed": summary["failed"] += 1 summary["errors"].append(f"{pid}: {outcome.get('error')}") elif status == "skipped_no_successor": # Deliberate refusal (Zoom deletes are irreversible), not an # error — the account stays deactivated until IT names a # handover person. summary["blocked_no_successor"] += 1 elif status == "blocked_transfer_recipient": # Zoom refused the recipient; the account is untouched and # still deactivated. Retried every tick, so it clears by # itself once IT names a successor Zoom accepts. summary["blocked_transfer_recipient"] += 1 else: # skipped_not_suspended / no_integration / skipped summary["skipped"] += 1 return summary async def _delete_zoom_account_if_due( self, process_id: UUID, *, trace_id: str, delete_lead_days: int, ) -> dict[str, Any]: """Per-process terminal Zoom delete, under a row lock. Returns a ``{"status": ...}`` verdict; ``"not_actionable"`` means a gate held (not due / cancelled / rehired / person gone / already terminal) and the caller must not count it. Mirrors ``_delete_google_account_if_due``. """ from datetime import UTC as _UTC from datetime import datetime as _dt proc = await self._get_process(process_id, lock=True) if proc.status != PROC_COMPLETED: return {"status": "not_actionable", "reason": "not_completed"} prior = (proc.session_enforcement or {}).get("zoom_account_deletion") if ( isinstance(prior, dict) and prior.get("status") in self._DELETION_TERMINAL ): return {"status": "not_actionable", "reason": "already_terminal"} person = await self.db.get(IdentityPerson, proc.person_id) if person is None or person.deleted_at is not None: return {"status": "not_actionable", "reason": "person_gone"} # Never delete a rehired / mis-flagged (now-active) employee's account. if person.employment_status == "active": return {"status": "not_actionable", "reason": "rehired_active"} if proc.leaver_date is None: return {"status": "not_actionable", "reason": "no_leaver_date"} due_date = proc.leaver_date + timedelta(days=delete_lead_days) if _local_today_for_person(person) < due_date: return {"status": "not_actionable", "reason": "not_due"} now_iso = _dt.now(_UTC).isoformat() def _record(ledger: dict[str, Any]) -> None: merged = dict(proc.session_enforcement or {}) merged["zoom_account_deletion"] = {**ledger, "at": now_iso} merged["updated_at"] = now_iso proc.session_enforcement = merged # Retention re-check at delete time (registry classification may have # changed since suspend): a retained account is NEVER deleted. if await self._is_zoom_account_retained(person): _record({"status": "retained"}) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.zoom_account_delete_skipped_retained", status="success", resource_type="identity_person", resource_id=str(person.id), detail={"work_email": person.work_email}, ) return {"status": "retained"} # Only delete an account we CONFIRMED dark (deactivated) — never delete # one whose deactivate failed / was skipped / never ran, so a delete can # never outrun the suspend it depends on. ``already_absent`` from the # deactivate step means the account is already gone → terminal, no call. acct = (proc.session_enforcement or {}).get("zoom_account") or {} acct_status = acct.get("status") if isinstance(acct, dict) else None if acct.get("already_absent") or acct_status == "already_absent": _record({"status": "already_absent", "already_absent": True}) await self.db.flush() return {"status": "deleted", "already_absent": True} if acct_status != "suspended": # Deactivate not confirmed — record a non-terminal skip so a later # re-run (which sets zoom_account=suspended) lets the next sweep # delete. Do NOT stamp a terminal status. _record({"status": "skipped_not_suspended"}) await self.db.flush() return {"status": "skipped_not_suspended"} # ── Content-handover gate (2026-08-13). ────────────────────── # Zoom's delete is PERMANENT (no undelete window) and the seat # binding is ``disassociate``, which transfers nothing — so cloud # recordings / scheduled meetings / webinars only survive if THIS # call carries a successor. Refuse to delete without one. resolution = await self._terminal_successor(proc, person) if not resolution.ok: _record({ "status": "skipped_no_successor", **resolution.as_detail(), }) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.zoom_account_delete_blocked_no_successor", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "process_id": str(proc.id), **resolution.as_detail(), }, ) await self._notify_delete_blocked( proc, person, channel="Zoom", reminder_kind=self._REMINDER_DELETE_BLOCKED_ZOOM, reason_zh=( "未能解析出有效交接人;Zoom 删除不可恢复,云录制会永久丢失" ), reason_en=( "no successor resolved — a Zoom delete is irreversible and " "the cloud recordings would be lost for good" ), reason_ja=( "引き継ぎ担当者を特定できません。Zoom の削除は元に戻せず、" "クラウド録画は完全に失われます" ), trace_id=trace_id, ) return {"status": "skipped_no_successor"} from app.core.coordinators import account_lifecycle_coordinator result = await account_lifecycle_coordinator.delete_zoom_account( self.db, person, successor_email=resolution.email, ) r_status = result.get("status") if r_status == "deleted": _record({ "status": "deleted", "already_absent": bool(result.get("already_absent")), "transferred_to": result.get("transferred_to"), **resolution.as_detail(), }) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.zoom_account_deleted", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "leaver_date": proc.leaver_date.isoformat(), "delete_lead_days": delete_lead_days, "already_absent": bool(result.get("already_absent")), # Handover receipt: meetings / webinars / cloud # recordings moved to this address in the same call. "transferred_to": result.get("transferred_to"), **resolution.as_detail(), }, ) return { "status": "deleted", "already_absent": bool(result.get("already_absent")), } if r_status == "skipped": # No work_email / no Zoom integration — non-terminal, retry next run. _record({"status": "no_integration", "error": str(result.get("reason"))[:200]}) await self.db.flush() return {"status": "no_integration"} from app.integrations.drivers.zoom_software import ( # noqa: PLC0415 ZOOM_TAG_TRANSFER_RECIPIENT_INVALID, ZOOM_TAG_TRANSFER_RECIPIENT_NO_WEBINAR, ) failure = str(result.get("error")) refusal = self._zoom_transfer_refusal(result) if refusal == ZOOM_TAG_TRANSFER_RECIPIENT_INVALID: # Zoom rejected the RECIPIENT outright — in practice a # successor with no Zoom account at all ("data receiver does # not exist"). Nothing was deleted: Zoom validates # transfer_email before it touches the leaver. # # The one thing we must NOT do here is what the missing- # webinar branch below does. Retrying without a transfer # would succeed instantly and destroy the cloud recordings — # the precise outcome DECISIONS 2026-08-13 reversed after two # prod leavers lost theirs. Nor may we keep failing: the # recipient is wrong every night, so a fault that repeats # forever teaches IT to ignore this job. # # So: refuse, keep the account suspended (still no access, # still recoverable), page IT ONCE, and count it apart from # ``errors``. The gate re-runs every tick, so naming a # successor Zoom accepts is the entire fix and it needs no # replay. _record({ "status": "blocked_transfer_recipient", "error": failure[:200], "transfer_refusal": refusal, **resolution.as_detail(), }) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.zoom_account_delete_blocked_recipient", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "process_id": str(proc.id), "vendor_error": failure[:500], **resolution.as_detail(), }, ) await self._notify_delete_blocked( proc, person, channel="Zoom", reminder_kind=self._REMINDER_DELETE_BLOCKED_ZOOM_RECIPIENT, # Renders as "…的 Zoom 账号的交接人未被厂商接受". headline_zh="的交接人未被厂商接受", reason_zh=( f"Zoom 拒绝把数据转交给 {resolution.email}(该账号在 Zoom " f"中不存在或不可接收);删除不可恢复,已暂停并保持停用。" f"请在离职流程上改选一位有 Zoom 账号的交接人。" ), reason_en=( f"Zoom refused to transfer the data to {resolution.email} " f"(no such Zoom account, or it cannot receive). The delete " f"is irreversible, so it is paused and the account stays " f"deactivated — name a handover person who has a Zoom " f"account on the offboarding process." ), reason_ja=( f"Zoom がデータの引き継ぎ先 {resolution.email} を拒否しました" f"(Zoom アカウントが存在しないか受け取れません)。削除は" f"元に戻せないため停止し、アカウントは無効のままです。" f"Zoom アカウントを持つ引き継ぎ担当者を指定してください。" ), trace_id=trace_id, ) return {"status": "blocked_transfer_recipient"} if refusal == ZOOM_TAG_TRANSFER_RECIPIENT_NO_WEBINAR: # Explicit operator policy: a missing Webinar entitlement on the # resolved recipient must never wedge offboarding. Zoom has # already refused the transfer, so retry its irreversible delete # without a recipient; preserve the receipt so this is visible in # audit rather than looking like a successful content handover. no_transfer = await account_lifecycle_coordinator.delete_zoom_account( self.db, person, successor_email=None, ) if no_transfer.get("status") == "deleted": _record({ "status": "deleted", "already_absent": bool(no_transfer.get("already_absent")), "transferred_to": None, "data_transfer": "skipped_no_webinar_license", "attempted_successor": resolution.email, "transfer_error": failure[:200], **resolution.as_detail(), }) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.zoom_account_deleted_without_transfer", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "attempted_successor": resolution.email, "reason": "recipient_missing_webinar_license", "vendor_error": failure[:500], }, ) return { "status": "deleted", "already_absent": bool(no_transfer.get("already_absent")), "transfer_skipped": True, } # Preserve the no-transfer retry's error; it is the actionable # failure now, rather than the expected capability refusal. failure = str(no_transfer.get("error") or failure) # Unknown failures remain loud and retryable. _record({"status": "failed", "error": failure[:200]}) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.zoom_account_delete_failed", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "error": failure[:200], }, ) return {"status": "failed", "error": result.get("error")} # ── Deferred Feishu-account delete retry sweep (2026-07-16) ──────── # # ``_push_force_logout`` defers the terminal Feishu delete (freezing # instead) while the leaver has open self-submitted approval instances — # the 回购 second step parks on the submitter's own node and a deleted # submitter strands it terminally. This sweep is the retry: once the # instances close, the delete lands; past FEISHU_DELETE_DEFER_MAX_DAYS # local days a still-open instance is treated as abandoned and the delete # proceeds anyway (audited). Transient delete failures stay in the # deferred state so the next tick retries them. async def sweep_deferred_feishu_account_deletions( self, *, trace_id: str, defer_max_days: int = FEISHU_DELETE_DEFER_MAX_DAYS, ) -> dict[str, Any]: """Scan offboarding processes whose ``feishu_account`` channel is in the deferred state and retry the terminal Feishu delete. Idempotent + safe to run every tick: only rows whose ledger status is exactly ``FEISHU_DELETE_DEFERRED`` are candidates, and the per-process helper re-checks every gate (cancelled, rehired, retained, still-open approvals, defer window) under a row lock. ``errors`` carries genuine vendor failures so the scheduled-job wrapper lands ``runs.status='failed'`` (feedback_handler_status_must_raise).""" summary: dict[str, Any] = { "scanned": 0, "deleted": 0, "still_deferred": 0, "deferral_expired_deleted": 0, "retained": 0, "skipped": 0, "failed": 0, "errors": [], } # Prefilter on the indexed leaver_date: deferrals only exist after # the leaver's last working day and are force-resolved at # leaver_date + defer_max_days, so a bounded band (generous slack for # TZ skew + failed-delete retries) covers every live deferral without # an unbounded scan. The JSON ledger check happens in Python — the # band is a handful of recent leavers. utc_today = datetime.now(UTC).date() lookback = utc_today - timedelta(days=defer_max_days + 60) candidates = ( await self.db.execute( select(OffboardingProcess.id, OffboardingProcess.session_enforcement) .where( OffboardingProcess.status != PROC_CANCELLED, OffboardingProcess.leaver_date.is_not(None), OffboardingProcess.leaver_date >= lookback, OffboardingProcess.leaver_date <= utc_today, ).order_by(OffboardingProcess.leaver_date) ) ).all() for pid, enforcement in candidates: chan = (enforcement or {}).get("feishu_account") if not isinstance(chan, dict) or chan.get("status") != FEISHU_DELETE_DEFERRED: continue try: outcome = await self._retry_deferred_feishu_delete( pid, trace_id=trace_id, defer_max_days=defer_max_days, ) except Exception as exc: # noqa: BLE001 — one bad row must not abort the sweep logger.exception( "offboarding.sweep_deferred_feishu_account_deletions: " "process %s raised", pid, ) # Roll back to release the FOR UPDATE lock ``_retry`` may have # taken on the offboarding_processes row before it raised, so # the next candidate doesn't queue behind a dead lock. await self.db.rollback() summary["errors"].append(f"{pid}: {type(exc).__name__}: {exc}") continue status = outcome.get("status") # Per-process commit: ``_retry_deferred_feishu_delete`` holds the # offboarding_processes row FOR UPDATE and makes the vendor delete # HTTP call. Commit each iteration so the scan never holds a row # lock across the NEXT candidate's external round-trip; a single # trailing commit would serialize the whole scan behind every # vendor call and one hung call would stall all of it. await self.db.commit() if status == "not_actionable": continue summary["scanned"] += 1 if status in ("deleted", "frozen_fallback", "already_absent"): if outcome.get("deferral_expired"): summary["deferral_expired_deleted"] += 1 else: summary["deleted"] += 1 elif status == "still_deferred": summary["still_deferred"] += 1 elif status == "retained": summary["retained"] += 1 elif status == "failed": summary["failed"] += 1 summary["errors"].append(f"{pid}: {outcome.get('error')}") else: # skipped_identity_mismatch / skipped_no_config summary["skipped"] += 1 return summary async def _retry_deferred_feishu_delete( self, process_id: UUID, *, trace_id: str, defer_max_days: int, ) -> dict[str, Any]: """Per-process deferred-delete retry, under a row lock. Returns a ``{"status": ...}`` verdict; ``"not_actionable"`` means a gate held (no longer deferred / cancelled / rehired / person gone) and the caller must not count it.""" from datetime import UTC as _UTC from datetime import datetime as _dt proc = await self._get_process(process_id, lock=True) # Re-assert the deferred state under lock — a concurrent reenforce # could have resolved the channel between prefilter and here. chan = (proc.session_enforcement or {}).get("feishu_account") if not isinstance(chan, dict) or chan.get("status") != FEISHU_DELETE_DEFERRED: return {"status": "not_actionable", "reason": "not_deferred"} if proc.status == PROC_CANCELLED: return {"status": "not_actionable", "reason": "cancelled"} person = await self.db.get(IdentityPerson, proc.person_id) if person is None or person.deleted_at is not None: return {"status": "not_actionable", "reason": "person_gone"} # Never touch a rehired / mis-flagged (now-active) employee's account. if person.employment_status == "active": return {"status": "not_actionable", "reason": "rehired_active"} now_iso = _dt.now(_UTC).isoformat() def _record(value: dict[str, Any]) -> None: merged = dict(proc.session_enforcement or {}) merged["feishu_account"] = {**value, "at": now_iso} merged["updated_at"] = now_iso proc.session_enforcement = merged # Retention re-check at delete time (registry classification may have # changed since the defer): a retained account is NEVER deleted. if await self._is_feishu_account_retained(person): # FIX 2: the defer FROZE this account; a retained account must be # kept ACTIVE — unfreeze it before recording retained, else a CBG # collaborator ends up permanently locked out. (Registry row # already exists here, so no FIX-1 write is needed.) unfreeze = await self._unfreeze_retained_after_defer( person, trace_id=trace_id, ) _record({"status": "retained", "unfreeze_status": unfreeze.get("status")}) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_skipped_retained", status="success", resource_type="identity_person", resource_id=str(person.id), detail={"work_email": person.work_email}, ) return {"status": "retained"} open_requests = await self._feishu_open_self_approvals(person.id) deferral_expired = ( proc.leaver_date is not None and _local_today_for_person(person) > proc.leaver_date + timedelta(days=defer_max_days) ) if open_requests and not deferral_expired: # Still in-flight, still in-window — refresh the ledger (fresh # request list + defer counter) and try again next tick. No audit # per tick: the initial defer was audited; a daily re-defer line # would be pure noise. The freeze itself is independently re- # asserted by account_backstop + the identity-sync feishu sweep # (both gate on employment_status='left'), so no re-freeze here. _record({ "status": FEISHU_DELETE_DEFERRED, "open_requests": open_requests[:20], "freeze_status": chan.get("freeze_status"), "defer_count": int(chan.get("defer_count") or 1) + 1, }) await self.db.flush() return {"status": "still_deferred"} # Instances closed (or the defer window expired — an approval still # pending 30+ days after the leaver left is abandoned, not in # progress) → land the terminal delete. from app.core.coordinators import account_lifecycle_coordinator fd = await account_lifecycle_coordinator.delete_feishu_account( self.db, person, trace_id=trace_id, ) status = fd.get("status") if status == "failed": # Transient/systemic failure — KEEP the deferred state (with the # error recorded) so tomorrow's sweep retries the delete, instead # of parking the row in a terminal ``failed`` that only a manual # reenforce would revive. The summary still counts it as an error # so the failure notifier fires. _record({ "status": FEISHU_DELETE_DEFERRED, "open_requests": open_requests[:20], "freeze_status": chan.get("freeze_status"), "defer_count": int(chan.get("defer_count") or 1) + 1, "error": str(fd.get("error"))[:200], }) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_failed", status="failed", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "error": str(fd.get("error"))[:200], "deferred_retry": True, }, ) return {"status": "failed", "error": fd.get("error")} if status == "retained_cbg_department": # Live CBG-department retention fired at delete time — normalise # to the SAME ledger status the dispatch path records ("retained", # so the success-merge protects it), audit with the same action, # and count it as retained in the sweep summary. # FIX 1: durably record the retention in the registry so future # runs (this sweep + #1983's leaver-assurance) see the keep. await self._record_cbg_retention_registry(person, trace_id=trace_id) # FIX 2: the defer FROZE this account; a retained account must be # kept ACTIVE — unfreeze before recording retained so a CBG # collaborator isn't permanently locked out. unfreeze = await self._unfreeze_retained_after_defer( person, trace_id=trace_id, ) _record({ "status": "retained", "external_id": fd.get("external_id"), "unfreeze_status": unfreeze.get("status"), }) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_skipped_retained", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "reason": "retained: CBG department", "external_id": fd.get("external_id"), "deferred_retry": True, }, ) return {"status": "retained"} value: dict[str, Any] = { "status": status, "error": (str(fd.get("error"))[:200] if fd.get("error") else None), "external_id": fd.get("external_id"), } if deferral_expired and open_requests: value["deferral_expired"] = True value["open_requests"] = open_requests[:20] _record(value) await self.db.flush() if status in ("deleted", "frozen_fallback", "already_absent"): await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_deleted", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "disposition": status, "external_id": fd.get("external_id"), "deferred_retry": True, "deferral_expired": bool(deferral_expired and open_requests), "open_requests": open_requests[:20] or None, }, ) elif status == "skipped_target_keep": # Mirror the dispatch path: an explicit on_offboard='keep' admin # decision resolved the deferral — audit so IT sees why the # deferred delete never landed. await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_skipped_target_keep", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "reason": fd.get("error"), "deferred_retry": True, }, ) elif status == "skipped_identity_mismatch": await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.feishu_account_delete_skipped_identity_mismatch", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "work_email": person.work_email, "external_id": fd.get("external_id"), "error": fd.get("error"), }, ) return { "status": status, "deferral_expired": bool(deferral_expired and open_requests), } async def cancel_process( self, process_id: UUID, *, reason: str, trace_id: str, actor_user_id: UUID, confirm_irreversible: bool = False, ) -> OffboardingProcess: proc = await self._get_process(process_id, lock=True) if proc.status == PROC_COMPLETED: raise AppException( code="PROCESS_COMPLETED", message="Cannot cancel a completed offboarding", status_code=400, ) # Pre-launch hardening 2026-05-22: idempotent on the cancelled # state. Without this fast-path a second cancel call (e.g. a # racing axios retry that beat the Idempotency-Key cache, or # a server-side timer firing a stale background cancel) would # re-run the whole cascade — pending-xfer / pending-task / # restore-seat queries all return empty on the re-run because # the prior cancel left them in terminal states, so the # operation is harmless, but it WOULD emit a duplicate # ``offboarding.process_cancelled`` audit row with empty # cascade lists. Forensic noise + audit contract violation. # Caller still gets the current detail via the router's final # ``get_process_detail``. if proc.status == PROC_CANCELLED: logger.info( "cancel_process: %s is already cancelled — no-op replay", proc.id, ) # Pre-launch security review 2026-05-22: emit a forensic # audit row even on the noop path so "who tried to # re-cancel and when" is answerable without grepping # request logs. Action name is distinct from the real # cancel (``offboarding.process_cancelled``) so dashboards # / SIEM can separate "actual cancellation" from "replay". await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.process_cancelled_noop", status="success", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "reason": reason, "person_id": str(proc.person_id), "confirm_irreversible_passed": confirm_irreversible, }, ) return proc # Pre-launch P1 fix (tightened 2026-05-08): refuse cancel only # when irreversible state has actually been emitted — i.e. SSO # was suspended (vendor account changes shipped) OR at least one # blocker has hit a terminal "real-state-changed" resolution # (TRANSFERRED = FK rewritten, REVOKED_AUTO/REVOKED_MANUAL = # vendor-side revoke executed). # # Earlier the gate fired on ``ready_at is not None`` alone, which # over-triggered for processes that hit READY purely via # ``RESO_SSO_CASCADE`` resolutions (no real action taken yet — # those blockers are bookkeeping that "the seat will die when # we suspend Google"). For sso-cascade-only flows the operator # can roll back cleanly without an irreversibility prompt. # Review 2026-07-08 fix (#1750): the account-level Google suspend # fired by ``_push_force_logout`` (late-discovery path: confirmed a # leaver whose leaver_date was already past → suspend runs within # seconds, well before ``sso_suspended_at`` or any terminal blocker # is set) is ALSO irreversible vendor state. The prior gate only # looked at ``sso_suspended_at`` + terminal blockers, so a cancel of # such a process slipped through with no confirm prompt AND left the # account suspended. Fold it in so the admin explicitly confirms, and # so the restore block below runs. google_account_suspended = ( (proc.session_enforcement or {}).get("google_account") or {} ).get("status") == "suspended" # Zoom parity: the day-of Zoom deactivate is ALSO irreversible-ish # vendor state whose restore the cancel must trigger (else a re-activated # employee stays locked out of Zoom). Fold it into the same # already-advanced confirm gate + post-commit restore as Google. zoom_account_suspended = ( (proc.session_enforcement or {}).get("zoom_account") or {} ).get("status") == "suspended" if proc.sso_suspended_at is not None: already_advanced = True advance_reason = "sso_suspended" else: advanced_resolution_count = ( await self.db.execute( select(func.count()) .select_from(OffboardingBlocker) .where( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.resolution.in_(( RESO_TRANSFERRED, RESO_REVOKED_AUTO, RESO_REVOKED_MANUAL, )), ) ) ).scalar() or 0 already_advanced = advanced_resolution_count > 0 advance_reason = ( f"{advanced_resolution_count}_terminal_blockers" if already_advanced else "none" ) if google_account_suspended and not already_advanced: already_advanced = True advance_reason = "google_account_suspended" if zoom_account_suspended and not already_advanced: already_advanced = True advance_reason = "zoom_account_suspended" if already_advanced and not confirm_irreversible: raise AppException( code="PROCESS_ALREADY_ADVANCED", message=( "Offboarding has already produced irreversible state " f"({advance_reason}): vendor accounts revoked, asset " "ownership rewritten, or SSO suspended. Cancel will " "restore employment_status='active' AND auto-restore " "seats whose vendor revoke already completed (re-grants " "re-queued — see PR #210 round-12 P2 closeout); it " "will NOT undo the force-transferred hardware / domain " "ownership rewrites. Pass confirm_irreversible=true to " "proceed and manually recover the transferred resources." ), status_code=409, ) proc.status = PROC_CANCELLED # Pre-launch hardening 2026-05-04: HR-cancel paths (false leaver, # re-org rename, mistake) MUST also restore the person to # ``employment_status='active'`` and clear ``termination_date``. # Otherwise the user stays locked out of every mutate endpoint # by ``require_active_employee_for_mutations`` even though # their offboarding has been called off. from app.identity.person_models import IdentityPerson person = await self.db.get(IdentityPerson, proc.person_id) prior_employment_status: str | None = None prior_termination_date = None prior_force_logout_at = None if person is not None and person.employment_status in ( "leaver_pending", "left", ): prior_employment_status = person.employment_status prior_termination_date = person.termination_date prior_force_logout_at = person.force_logout_at person.employment_status = "active" person.termination_date = None # P0-1 fix (2026-05-08): clear the leaver-blocked dashboard # marker too. ``force_logout_at`` is read by # ``leaver_blocked.dashboard_service`` and ``me_universe.service`` # to render "session was cut at X" badges. Without clearing it # on cancel, a falsely-flagged leaver — restored to active — # still shows up in monitoring dashboards as "session was # severed", confusing operators and audits. The person can # log back in fine (auth gate only checks employment_status), # but the UX/audit story stays misleading until next sync. person.force_logout_at = None # Pre-launch P1 fix (Round 45, CR-417): audit-log the # force_logout_at clear. This field affects PAT/session # rejection at _enforce_force_logout; clearing it without # a discrete audit row leaves "who unset X's # force_logout_at and when" answerable only by walking # cancel_process trace_ids — easy to miss in forensics. if prior_force_logout_at is not None: await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="identity.force_logout_at.cleared", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "prior_force_logout_at": ( prior_force_logout_at.isoformat() if prior_force_logout_at else None ), "via": "cancel_process", "process_id": str(proc.id), "reason": reason, }, ) # Pre-launch P1 fix (Round 45, CR-412): cancel any in-flight # LeaverDualSignOverride tied to this process. Without this, # an approved/pending_secondary override survives the cancel # and stays valid for the SAME process row — if the person is # later re-flagged as a leaver (reopen), the override carries # over and effectively pre-authorises the next cycle's flip # without consulting the secondary signer. # SAVEPOINT-isolate (2026-08-06): this loop writes override rows AND # audit rows. A failure used to poison the transaction, so the # cascade-cancel of AssetTransferRequests right below (and the rest # of cancel_process) died with it — a falsely-flagged leaver never # got restored. async with best_effort( self.db, label=f"cancel_process: cancel dual-sign overrides for {proc.id}", log=logger, ) as override_step: from app.domains.leaver_blocked.models import ( # noqa: PLC0415 BLOCKING_DUAL_SIGN_STATUSES, DUAL_SIGN_CANCELLED, LeaverDualSignOverride, ) overrides = list( ( await self.db.execute( select(LeaverDualSignOverride).where( LeaverDualSignOverride.process_id == proc.id, LeaverDualSignOverride.status.in_( BLOCKING_DUAL_SIGN_STATUSES, ), ) ) ).scalars().all() ) for ov in overrides: prior_status = ov.status ov.status = DUAL_SIGN_CANCELLED await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="leaver_blocked.override.cancelled_on_process_cancel", status="success", resource_type="leaver_dual_sign_override", resource_id=str(ov.id), detail={ "prior_status": prior_status, "process_id": str(proc.id), "reason": reason, }, ) if not override_step.ok: logger.warning( "cancel_process: failed to cancel dual-sign overrides " "for %s: %s", proc.id, override_step.reason(), ) # Pre-launch hardening 2026-05-06: cascade-cancel every still-pending # AssetTransferRequest tied to this process. Without this, a # false-leaver cancel leaves queued xfers in the recipient's # inbox — recipient (often a manager) can still click "accept" # and silently re-assign ownership of the falsely-flagged # leaver's asset to themselves. The recipient's Feishu push for # the xfer also stays live, generating an apparent leak of the # mistaken leaver's asset list. pending_xfers = list(( await self.db.execute( select(AssetTransferRequest).where( AssetTransferRequest.offboarding_process_id == proc.id, AssetTransferRequest.status == XFER_PENDING, ) ) ).scalars().all()) cancelled_xfer_ids: list[str] = [] for xfer in pending_xfers: xfer.status = XFER_CANCELLED xfer.response_note = ( f"Auto-cancelled with offboarding process {proc.id}: {reason}" ) xfer.responded_at = datetime.now(UTC) cancelled_xfer_ids.append(str(xfer.id)) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="transfer.cancelled", status="success", resource_type="asset_transfer_request", resource_id=str(xfer.id), detail={ "reason": "offboarding_process_cancelled", "offboarding_process_id": str(proc.id), "cascade": True, }, ) # Pre-launch hardening 2026-05-06 (round 2): also cancel every # in-flight ProvisioningTask linked to this process via # OffboardingBlocker.provisioning_task_id. Without this, an # offboarding-spawned revoke task (against Slack/Google/Zoom/ # Jira/GitHub) keeps running against the now-reactivated person # — silently stripping their real cloud-side access even though # offboarding has been called off. from app.domains.provisioning.models import ( STATUS_CANCELLED as PROV_STATUS_CANCELLED, ) from app.domains.provisioning.models import ( STATUS_IN_PROGRESS as PROV_STATUS_IN_PROGRESS, ) from app.domains.provisioning.models import ( STATUS_NOTIFIED as PROV_STATUS_NOTIFIED, ) from app.domains.provisioning.models import ( STATUS_PENDING as PROV_STATUS_PENDING, ) from app.domains.provisioning.models import ( ProvisioningTask, ) proc_blockers = list(( await self.db.execute( select(OffboardingBlocker).where( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.provisioning_task_id.is_not(None), ) ) ).scalars().all()) cancelled_task_ids: list[str] = [] skipped_inflight_task_ids: list[str] = [] for b in proc_blockers: task = await self.db.get( ProvisioningTask, b.provisioning_task_id, ) if task is None: continue # Pre-launch hardening 2026-05-06 (round 3): only cancel # tasks in PENDING / NOTIFIED. ``IN_PROGRESS`` tasks are # mid-flight against the cloud driver — racing them with # ``status=CANCELLED`` here would lose the cancel intent # because the driver's terminal handler in # ``provisioning/service.py`` overwrites status to # COMPLETED / FAILED with no ``status==CANCELLED`` recheck. # Better to let the in-flight task finish (it'll fail or # succeed naturally) and surface a warning so the admin # knows there's a residual cloud-side mutation against the # re-activated person — they can manually reverse it from # the connector UI. # Terminal states (COMPLETED / FAILED / CANCELLED) are # no-ops as before. if task.status not in (PROV_STATUS_PENDING, PROV_STATUS_NOTIFIED): if task.status == PROV_STATUS_IN_PROGRESS: skipped_inflight_task_ids.append(str(task.id)) logger.warning( "cancel_process: provisioning task %s is " "IN_PROGRESS at cancel time; cloud-side " "mutation will complete against re-activated " "person %s — admin must reverse manually if " "needed.", task.id, proc.person_id, ) continue task.status = PROV_STATUS_CANCELLED task.error_message = ( f"Auto-cancelled with offboarding process {proc.id}: " f"{reason}" ) cancelled_task_ids.append(str(task.id)) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="provisioning.task_cancelled", status="success", resource_type="provisioning_task", resource_id=str(task.id), detail={ "reason": "offboarding_process_cancelled", "offboarding_process_id": str(proc.id), "blocker_id": str(b.id), "cascade": True, }, ) # Pre-launch hardening 2026-05-08 (round 12): auto-restore seats # whose offboarding-driven revoke task already COMPLETED. Without # this, a false-leaver cancel left every Zoom/Slack/Google seat # stuck at ``status='revoked'`` and required admin to walk the # IT 宇宙 seat list re-granting one by one. Worse: the manual # re-grant created a NEW seat row alongside the old revoked one # (the unique index is on active|pending only, so historical # revoked rows are unbothered) — that's exactly the duplicate- # row mess on Kevin Lu's screenshot. # # Restoration scope: seats blocked by THIS process whose revoke # task reached COMPLETED. Tasks still PENDING/NOTIFIED were # cancelled above and the seat was never flipped (offboarding # only flips on task success — see provisioning/service.py:2336). # IN_PROGRESS tasks are skipped (the worker will flip the seat # any second now and we'd race it; admin gets a warning above # and can manually re-grant once the inflight revoke settles). # FAILED tasks: seat may or may not be flipped depending on # ``revert_seat_on_failure``; restoration is admin's call. from app.core.coordinators.provisioning_coordinator import ( enqueue_grant_for_seat, ) from app.domains.asset.models import SoftwareSeatAllocation restored_seat_ids: list[str] = [] restoration_grant_task_ids: list[str] = [] skipped_restore_reasons: list[dict[str, str]] = [] for b in proc_blockers: if b.resource_type != RES_SOFTWARE_SEAT or b.resource_id is None: continue # Suppress autoflush for the lookup queries — seat objects # modified in prior iterations are dirty in the session; a # SELECT would trigger an UPDATE that can deadlock against a # concurrent seat-management transaction. with self.db.no_autoflush: task = await self.db.get( ProvisioningTask, b.provisioning_task_id, ) if task is None or task.status != TASK_COMPLETED: continue seat = await self.db.get(SoftwareSeatAllocation, b.resource_id) if seat is None or seat.status != "revoked": continue # Skip if admin has already manually re-granted this user on # this plan (a separate active|pending row exists). The # migration-119 unique index would reject our flip otherwise. existing_live = await self.db.scalar( select(SoftwareSeatAllocation.id).where( SoftwareSeatAllocation.plan_id == seat.plan_id, SoftwareSeatAllocation.user_id == seat.user_id, SoftwareSeatAllocation.status.in_(("active", "pending")), SoftwareSeatAllocation.id != seat.id, ) ) if existing_live is not None: skipped_restore_reasons.append({ "seat_id": str(seat.id), "reason": "admin_already_regranted", }) continue # Reactivate THIS row rather than insert a new one so we # don't pile up a duplicate (revoked + active) pair on the # IT 宇宙 view. Bumping ``allocated_at`` to now means # ``billing_service._fetch_allocations_overlapping`` starts # billing fresh from the restoration moment — the gap # period (offboarding revoke → cancel) is correctly NOT # billed (revoked_at < new allocated_at, but billing keys # off allocated_at..revoked_at on the row's CURRENT shape). now_ts = datetime.now(UTC) # Attempt vendor re-grant BEFORE mutating seat status. # If enqueue_grant_for_seat raises, we leave the seat in # "revoked" (its current state) rather than flipping to # "pending" with no backing grant task — that "pending + # no task" state is invisible to the dispatcher and would # never progress. The admin sees the seat as revoked + # enqueue_grant_failed in the cancel response, which is # accurately actionable. grant_task_id: UUID | None = None # SAVEPOINT-isolate (2026-08-06): the seats restored here are # pre-existing rows that can carry the EmployeeRequest-valued # ``approval_request_id``, so this loop hit the same FK violation # as the Feishu path. Swallowing it without a savepoint left the # session dead, so the next iteration's ``self.db.get`` and the # per-seat audit write raised — one bad seat aborted the entire # false-leaver restore instead of skipping just that seat. async with best_effort( self.db, label=f"cancel_process: enqueue grant for seat {seat.id}", log=logger, ) as restore_step: grant_task_id = await enqueue_grant_for_seat( self.db, seat=seat, trace_id=trace_id, actor_user_id=actor_user_id, ) if not restore_step.ok: grant_task_id = None logger.error( "cancel_process: enqueue_grant_for_seat failed " "for seat %s (%s) — seat left as revoked; " "admin must manually re-grant on vendor side", seat.id, restore_step.reason(), ) # Pre-launch P2 fix (Round 1, CR-006): surface the # failure in the cancel response so admin sees seats # that need manual vendor re-grant without grepping # audit logs. skipped_restore_reasons.append({ "seat_id": str(seat.id), "reason": "enqueue_grant_failed", "detail": restore_step.reason(max_len=300) or "unknown", }) # Do not flip seat to "pending" — leave it revoked. continue if grant_task_id is not None: restoration_grant_task_ids.append(str(grant_task_id)) # Enqueue succeeded (or software has no connector — manual # path). Now safe to reactivate the seat row. seat.status = "pending" seat.allocated_at = now_ts seat.revoked_at = None # Clear any pre-offboarding cancel-next-month schedule — # the boundary date is in the past now anyway and the # auto_revoke_pending_cancels cron would re-revoke this # row on its very next tick. seat.cancel_at_period_end = None seat.cancel_requested_at = None seat.cancel_requested_by = None seat.cancel_reason = None seat.reason = ( (seat.reason or "") + f"\n[restored by offboarding.cancel ({proc.id}): " f"{reason}]" ).strip() restored_seat_ids.append(str(seat.id)) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="software_seat.restored_on_offboarding_cancel", status="success", resource_type="software_seat_allocation", resource_id=str(seat.id), detail={ "offboarding_process_id": str(proc.id), "blocker_id": str(b.id), "completed_revoke_task_id": str(task.id), "plan_id": str(seat.plan_id), "user_id": str(seat.user_id), }, ) # ── software_account restoration surface ── # The seat-restoration loop above only handles # ``RES_SOFTWARE_SEAT`` blockers. ``RES_SOFTWARE_ACCOUNT`` # blockers (Jira / Atlassian full-access-revoke via # ``revoke_all_access`` on jira_software driver) carry no seat # row — the revoke removed the leaver from every group on the # Atlassian site. Helix has no way to know which groups they # should belong to going forward (the leaver_date itself is now # cancelled — they're staying), so the only honest answer is # "tell the admin what was stripped so they can re-grant in the # vendor console". Pre-launch incident 2026-05-28: Thalie's # Atlassian access was revoked on HRIS sync but cancel_process # only restored the Google Workspace seat — her Jira access # stayed dark with no admin signal. This block emits a # structured ``software_account.restore_manual_required`` audit # row per stripped account blocker, carrying the group list # from the task's ``context_data['vendor_response']`` (saved by # ``execute_task`` post-success — see # provisioning/service.py:auto_api_success branch). The admin # uses the group list to re-grant in the Atlassian admin # console. Future enhancement: auto-schedule a grant-to-groups # task; that needs a driver method that doesn't exist yet, so # we keep this honest-signal first. manual_restore_notes: list[dict[str, Any]] = [] for b in proc_blockers: if b.resource_type != RES_SOFTWARE_ACCOUNT: continue if b.provisioning_task_id is None: continue with self.db.no_autoflush: task_row = await self.db.get( ProvisioningTask, b.provisioning_task_id, ) if task_row is None or task_row.status != TASK_COMPLETED: continue ctx = task_row.context_data or {} vendor = ctx.get("vendor_response") or {} removed_groups = vendor.get("removed") or [] note_entry = { "blocker_id": str(b.id), "resource_id": str(b.resource_id), "integration_id": str(b.resource_id), "label": b.label, "completed_revoke_task_id": str(task_row.id), "external_ref": task_row.external_ref, "removed_groups": list(removed_groups), "removed_group_count": len(removed_groups), } manual_restore_notes.append(note_entry) skipped_restore_reasons.append({ "blocker_id": str(b.id), "reason": "software_account_manual_restore_required", "detail": ( f"Atlassian/Jira group memberships removed " f"({len(removed_groups)} groups). Helix cannot " f"auto-restore — re-grant via vendor admin console. " f"Removed: {', '.join(removed_groups[:8])}" + (f" (+{len(removed_groups) - 8} more)" if len(removed_groups) > 8 else "") ), }) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="software_account.restore_manual_required", status="success", resource_type="offboarding_blocker", resource_id=str(b.id), detail={ "offboarding_process_id": str(proc.id), "completed_revoke_task_id": str(task_row.id), "integration_id": str(b.resource_id), "label": b.label, "external_ref": task_row.external_ref, "removed_groups": list(removed_groups), "person_id": str(proc.person_id), }, ) # ── Google account-suspend reversal — DEFERRED post-commit (#1794) ── # #1794: the vendor unsuspend is NO LONGER called inline here. Calling # ``unsuspend_google_account`` mid-transaction was unsafe: any later # raise (audit FK, deadlock) OR a commit failure rolled back the # Helix-side ledger while the vendor account was already re-enabled — # leaving ``session_enforcement.google_account.status='suspended'`` on # a LIVE account (the ``offboarding_session_not_cut`` data-quality scan # then reads a false ledger; the exact reverse of the drift the SUSPEND # direction was deliberately moved to post-commit to avoid). The router # calls ``unsuspend_google_after_cancel`` AFTER this cancel commits, so # the vendor call only happens once the cancel is durable. google_account_restore: dict[str, Any] | None = ( {"status": "deferred_post_commit"} if (google_account_suspended and person is not None) else None ) # Same deferred-post-commit contract for the Zoom deactivate reversal # (router calls ``unsuspend_zoom_after_cancel`` after the commit). zoom_account_restore: dict[str, Any] | None = ( {"status": "deferred_post_commit"} if (zoom_account_suspended and person is not None) else None ) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.process_cancelled", status="success", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "reason": reason, "person_id": str(proc.person_id), "prior_employment_status": prior_employment_status, "prior_termination_date": ( prior_termination_date.isoformat() if prior_termination_date else None ), "prior_force_logout_at": ( prior_force_logout_at.isoformat() if prior_force_logout_at else None ), "force_logout_at_cleared": prior_force_logout_at is not None, "restored_to_active": prior_employment_status is not None, "advance_reason": advance_reason, "confirm_irreversible_passed": confirm_irreversible, "cascaded_xfer_cancellations": cancelled_xfer_ids, "cascaded_task_cancellations": cancelled_task_ids, "skipped_inflight_tasks": skipped_inflight_task_ids, "restored_seat_ids": restored_seat_ids, "restoration_grant_task_ids": restoration_grant_task_ids, "skipped_restore_reasons": skipped_restore_reasons, "software_account_manual_restore_required": ( manual_restore_notes ), "google_account_suspend_reversed": google_account_restore, "zoom_account_suspend_reversed": zoom_account_restore, }, ) return proc async def unsuspend_google_after_cancel( self, process_id: UUID, *, trace_id: str, actor_user_id: UUID | None = None, ) -> dict[str, Any]: """#1794: post-commit Google account-suspend reversal for a cancelled offboarding. Called by the router AFTER ``cancel_process`` + its commit. Because the cancel is already durable, the vendor unsuspend here can never leave the ledger disagreeing with vendor reality on a rollback (the failure mode the inline call had). Self-contained: re-reads the process under a row lock, verifies the Google account ledger still says ``suspended`` (idempotent — a concurrent path may have already reversed it), calls the coordinator, updates the ledger, and commits. Never raises — on failure it records a manual-restore audit hint so the admin re-enables the account in the Google Admin console (mirrors the software_account manual-restore surface). """ from app.identity.person_models import IdentityPerson proc = await self.db.scalar( select(OffboardingProcess) .where(OffboardingProcess.id == process_id) .with_for_update() .execution_options(populate_existing=True) ) if proc is None: return {"status": "skipped", "reason": "process not found"} acct = (proc.session_enforcement or {}).get("google_account") or {} if acct.get("status") != "suspended": # Nothing to reverse (never suspended, or already reversed). return {"status": "noop", "ledger_status": acct.get("status")} person = await self.db.get(IdentityPerson, proc.person_id) if person is None: return {"status": "skipped", "reason": "person not found"} from app.core.coordinators import ( # noqa: PLC0415 account_lifecycle_coordinator, ) res = await account_lifecycle_coordinator.unsuspend_google_account( self.db, person, ) result = { "status": res.get("status"), "already_absent": res.get("already_absent"), "error": (str(res.get("error"))[:200] if res.get("error") else None), } if res.get("status") == "unsuspended": merged_enf = dict(proc.session_enforcement or {}) ga = dict(merged_enf.get("google_account") or {}) ga["status"] = "unsuspended_on_cancel" ga["unsuspended_at"] = datetime.now(UTC).isoformat() merged_enf["google_account"] = ga proc.session_enforcement = merged_enf await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="google_account.restored_on_offboarding_cancel", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "offboarding_process_id": str(proc.id), "work_email": person.work_email, "already_absent": res.get("already_absent"), }, ) else: await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="google_account.restore_manual_required", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "offboarding_process_id": str(proc.id), "work_email": person.work_email, "unsuspend_status": res.get("status"), "error": result["error"], }, ) await self.db.commit() return result async def unsuspend_zoom_after_cancel( self, process_id: UUID, *, trace_id: str, actor_user_id: UUID | None = None, ) -> dict[str, Any]: """Post-commit Zoom account-deactivate reversal for a cancelled offboarding — parity with :meth:`unsuspend_google_after_cancel`. Called by the router AFTER ``cancel_process`` + its commit, so the vendor re-activate can never leave the ledger disagreeing with vendor reality on a rollback. Self-contained: re-reads the process under a row lock, verifies the Zoom account ledger still says ``suspended`` (idempotent — a concurrent path may have already reversed it), calls the coordinator, updates the ledger, and commits. Never raises — on failure it records a manual-restore audit hint so the admin re-enables the account in the Zoom admin console. """ from app.identity.person_models import IdentityPerson proc = await self.db.scalar( select(OffboardingProcess) .where(OffboardingProcess.id == process_id) .with_for_update() .execution_options(populate_existing=True) ) if proc is None: return {"status": "skipped", "reason": "process not found"} acct = (proc.session_enforcement or {}).get("zoom_account") or {} if acct.get("status") != "suspended": # Nothing to reverse (never suspended, or already reversed). return {"status": "noop", "ledger_status": acct.get("status")} person = await self.db.get(IdentityPerson, proc.person_id) if person is None: return {"status": "skipped", "reason": "person not found"} from app.core.coordinators import ( # noqa: PLC0415 account_lifecycle_coordinator, ) res = await account_lifecycle_coordinator.unsuspend_zoom_account( self.db, person, ) result = { "status": res.get("status"), "already_absent": res.get("already_absent"), "error": (str(res.get("error"))[:200] if res.get("error") else None), } if res.get("status") == "unsuspended": merged_enf = dict(proc.session_enforcement or {}) za = dict(merged_enf.get("zoom_account") or {}) za["status"] = "unsuspended_on_cancel" za["unsuspended_at"] = datetime.now(UTC).isoformat() merged_enf["zoom_account"] = za proc.session_enforcement = merged_enf await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="zoom_account.restored_on_offboarding_cancel", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "offboarding_process_id": str(proc.id), "work_email": person.work_email, "already_absent": res.get("already_absent"), }, ) else: await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="zoom_account.restore_manual_required", status="success", resource_type="identity_person", resource_id=str(person.id), detail={ "offboarding_process_id": str(proc.id), "work_email": person.work_email, "unsuspend_status": res.get("status"), "error": result["error"], }, ) await self.db.commit() return result async def _rescan( self, proc: OffboardingProcess, *, trace_id: str, actor_user_id: UUID | None, ) -> None: """Populate (or refresh) the blocker list by scanning the person's active resources. Idempotent: existing unresolved blockers for the same resource are kept as-is; new resources become new blockers. """ existing = ( await self.db.execute( select(OffboardingBlocker).where(OffboardingBlocker.process_id == proc.id) ) ).scalars().all() existing_keys = {(b.resource_type, b.resource_id): b for b in existing} # Pre-launch P1 fix (re-opened cancelled process leaves stale # blockers): track every (resource_type, resource_id) the # current scan touches. At the end of _rescan we mark any # existing pending blocker that DOES NOT appear in this set # as ``RESO_SKIPPED`` with a stale-after-reopen note. Without # this, a process reopened after cancel keeps the prior run's # pending blockers forever — e.g. an asset disposed during # the cancelled window stays as a permanent PENDING blocker # and the manager-cascade can never reach completion. scanned_keys: set[tuple[str, Any]] = set() person_id = proc.person_id # Leaver invariant: sweep away not-yet-dispatched GRANT tasks the # moment (and for as long as) an offboarding process is active. # A pending onboarding/bundle grant that outlives the person's # flip to leaver would otherwise mint a fresh vendor seat AFTER # the revoke side already ran — silent divergence. Runs on every # rescan (incl. the 5-minute reconcile cron) so grants enqueued # after process start are caught too. IN_PROGRESS tasks are left # to finish (mirrors cancel_process: racing the driver loses the # cancel); the grantee guard in _invoke_connector is the backstop. from app.domains.provisioning.models import ( # noqa: PLC0415 ACTION_GRANT as _ACTION_GRANT, ) from app.domains.provisioning.models import ( # noqa: PLC0415 STATUS_CANCELLED as _TASK_CANCELLED, ) pending_grants = ( await self.db.execute( select(ProvisioningTask).where( ProvisioningTask.user_id == person_id, ProvisioningTask.action == _ACTION_GRANT, ProvisioningTask.status.in_( [TASK_PENDING, TASK_NOTIFIED], ), ) ) ).scalars().all() for _gt in pending_grants: _gt.status = _TASK_CANCELLED _gt.error_message = ( f'Auto-cancelled: offboarding process {proc.id} active ' '(leaver invariant — no new grants for a departing person)' ) await self.audit.log( trace_id=trace_id, actor_type='system' if not actor_user_id else 'user', actor_user_id=actor_user_id, action='provisioning.grant_cancelled_offboarding', status='success', resource_type='provisioning_task', resource_id=str(_gt.id), detail={ 'person_id': str(person_id), 'process_id': str(proc.id), 'target_type': _gt.target_type, 'target_id': str(_gt.target_id) if _gt.target_id else None, }, ) # ── Hardware ── # Round 2b/4 audit fix-up 2026-05-04: previously only matched # ``AssetItem.assigned_to == person_id AND status == 'assigned'``. # That missed self-claimed assets, borrowed-OUT (status=borrowed # with assigned_to=owner), borrowed-IN (borrowed_by=person), and # all the *_pending in-flight states. # Use the canonical ``ASSET_IN_USE_STATUSES`` set (Round 4 — # promoted to ``app.domains.asset.models`` so this scan stays # in lockstep with the asset domain's ``delete_asset`` "in use" # gate; both must agree on what "person is responsible for this # asset" means). from app.domains.asset.models import ASSET_IN_USE_STATUSES hw_rows = ( await self.db.execute( select(AssetItem).where( AssetItem.deleted_at.is_(None), or_( and_( AssetItem.assigned_to == person_id, AssetItem.status.in_(ASSET_IN_USE_STATUSES), ), and_( AssetItem.borrowed_by == person_id, # Round 6 audit fix-up 2026-05-04: widen # to include in-flight borrow states so a # subordinate offboarding mid-borrow_pending # (request approved, dispatch in flight, # status not yet flipped) isn't missed as # the borrower side. AssetItem.status.in_({ "borrowed", "borrow_pending", "borrow_return_pending", }), ), ), ) ) ).scalars().all() for asset in hw_rows: key = (RES_HARDWARE, asset.id) scanned_keys.add(key) if key in existing_keys: continue label_parts = [asset.asset_tag or "", asset.brand or "", asset.model or ""] label = " / ".join(p for p in label_parts if p) or str(asset.id) self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_HARDWARE, resource_id=asset.id, label=label, severity=severity_for_resource(RES_HARDWARE), )) # ── Software seats ── # Per-software offboarding_policy (set on the connector row) # drives auto-resolution: auto_revoke creates a provisioning task, # sso_cascade marks the blocker resolved immediately (it dies with # Google/Feishu account suspension). Software has no # must_transfer — seats are people-bound. seat_rows = ( await self.db.execute( select(SoftwareSeatAllocation, SoftwareLicensePlan, AssetSoftwareCatalog) .join(SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id) .join(AssetSoftwareCatalog, SoftwareLicensePlan.software_id == AssetSoftwareCatalog.id) .where( and_( SoftwareSeatAllocation.user_id == person_id, SoftwareSeatAllocation.status.in_(["active", "pending"]), ) ) ) ).all() # Jira/Atlassian offboarding is account-wide, not seat-level: the # leaver gets ONE full-access-revoke blocker (below) that removes # them from EVERY group they belong to, so we must NOT also emit # per-seat blockers for Jira-backed software (those would each try # to revoke a single group — the flawed partial coverage we're # replacing). from app.core.coordinators.jira_seat_coordinator import ( get_jira_offboarding_target, ) jira_target = await get_jira_offboarding_target(self.db) jira_software_ids: set[UUID] = ( jira_target["software_ids"] if jira_target else set() ) # Device management is not personal access. A catalogue row bound # to an MDM (Jamf) counts enrolled hardware, not seats a person # holds: the Mac is returned, re-imaged and handed to the next # hire without ever leaving the tenant, so a departure has nothing # to revoke. Cleanup is triggered by the device leaving company # ownership — see ``mdm_device_retirement_coordinator``. Leaving # this gate in place even though the seat rows themselves are gone # (migration 558 closed them): an admin can re-import allocations # by hand, and the blocker must not come back if they do. from app.core.coordinators.jamf_seat_coordinator import ( mdm_managed_software_ids, ) mdm_software_ids: set[UUID] = await mdm_managed_software_ids(self.db) seat_blockers: list[tuple[OffboardingBlocker, UUID]] = [] for seat, plan, sw in seat_rows: if sw.id in jira_software_ids: # Covered by the single Jira full-access-revoke blocker. continue if sw.id in mdm_software_ids: # MDM enrolment follows the device, not the employee. continue key = (RES_SOFTWARE_SEAT, seat.id) scanned_keys.add(key) if key in existing_keys: continue blocker = OffboardingBlocker( process_id=proc.id, resource_type=RES_SOFTWARE_SEAT, resource_id=seat.id, label=f"{sw.name} — {plan.name}", software_id=sw.id, severity=severity_for_resource(RES_SOFTWARE_SEAT), ) self.db.add(blocker) seat_blockers.append((blocker, sw.id)) # ── Jira/Atlassian full-access revoke (remove from all groups) ── # ONE blocker per leaver when a Jira integration is configured — # created off the integration's existence, NOT off tracked seats, # so a leaver whose Jira access lived only in untracked groups # (jira-administrators, SOC2, per-site groups, …) is still caught. # resource_id = the Jira integration id (a real UUID; the column # is NOT NULL). The account lookup + enumerate-all-groups + remove # happens at task-execution time in the worker (idempotent), # keeping the scan DB-only. account_blockers carries # (blocker, integration_id). account_blockers: list[tuple[OffboardingBlocker, UUID]] = [] if jira_target is not None: integration_id = jira_target["integration_id"] key = (RES_SOFTWARE_ACCOUNT, integration_id) scanned_keys.add(key) if key not in existing_keys: acct_blocker = OffboardingBlocker( process_id=proc.id, resource_type=RES_SOFTWARE_ACCOUNT, resource_id=integration_id, label="Atlassian/Jira 访问回收(移出全部 group)", severity=severity_for_resource(RES_SOFTWARE_ACCOUNT), ) self.db.add(acct_blocker) account_blockers.append((acct_blocker, integration_id)) # ── Software permissions ── # The ``permission`` domain is optional in legacy deployments. # Guard ONLY the import — DB errors during the SELECT must # propagate so a real failure doesn't silently leave a leaver # with active permissions and no blocker. perm_blockers: list[tuple[OffboardingBlocker, UUID]] = [] try: from app.domains.permission.models import ( PERM_ACTIVE, PERM_PENDING, SoftwarePermission, UserPermission, ) except ImportError: logger.info( "permission domain not installed; offboarding will skip " "per-permission blockers for person %s", person_id, ) else: perm_rows = ( await self.db.execute( select(UserPermission, SoftwarePermission, AssetSoftwareCatalog) .join(SoftwarePermission, UserPermission.permission_id == SoftwarePermission.id) .join(AssetSoftwareCatalog, SoftwarePermission.software_id == AssetSoftwareCatalog.id) .where( and_( UserPermission.user_id == person_id, UserPermission.status.in_([PERM_ACTIVE, PERM_PENDING]), ) ) ) ).all() for up, perm, sw in perm_rows: key = (RES_SOFTWARE_PERMISSION, up.id) scanned_keys.add(key) if key in existing_keys: continue blocker = OffboardingBlocker( process_id=proc.id, resource_type=RES_SOFTWARE_PERMISSION, resource_id=up.id, label=f"{sw.name} — {perm.display_name}", software_id=sw.id, severity=severity_for_resource(RES_SOFTWARE_PERMISSION), ) self.db.add(blocker) perm_blockers.append((blocker, sw.id)) # ── API keys ── ak_rows = ( await self.db.execute( select(ApiKeyAssignment).where( and_( ApiKeyAssignment.assigned_to == person_id, ApiKeyAssignment.status.in_(["active", "approved", "pending_approval"]), ) ) ) ).scalars().all() for ak in ak_rows: key = (RES_API_KEY, ak.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_API_KEY, resource_id=ak.id, label=f"API Key {ak.request_number} — {ak.project_name}", severity=severity_for_resource(RES_API_KEY), )) # ── Cloud accounts: subsumed by RES_CLOUD_SCOPE_OWNER + # RES_CLOUD_ACCESS_GRANT below (migration 053 dropped the # legacy cmdb_cloud_accounts table). Scope owner is transferred # up the manager cascade; individual grants are revoked per # driver. No separate cloud-account scan needed. # ── Network assets: domains / DNS records / cloud servers ── # Always MUST_TRANSFER semantics — these resources can't be # revoked; the owner FK just needs to move to the new responsible # person. _force_transfer_all handles the actual FK rewrite on # leaver_date via the manager cascade. from app.domains.cmdb.models import CmdbDnsRecord, CmdbDomain, CmdbServer domain_rows = ( await self.db.execute( select(CmdbDomain).where( and_( CmdbDomain.owner_person_id == str(person_id), CmdbDomain.deleted_at.is_(None), ), ), ) ).scalars().all() for dom in domain_rows: key = (RES_DOMAIN, dom.id) scanned_keys.add(key) if key in existing_keys: continue label = dom.domain_name or f"domain {dom.id}" if dom.registrar: label = f"{label} @ {dom.registrar}" self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_DOMAIN, resource_id=dom.id, label=label, severity=severity_for_resource(RES_DOMAIN), )) dns_rows = ( await self.db.execute( select(CmdbDnsRecord).where( and_( CmdbDnsRecord.owner_person_id == str(person_id), CmdbDnsRecord.deleted_at.is_(None), ), ), ) ).scalars().all() for rec in dns_rows: key = (RES_DNS_RECORD, rec.id) scanned_keys.add(key) if key in existing_keys: continue # Render "name.zone_name TYPE" — e.g. "www.example.com A" head = rec.name or rec.zone_name or "" if rec.name and rec.zone_name and not rec.name.endswith(rec.zone_name): head = f"{rec.name}.{rec.zone_name}" label = f"{head} {rec.record_type}".strip() self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_DNS_RECORD, resource_id=rec.id, label=label or str(rec.id), severity=severity_for_resource(RES_DNS_RECORD), )) server_rows = ( await self.db.execute( select(CmdbServer).where( and_( CmdbServer.owner_person_id == str(person_id), CmdbServer.deleted_at.is_(None), ), ), ) ).scalars().all() for srv in server_rows: key = (RES_CLOUD_SERVER, srv.id) scanned_keys.add(key) if key in existing_keys: continue label_bits = [srv.name, srv.instance_id] if srv.region: label_bits.append(srv.region) label = " / ".join(p for p in label_bits if p) or str(srv.id) self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_CLOUD_SERVER, resource_id=srv.id, label=label, severity=severity_for_resource(RES_CLOUD_SERVER), )) # ── Load Balancers + CDN domains (2026-05-18) ────────── # Mirror domain / DNS / server scan. Migration 173 / 160 # populated ``owner_person_id`` on these tables but the # scanner never walked them — leaver kept perpetual # ownership through every offboarding cycle. from app.domains.cmdb.models import ( # noqa: PLC0415 CmdbCdnDomain, CmdbLoadBalancer, ) lb_rows = ( await self.db.execute( select(CmdbLoadBalancer).where( and_( CmdbLoadBalancer.owner_person_id == str(person_id), CmdbLoadBalancer.deleted_at.is_(None), ), ), ) ).scalars().all() for lb in lb_rows: key = (RES_LOAD_BALANCER, lb.id) scanned_keys.add(key) if key in existing_keys: continue label_bits = [lb.name, lb.lb_id, lb.region] label = " / ".join(p for p in label_bits if p) or str(lb.id) self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_LOAD_BALANCER, resource_id=lb.id, label=label, severity=severity_for_resource(RES_LOAD_BALANCER), )) cdn_rows = ( await self.db.execute( select(CmdbCdnDomain).where( and_( CmdbCdnDomain.owner_person_id == str(person_id), CmdbCdnDomain.deleted_at.is_(None), ), ), ) ).scalars().all() for cdn in cdn_rows: key = (RES_CDN_DOMAIN, cdn.id) scanned_keys.add(key) if key in existing_keys: continue label = cdn.domain_name or str(cdn.id) self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_CDN_DOMAIN, resource_id=cdn.id, label=label, severity=severity_for_resource(RES_CDN_DOMAIN), )) # ── Object storage buckets + manual certificate owners ── # (2026-09-05, PR3). Both carry ``owner_person_id`` exactly like # LB / CDN above and were never scanned — a leaver stayed the # named owner of a production bucket through every offboarding. # # Certificates are filtered to ``owner_is_manual`` ON PURPOSE: # a probed / cloud-synced certificate's owner is live-resolved # from the domain chain (migration 360), so "transferring" it # would fight the resolver and put a false owner pair in the # history. Only a deliberate admin assignment is this person's # to hand over. from app.domains.cmdb.models import ( # noqa: PLC0415 CmdbCertificate, CmdbObjectStorage, ) from app.domains.offboarding.models import ( # noqa: PLC0415 RES_CERTIFICATE, RES_OBJECT_STORAGE, ) oss_rows = ( await self.db.execute( select(CmdbObjectStorage).where( and_( CmdbObjectStorage.owner_person_id == str(person_id), CmdbObjectStorage.deleted_at.is_(None), ), ), ) ).scalars().all() for oss in oss_rows: key = (RES_OBJECT_STORAGE, oss.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_OBJECT_STORAGE, resource_id=oss.id, label=f"对象存储桶: {oss.bucket_name}", severity=severity_for_resource(RES_OBJECT_STORAGE), )) cert_rows = ( await self.db.execute( select(CmdbCertificate).where( and_( CmdbCertificate.owner_person_id == str(person_id), CmdbCertificate.owner_is_manual.is_(True), CmdbCertificate.deleted_at.is_(None), ), ), ) ).scalars().all() for cert in cert_rows: key = (RES_CERTIFICATE, cert.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_CERTIFICATE, resource_id=cert.id, label=f"证书负责人: {cert.common_name or cert.id}", severity=severity_for_resource(RES_CERTIFICATE), )) # ── Software catalog ownership (2026-04-22) ───────────── # A leaver who's the business owner or IT contact of a # software_catalog row must hand it off — the record tracks # who's responsible for the software (billing, renewals, # downstream contact), not access to it. Always-transfer. # One software row can surface twice (owner + it_contact) so # each FK gets its own blocker and resolution. sw_owner_rows = ( await self.db.execute( select(AssetSoftwareCatalog).where( AssetSoftwareCatalog.owner_person_id == person_id, ), ) ).scalars().all() for sw in sw_owner_rows: key = (RES_SOFTWARE_OWNER, sw.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_SOFTWARE_OWNER, resource_id=sw.id, label=f"软件负责人: {sw.name}", severity=severity_for_resource(RES_SOFTWARE_OWNER), )) sw_it_rows = ( await self.db.execute( select(AssetSoftwareCatalog).where( AssetSoftwareCatalog.it_contact_person_id == person_id, ), ) ).scalars().all() for sw in sw_it_rows: key = (RES_SOFTWARE_IT_CONTACT, sw.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_SOFTWARE_IT_CONTACT, resource_id=sw.id, label=f"软件 IT 负责人: {sw.name}", severity=severity_for_resource(RES_SOFTWARE_IT_CONTACT), )) # ── Email groups (PR-D 2026-04-21) ────────────────────── # Google requires ≥1 owner per group; suspending the leaver's # account while they're still the sole owner of a group leaves # the group in a "ghost" state (mail routes, membership # frozen, no admin). Always-transfer policy. email_groups = await self._scan_email_groups(person_id) for group in email_groups: key = (RES_EMAIL_GROUP, group.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_EMAIL_GROUP, resource_id=group.id, label=f"{group.name} <{group.email}>", severity=severity_for_resource(RES_EMAIL_GROUP), )) # ── Static mail-group MEMBERSHIP (2026-09-05, PR3) ────── # Ownership (above) has always been handled; plain membership # never was. While the leaver's Google account is suspended the # membership is inert — but a RETAINED account (CBG / shared # service classification, the exact population the assurance # page tracks) or a revived one keeps receiving whatever the # group receives. People-bound, so it is revoked at the source # on the T+0 tick rather than transferred. # # Groups the leaver OWNS are skipped here: Google needs ≥1 owner, # so the ownership blocker above must land first — a membership # removal on a sole owner would break the group. The next rescan # (after the transfer) picks the membership up. # A missing / unreadable Google integration is a skip, not a # failure: the family simply does not appear this round. from app.domains.email_group.membership_scan import ( # noqa: PLC0415 static_group_memberships_for_person, ) from app.domains.offboarding.models import ( # noqa: PLC0415 RES_EMAIL_GROUP_MEMBER, ) leaver_person = await self.db.get(IdentityPerson, person_id) if leaver_person is not None: owned_group_ids = {g.id for g in email_groups} membership = await static_group_memberships_for_person( self.db, leaver_person, ) if membership.skipped_reason: logger.info( "offboarding: mail-group membership scan skipped for %s (%s)", person_id, membership.skipped_reason, ) for group in membership.groups: if group.id in owned_group_ids: continue key = (RES_EMAIL_GROUP_MEMBER, group.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_EMAIL_GROUP_MEMBER, resource_id=group.id, label=f"邮件组成员: {group.email}", severity=severity_for_resource(RES_EMAIL_GROUP_MEMBER), )) # ── Cloud scope ownership + grants (PR 2026-04-23) ───── # Scopes the leaver owns: MUST_TRANSFER (force-transfer up the # manager cascade on T+0 — otherwise the scope sits in # unowned-compliance-incident state and blocks new grants). # Active grants the leaver holds: always revoke (grants aren't # transferable — the replacement can re-request). from app.domains.cmdb.models import ( GRANT_STATUS_ACTIVE, GRANT_STATUS_PENDING, CmdbCloudAccessGrant, CmdbCloudScope, owner_governed_scope_criteria, ) from app.domains.offboarding.models import ( RES_CLOUD_ACCESS_GRANT, RES_CLOUD_SCOPE_OWNER, ) # A blocker names something a human (or the T+0 force-transfer # path) must act on, so it must only ever name something that # CAN be acted on. Prod 2026-09-07 audit: this scanner # hand-rolled ``deleted_at IS NULL`` while the leaver-residue # reconciler next door applied the status filter, so it raised # blockers for a ``closed`` azure_resource_group and a # ``legacy_billing_unit`` — rows that are not accounts. Those # did not wedge anything (force-transfer auto-resolved both; # prod has zero unresolved blockers of any type) — the cost is # that a successor was handed "ownership" of a cost-allocation # row. Same predicate on both sides now; the preview below # shares it too, so the preview can never promise a different # blocker set than the process creates. scope_rows = (await self.db.execute( select(CmdbCloudScope).where( CmdbCloudScope.owner_person_id == str(person_id), *owner_governed_scope_criteria(), ) )).scalars().all() for scope in scope_rows: key = (RES_CLOUD_SCOPE_OWNER, scope.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_CLOUD_SCOPE_OWNER, resource_id=scope.id, label=( f"Scope 负责人: {scope.display_name or scope.external_id} " f"({scope.scope_type})" ), severity=severity_for_resource(RES_CLOUD_SCOPE_OWNER), )) grant_rows = (await self.db.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.person_id == str(person_id), CmdbCloudAccessGrant.status.in_( (GRANT_STATUS_ACTIVE, GRANT_STATUS_PENDING), ), ) )).scalars().all() for grant in grant_rows: key = (RES_CLOUD_ACCESS_GRANT, grant.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_CLOUD_ACCESS_GRANT, resource_id=grant.id, label=( f"云访问 grant: {grant.role_display_name or grant.role_key} " f"({grant.status})" ), severity=severity_for_resource(RES_CLOUD_ACCESS_GRANT), )) # ── Identity-consumer ownership (identity_consumers) ────── # The leaver owns an external system that pulls directory data # (PII egress §10.6). Hand to a successor, else the T+0 handler # DISABLES it (a data tap must not run under a departed owner). from app.domains.offboarding.models import ( RES_IDENTITY_CONSUMER_OWNER, ) from app.identity.consumers.models import IdentityConsumer consumer_rows = (await self.db.execute( select(IdentityConsumer).where( IdentityConsumer.owner_person_id == person_id, IdentityConsumer.deleted_at.is_(None), ) )).scalars().all() for consumer in consumer_rows: key = (RES_IDENTITY_CONSUMER_OWNER, consumer.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_IDENTITY_CONSUMER_OWNER, resource_id=consumer.id, label=f"身份消费方负责人: {consumer.name} ({consumer.slug})", severity=severity_for_resource(RES_IDENTITY_CONSUMER_OWNER), )) # ── Cloud IAM user ownership (cmdb_iam_users) ───────────── # When an admin maps an IAM user (AWS IAM / Aliyun RAM / future # CAM) to a Helix person, that person owns the credential # operationally. On offboarding the FK MUST move to a teammate # via the manager-fallback chain — otherwise the leaver retains # named-on-record responsibility for credentials they no longer # control. Pre-2026-05-12 gap: the T+1 post_leaver_verifier # raised compliance_incidents for this case but never resolved # them, so leavers stayed mapped indefinitely. Scanner symmetry # with the force-transfer branch in advance_service closes the # loop. from app.domains.cmdb.models import CmdbIamUser # noqa: PLC0415 from app.domains.offboarding.models import ( # noqa: PLC0415 RES_CLOUD_IAM_USER, ) iam_user_rows = (await self.db.execute( select(CmdbIamUser).where( CmdbIamUser.owner_person_id == str(person_id), CmdbIamUser.deleted_at.is_(None), # ``revoked_at IS NULL`` excludes IAM users we already # cleaned up on the cloud side (offboarding evacuation / # scope decommission). Their owner FK is bookkeeping # only and force-transfer would create noise. CmdbIamUser.revoked_at.is_(None), ) )).scalars().all() for iam in iam_user_rows: key = (RES_CLOUD_IAM_USER, iam.id) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_CLOUD_IAM_USER, resource_id=iam.id, label=( f"IAM 用户: {iam.user_name or iam.arn or str(iam.id)}" ), severity=severity_for_resource(RES_CLOUD_IAM_USER), )) # ── SSO grants (sso_grants, status=approved) ────────────── # Generic flow added 2026-05-02. Approved grants tie the seat # to a Helix identity; a leaver MUST hand it to a successor # (the seat itself stays — the access just changes hands). from app.domains.offboarding.models import RES_SSO_GRANT from app.domains.sso.models import ( SSO_GRANT_STATUS_APPROVED, SsoGrant, ) sso_rows = (await self.db.execute( select(SsoGrant).where( SsoGrant.person_id == str(person_id), SsoGrant.status == SSO_GRANT_STATUS_APPROVED, ) )).scalars().all() for grant in sso_rows: key = (RES_SSO_GRANT, UUID(grant.id)) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_SSO_GRANT, resource_id=UUID(grant.id), label=f"SSO 授权: {grant.sso_provider} · {grant.system_name}", severity=severity_for_resource(RES_SSO_GRANT), )) # ── GitHub 组织成员资格 ────────────────────────────────── # 人绑权限,T+0 直接在源头撤销。**一条成员资格一行 blocker**, # 不是「一个人一行」:每个组织的离职策略可以不同(社区组织可以 # 豁免),合并成一行就无法表达「移出了 3 个、留了 1 个」。 # # 两类刻意不入队: # * ``offboarding_policy='exempt'`` 的组织 —— 社区组织,离职的人 # 可能仍是合法的 maintainer。豁免是 org owner 明确做出的决定, # 不是一个默认值(登记表播种时全是 remove)。 # * 已经是 removed / declined / expired 的记录 —— 没有活着的 # 访问权可撤。 # # ``pending_invite`` **要**入队:那张邀请随时可能被点接受,把它 # 排除在离职回收之外就是留了一个后门。驱动的 DELETE membership # 同时收掉活跃成员资格和待接受邀请,所以撤销动作是同一个。 from app.domains.github_org.constants import ( MEMBERSHIP_LIVE_STATES, OFFBOARDING_POLICY_EXEMPT, ) from app.domains.github_org.models import ( GithubOrg, GithubOrgMembership, ) from app.domains.offboarding.models import RES_GITHUB_ORG_MEMBERSHIP gh_rows = (await self.db.execute( select(GithubOrgMembership, GithubOrg) .join(GithubOrg, GithubOrg.id == GithubOrgMembership.org_id) .where( GithubOrgMembership.person_id == str(person_id), GithubOrgMembership.state.in_(MEMBERSHIP_LIVE_STATES), GithubOrg.deleted_at.is_(None), ) )).all() for membership, org in gh_rows: if org.offboarding_policy == OFFBOARDING_POLICY_EXEMPT: continue key = (RES_GITHUB_ORG_MEMBERSHIP, UUID(membership.id)) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_GITHUB_ORG_MEMBERSHIP, resource_id=UUID(membership.id), label=( f"GitHub 组织: {org.slug} · {membership.github_login}" ), severity=severity_for_resource(RES_GITHUB_ORG_MEMBERSHIP), )) # ── Domain registrations (domain_registrations, # status=approved) ─────────────────────────────────────── # Same MUST_TRANSFER semantics: the registered domain stays # an org asset; only the responsible person changes. from app.domains.domain_registration.models import ( DOMAIN_REG_STATUS_APPROVED, DomainRegistration, ) from app.domains.offboarding.models import RES_DOMAIN_REGISTRATION domreg_rows = (await self.db.execute( select(DomainRegistration).where( DomainRegistration.person_id == str(person_id), DomainRegistration.status == DOMAIN_REG_STATUS_APPROVED, ) )).scalars().all() for reg in domreg_rows: key = (RES_DOMAIN_REGISTRATION, UUID(reg.id)) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_DOMAIN_REGISTRATION, resource_id=UUID(reg.id), label=f"已注册域名: {reg.domain_name}", severity=severity_for_resource(RES_DOMAIN_REGISTRATION), )) # ── Internal system registry ownership (added 2026-08-25) ─ # The leaver is the named owner of a live first-party system. # MUST_TRANSFER: the system keeps running and keeps receiving # inbound vulnerability reports; a report routed at a departed # owner produces silence, not a visible queue. # # Backup-owner-only rows are scanned too, with a distinct label — # leaving a departed employee as the emergency contact is the # same stale state one step removed. from app.core.coordinators import ( system_registry_coordinator as _registry, ) from app.domains.offboarding.models import RES_SYSTEM_OWNER for owned in await _registry.systems_owned_by(self.db, person_id): key = (RES_SYSTEM_OWNER, UUID(owned["id"])) scanned_keys.add(key) if key in existing_keys: continue role = "负责人" if owned["is_primary_owner"] else "备份负责人" self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_SYSTEM_OWNER, resource_id=UUID(owned["id"]), label=f"系统{role}: {owned['name']}", severity=severity_for_resource(RES_SYSTEM_OWNER), )) # ── Approval-flow registry ownership (added 2026-08-26) ─ # The leaver is the named owner of a live approval flow. The # flow keeps running in Feishu; what stops is anyone answering # for it — and the all-staff page publishes that name, so a # departed owner turns 「这个流程找谁」 into a dead end. from app.core.coordinators import ( approval_registry_coordinator as _flow_registry, ) from app.domains.offboarding.models import RES_APPROVAL_FLOW_OWNER for owned_flow in await _flow_registry.flows_owned_by(self.db, person_id): key = (RES_APPROVAL_FLOW_OWNER, UUID(owned_flow["id"])) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_APPROVAL_FLOW_OWNER, resource_id=UUID(owned_flow["id"]), label=f"审批流程负责人: {owned_flow['name']}", severity=severity_for_resource(RES_APPROVAL_FLOW_OWNER), )) # ── Google Workspace orphan accounts (added 2026-05-02) ─ # Shared mailboxes / service accounts where the leaver is the # named owner. MUST_TRANSFER — IT picks a new owner via the # /admin/google-workspace/orphans page (or the offboarding # panel's compact list). Until then the leaver flow stays # blocked, which is the whole point: never lose accountability # for a billed Google account. from app.domains.google_workspace.models import GoogleOrphanAccount from app.domains.offboarding.models import RES_GOOGLE_ORPHAN_OWNER # Round-49: filter out soft-deleted orphan accounts (migration # 129 soft-deletes when google.seat_sync observes the account # disappeared from Google). A tombstoned row should not raise # a fresh offboarding blocker — the force-transfer branch # would then audit-log an ownership change on a tombstone. orphan_rows = (await self.db.execute( select(GoogleOrphanAccount).where( GoogleOrphanAccount.owner_user_id == person_id, GoogleOrphanAccount.deleted_at.is_(None), ) )).scalars().all() for orphan in orphan_rows: key = (RES_GOOGLE_ORPHAN_OWNER, orphan.id) scanned_keys.add(key) if key in existing_keys: continue label_suffix = f" ({orphan.purpose})" if orphan.purpose else "" self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_GOOGLE_ORPHAN_OWNER, resource_id=orphan.id, label=f"Google 共用账号负责人: {orphan.primary_email}{label_suffix}", severity=severity_for_resource(RES_GOOGLE_ORPHAN_OWNER), )) # ── VPN application access (added 2026-05-06 audit) ──── # Approved corporate VPN entitlements (Feilian Full-Mode etc.) # MUST be revoked on offboarding — without this scan a leaver # keeps overseas-resource access from corporate VPN past their # leave date. Default policy is auto-revoke (the admin path # already exists at ``/vpn-applications/{id}/revoke``). from app.domains.offboarding.models import RES_VPN_APPLICATION from app.domains.vpn_application.models import ( VPN_APP_STATUS_APPROVED, VpnApplication, ) vpn_rows = (await self.db.execute( select(VpnApplication).where( VpnApplication.person_id == str(person_id), VpnApplication.status == VPN_APP_STATUS_APPROVED, ) )).scalars().all() for vpn in vpn_rows: key = (RES_VPN_APPLICATION, UUID(vpn.id)) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_VPN_APPLICATION, resource_id=UUID(vpn.id), label=f"VPN 访问: {vpn.vpn_node}", severity=severity_for_resource(RES_VPN_APPLICATION), )) # ── AI tool applications (added 2026-05-06 audit) ────── # Approved AI tool seats (ChatGPT / Bailian) leak budget + # seat allocation when a leaver retains them. Same shape as # VPN — auto-revoke is the right default. from app.domains.ai_tool_application.models import ( AI_TOOL_STATUS_APPROVED, AiToolApplication, ) from app.domains.offboarding.models import RES_AI_TOOL_APPLICATION ai_rows = (await self.db.execute( select(AiToolApplication).where( AiToolApplication.person_id == str(person_id), AiToolApplication.status == AI_TOOL_STATUS_APPROVED, ) )).scalars().all() for app_row in ai_rows: key = (RES_AI_TOOL_APPLICATION, UUID(app_row.id)) scanned_keys.add(key) if key in existing_keys: continue self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_AI_TOOL_APPLICATION, resource_id=UUID(app_row.id), label=f"AI 工具席位: {app_row.ai_name}", severity=severity_for_resource(RES_AI_TOOL_APPLICATION), )) # ── DAX personal dataset grants (migration 606) ───────────── # A leaver's personal BigQuery read grants are people-bound access # and are revoked at the source on the first T+0 tick (same family # as VPN / AI tool / cloud grant). One blocker per binding so the # console can show which dataset was reclaimed and which failed. from app.domains.data_access.lifecycle import ( # noqa: PLC0415 select_personal_active_bindings, ) from app.domains.data_access.models import DataAccessResource # noqa: PLC0415 from app.domains.offboarding.models import RES_DATA_ACCESS_GRANT # noqa: PLC0415 dax_bindings = await select_personal_active_bindings(self.db, person_id) if dax_bindings: dax_resources = { r.id: r for r in (await self.db.execute( select(DataAccessResource).where( DataAccessResource.id.in_([b.resource_id for b in dax_bindings]), ) )).scalars().all() } for binding in dax_bindings: key = (RES_DATA_ACCESS_GRANT, binding.id) scanned_keys.add(key) if key in existing_keys: continue res = dax_resources.get(binding.resource_id) locator = ".".join(filter(None, ( res.project_id if res else None, res.dataset_id if res else None, res.table_id if res else None, ))) or str(binding.resource_id) self.db.add(OffboardingBlocker( process_id=proc.id, resource_type=RES_DATA_ACCESS_GRANT, resource_id=binding.id, label=f"数据集访问: {locator}", severity=severity_for_resource(RES_DATA_ACCESS_GRANT), )) await self.db.flush() # ── Re-opened-process recovery (pre-launch fix 2026-06-09) ────── # The reopen path (``start_offboarding`` on a CANCELLED/COMPLETED # process) re-pends blockers that were resolved by SSO-cascade / # identity-sync (RESO_SSO_CASCADE / RESO_IDENTITY_SYNC_MANAGED → # RESO_PENDING) so the policy is re-evaluated against current # state. But those blockers already exist, so the per-resource # scan loops above SKIP them (``key in existing_keys``) and they # never enter seat_blockers/perm_blockers/account_blockers — so the # appliers below never touch them and the process would stay pinned # at PROC_BLOCKED forever (no task, no resolution path). Re-feed # such task-less, still-present pending software blockers into the # appliers so the reopen actually re-evaluates them. # # The ``provisioning_task_id is None`` guard means we never re-feed # a blocker that already has an in-flight revoke task (the account # applier has no dedup of its own; the seat applier's # find_in_flight dedup backstops the seat path regardless). already_fed = { id(b) for b, _ in (seat_blockers + perm_blockers + account_blockers) } for (res_type, res_id), blocker in existing_keys.items(): if blocker.resolution != RESO_PENDING: continue if (res_type, res_id) not in scanned_keys: continue # stale resource — handled by the sweep below if blocker.provisioning_task_id is not None: continue # already has a task; re-applying would duplicate if id(blocker) in already_fed: continue if res_type == RES_SOFTWARE_SEAT and blocker.software_id is not None: seat_blockers.append((blocker, blocker.software_id)) elif ( res_type == RES_SOFTWARE_PERMISSION and blocker.software_id is not None ): perm_blockers.append((blocker, blocker.software_id)) elif res_type == RES_SOFTWARE_ACCOUNT: account_blockers.append((blocker, res_id)) # ── Apply per-connector offboarding policy to freshly-scanned # software blockers. Hardware / API key / domain / cloud grants # always resolve via MUST_TRANSFER (scope owners) or auto-revoke # (access grants) — no per-row policy. ── await self._apply_software_policy( proc, seat_blockers + perm_blockers, trace_id=trace_id, actor_user_id=actor_user_id, ) # Jira full-access revoke (remove from all groups) always auto- # revokes via a target_type='account' provisioning task. await self._apply_account_policy( proc, account_blockers, trace_id=trace_id, actor_user_id=actor_user_id, ) # Stale-blocker sweep (pre-launch P1 fix). Any existing PENDING # blocker whose underlying resource was NOT seen by the fresh # scan must have left the leaver's possession (asset disposed, # seat revoked, scope decommissioned, group transferred...) # between the prior scan and now. Mark such rows # ``RESO_SKIPPED`` with a stale-after-reopen note so the process # can complete instead of being permanently blocked on a # blocker for a resource that no longer exists / no longer # belongs to the leaver. stale_handover_blockers: list[OffboardingBlocker] = [] for (res_type, res_id), blocker in existing_keys.items(): if blocker.resolution != RESO_PENDING: continue if (res_type, res_id) in scanned_keys: continue blocker.resolution = RESO_SKIPPED blocker.notes = ( (blocker.notes or "") + "\n[stale_after_rescan] resource " "no longer present on leaver's scan; auto-skipped." ).strip() blocker.updated_at = datetime.now(UTC) # 2026-08-13: skipping is right for a resource that genuinely # left the leaver's possession — but a seat whose revoke was # going to MOVE DATA is a different animal. hice@pingcap.com's # Google Workspace seat vanished mid-flight, this branch # silently skipped the blocker, and the Drive/Calendar # transfer that blocker existed to perform simply never # happened. The terminal-delete gate is the backstop, but IT # should hear about it at the moment the plan changes, not # only when a delete gets refused days later. if await self._blocker_carries_data_handover(blocker): stale_handover_blockers.append(blocker) # Cancel the blocker's own revoke task alongside it # (2026-08-11). Skipping the blocker while leaving its task # ``pending`` orphans the task: its target row is gone, so # nothing will ever resolve it, it never links back to a # live blocker, and it sits in ``/admin/software/ # cancellations`` 「待人工」 forever. When the resource later # re-appears (a seat reconciler re-importing the row under a # NEW id) the fresh scan enqueues a SECOND task — which is # exactly how the P0779 / P01361 duplicates were produced. # ``in_progress`` is left alone: the dispatcher owns that row # right now and will land a terminal status itself. await self._cancel_orphaned_blocker_task( blocker, trace_id=trace_id, actor_user_id=actor_user_id, ) if stale_handover_blockers: await self._notify_stale_handover_blockers( proc, stale_handover_blockers, trace_id=trace_id, ) proc.scanned_at = datetime.now(UTC) await self._recompute_status(proc) # ── Data-handover-bearing blockers (2026-08-13) ────────────────── async def _blocker_carries_data_handover(self, blocker) -> bool: """True when this blocker's revoke was going to MOVE VENDOR DATA, not merely remove access. Today that means a software seat whose connector binding performs a handover as part of the revoke: * Google Workspace ``operation=user_suspend`` — transfers Drive + Calendar ownership to the successor, then suspends; * Zoom ``action_on_offboard=delete`` — transfers meetings / webinars / cloud recordings to the successor, then deletes. A plain ``group_member`` removal or a Zoom ``disassociate`` moves nothing, so skipping those is genuinely uneventful. A savepoint-local classification error returns ``False`` so it cannot break a rescan; transaction-aborting database errors propagate to the scheduled-job retry boundary. """ from app.domains.offboarding.models import ( # noqa: PLC0415 RES_SOFTWARE_SEAT, ) if blocker.resource_type != RES_SOFTWARE_SEAT: return False if blocker.software_id is None: return False async with best_effort( self.db, label=f"classify data handover for blocker {blocker.id}", log=logger, ) as _outcome: from app.domains.provisioning.models import ( # noqa: PLC0415 SoftwareConnector, ) connectors = ( await self.db.execute( select(SoftwareConnector).where( SoftwareConnector.software_id == blocker.software_id, SoftwareConnector.is_active.is_(True), ) ) ).scalars().all() for conn in connectors: cfg = conn.binding_config or {} if str(cfg.get("operation") or "") == "user_suspend": return True if str(cfg.get("action_on_offboard") or "").lower() == "delete": return True return False async def _notify_stale_handover_blockers( self, proc, blockers: list, *, trace_id: str, ) -> None: """Page IT when a rescan drops a blocker that was going to move data. Deduped per process (kind ``stale_handover``): a rescan runs on every process advance, and the same skipped blocker must not produce a fresh alert each time. """ from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) from app.notifications import deliver_enqueued # noqa: PLC0415 # Enqueue inside the savepoint, push after it — ``deliver()`` # commits and cannot run inside ``begin_nested`` (see # ``deliver_enqueued``). queued: list[tuple[Any, Any]] = [] async with best_effort( self.db, label="notify stale data-handover blockers", log=logger, ) as outcome: already = ( await self.db.execute( select(OffboardingReminderLog.id).where( OffboardingReminderLog.process_id == proc.id, OffboardingReminderLog.reminder_kind == "stale_handover", ) ) ).scalars().first() if already is not None: return person = await self.db.get(IdentityPerson, proc.person_id) if person is None: return await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.data_handover_blocker_went_stale", status="failed", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "work_email": person.work_email, "labels": [b.label for b in blockers][:10], }, ) admins = await self._active_admin_recipients() if not admins: return from app.notifications import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) # Seat labels only — vendor product names, language-neutral. listed = "\n".join(f" · {b.label}" for b in blockers[:10]) notify = NotificationService(self.db) for admin in admins: env = NotificationEnvelope( recipient=admin, title=( f"[离职交接] {person.display_name or person.work_email} " "的数据交接坐席在重扫时消失" ), body_text="(see template)", channels=["feishu", "email"], template_key="offboarding_stale_handover_blocker", context={ "leaver_name": ( person.display_name or person.work_email or "" ), "leaver_email": person.work_email or "", "blocker_list": listed, }, url=f"/admin/offboarding/process/{proc.id}", related_resource_type="offboarding_process", related_resource_id=proc.id, ) queued.append((await notify.enqueue(env), env)) self.db.add( OffboardingReminderLog( id=uuid4(), process_id=proc.id, reminder_kind="stale_handover", channels=["feishu", "email"], detail={"labels": [b.label for b in blockers][:10]}, ) ) await self.db.flush() if outcome.error is None: await deliver_enqueued( self.db, queued, trace_id=trace_id, log=logger, ) async def _cancel_orphaned_blocker_task( self, blocker: OffboardingBlocker, *, trace_id: str, actor_user_id: UUID | None, ) -> None: """Cancel the still-queued provisioning task attached to a blocker that the stale sweep just skipped. No-op when the blocker has no task, or the task already reached a terminal state, or the dispatcher is mid-flight on it (``in_progress``). Idempotent: a second rescan finds the task already ``cancelled`` and returns. """ if blocker.provisioning_task_id is None: return task = await self.db.get(ProvisioningTask, blocker.provisioning_task_id) if task is None: return if task.status not in (TASK_PENDING, TASK_NOTIFIED, TASK_FAILED): return prior_status = task.status task.status = TASK_CANCELLED task.completed_at = datetime.now(UTC) task.error_message = ( "Cancelled by offboarding rescan: the target resource is no " f"longer present on the leaver's scan (blocker {blocker.id} " "auto-skipped as stale). A fresh task is enqueued if the " "resource re-appears." ) await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.blocker_stale_task_cancelled", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "task_id": str(task.id), "prior_task_status": prior_status, "resource_type": blocker.resource_type, "resource_id": ( str(blocker.resource_id) if blocker.resource_id else None ), }, ) logger.info( "offboarding: cancelled orphaned %s task %s for stale blocker " "%s (process=%s)", prior_status, task.id, blocker.id, blocker.process_id, ) async def _scan_email_groups(self, person_id: UUID) -> list: """Return active EmailGroup rows owned by the person. Imported lazily so the offboarding domain doesn't pull the email_group domain at module import time (keeps the "domains-don't-import-each-other" rule — offboarding is the acknowledged coordination-class domain that's allowed to reach into resource domains for this purpose). """ from app.domains.email_group.service import EmailGroupService svc = EmailGroupService(self.db) return await svc.list_groups_owned_by(person_id) async def _apply_software_policy( self, proc: OffboardingProcess, blockers: list[tuple[OffboardingBlocker, UUID]], *, trace_id: str, actor_user_id: UUID | None, ) -> None: """For each freshly-scanned software blocker, look at the software's ``offboarding_policy`` (on ``asset_software_catalog``) and act. * ``auto_revoke`` — create a provisioning task immediately. The blocker keeps ``resolution='pending'`` until the task runs and ``reconcile_blockers`` flips it to ``revoked_auto``. Requires a configured connector; if missing, blocker stays pending and admin gets a visible warning in the offboarding UI. * ``sso_cascade`` — mark the blocker ``RESO_SSO_CASCADE`` right now. The resource dies when Google/Feishu account is suspended; no connector needed. * ``identity_sync_managed`` — the catalog row IS an identity (a Feishu / Google account). Lifecycle is owned by an ``identity_sync`` target (HRIS → vendor push) that runs ``deactivate`` on the next out-of-scope sweep AND on the per-person event hook fired from ``hr.process_advance``. Mark the blocker resolved with an audit row pointing at identity_sync; creating a seat-layer provisioning task here would 409 against the same vendor user from two code paths. """ if not blockers: return # Policy lives on the software row. Connector (if any) is the # executor the auto_revoke branch calls into — fetched only when # needed. software_ids = [sw_id for _, sw_id in blockers] sw_rows = ( await self.db.execute( select(AssetSoftwareCatalog).where( AssetSoftwareCatalog.id.in_(software_ids) ) ) ).scalars().all() policy_by_software: dict[UUID, str] = { sw.id: sw.offboarding_policy for sw in sw_rows } # ``not_before`` defers task execution until the leaver's last # working day has ended in their local TZ. The dispatcher will # skip the row before then; manual admin retry is also gated in # ``execute_task``. Pre-launch incident 2026-05-28 (Thalie # Zhuang): a leaver_date 30 days in the future triggered Gmail # / Atlassian revoke within 30s of HRIS sync. The # ``_apply_account_policy`` mirror is below. leaver_person = await self.db.get(IdentityPerson, proc.person_id) not_before = _leaver_revoke_not_before( leaver_person, proc.leaver_date, ) for blocker, sw_id in blockers: policy = policy_by_software.get(sw_id, POLICY_SSO_CASCADE) if policy == POLICY_SSO_CASCADE: blocker.resolution = RESO_SSO_CASCADE blocker.notes = "SSO 级联:Google/飞书 账号停用后自动失效" await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.blocker_sso_cascade", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "software_id": str(sw_id), "resource_type": blocker.resource_type, }, ) elif policy == POLICY_IDENTITY_SYNC_MANAGED: # The catalog row is an identity (e.g. 飞书 account) # whose create/freeze is driven by an identity_sync # target — feishu-contact for 飞书, future google-contact # for Google Workspace user accounts. Don't enqueue a # provisioning task: process_advance fires the offboard # event hook, which fan-outs to every enabled # identity_sync target and runs the runner's # ``deactivate`` (PATCH freeze on Feishu, idempotent # against already-frozen accounts). Resolving the # blocker here keeps the offboarding gate from # double-asking for an action the platform is already # taking on a separate path. blocker.resolution = RESO_IDENTITY_SYNC_MANAGED blocker.notes = ( "由身份源同步任务关停 (HRIS → 飞书/Google);" " 不在 seat 层创建 task,避免与 identity_sync 重复" "调用同一个 vendor API。" ) await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.blocker_identity_sync_managed", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "software_id": str(sw_id), "resource_type": blocker.resource_type, }, ) elif policy == POLICY_AUTO_REVOKE: target_type = ( "seat" if blocker.resource_type == RES_SOFTWARE_SEAT else "permission" ) context_data: dict[str, Any] = { "offboarding_process_id": str(proc.id), "offboarding_blocker_id": str(blocker.id), "policy": POLICY_AUTO_REVOKE, } # Seat revokes where the driver needs data handoff # (Zoom delete / Google user_suspend) must carry a # successor. Try the leaver's manager first; if they # aren't a valid candidate (inactive, or no seat on # this software) leave the blocker pending — admin # picks someone in the offboarding UI. successor_id: UUID | None = None if blocker.resource_type == RES_SOFTWARE_SEAT: ( needs_successor, successor_id, successor_email, reason, ) = await self._auto_successor_for_seat( person_id=proc.person_id, software_id=sw_id, ) if needs_successor and successor_id is None: blocker.notes = ( "连接器要求交接人(Zoom/Google 数据转移)——" f"未能自动确定:{reason}。请在离职面板手动指定。" ) await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.blocker_successor_required", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "software_id": str(sw_id), "reason": reason, }, ) continue if successor_id is not None: context_data["successor_user_id"] = str(successor_id) if successor_email: context_data["successor_email"] = successor_email # Dedup gate: a rescan after a partial rerun, a # replayed HRIS confirm batch, or any future cross- # path enqueuer can otherwise produce a second # ProvisioningTask for the same seat. ``create_task`` # has no built-in dedup (unlike the coordinator's # ``enqueue_revoke_for_seat``) — close the gap here # by reusing the SAME helper the coordinator uses. # If a matching task already exists, link this # blocker to it instead of creating a duplicate. if ( blocker.resource_type == RES_SOFTWARE_SEAT and blocker.resource_id is not None and sw_id is not None ): from app.core.coordinators.provisioning_coordinator import ( find_in_flight_seat_revoke_task, ) existing = await find_in_flight_seat_revoke_task( self.db, user_id=proc.person_id, software_id=sw_id, seat_id=blocker.resource_id, ) if existing is not None: blocker.provisioning_task_id = existing.id await self.audit.log( trace_id=trace_id, actor_type=( "system" if not actor_user_id else "user" ), actor_user_id=actor_user_id, action=( "offboarding.blocker_auto_revoke_dedup_linked" ), status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "existing_task_id": str(existing.id), "existing_task_status": existing.status, "software_id": str(sw_id), "seat_id": str(blocker.resource_id), "note": ( "Linked blocker to pre-existing " "in-flight revoke task instead of " "creating duplicate." ), }, ) continue task = await self.provisioning.create_task(ProvisioningTaskCreate( user_id=proc.person_id, software_id=sw_id, action=ACTION_REVOKE, target_type=target_type, target_id=blocker.resource_id, source=SOURCE_OFFBOARDING, context_data=context_data, not_before=not_before, )) blocker.provisioning_task_id = task.id await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.blocker_auto_revoke_scheduled", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "task_id": str(task.id), "successor_user_id": str(successor_id) if successor_id else None, "not_before": not_before.isoformat(), }, ) # else: no connector or unknown policy — leave pending; admin # will act via the offboarding UI. await self.db.flush() async def _apply_account_policy( self, proc: OffboardingProcess, account_blockers: list[tuple[OffboardingBlocker, UUID]], *, trace_id: str, actor_user_id: UUID | None, ) -> None: """Schedule full-access-revoke tasks (Jira). Each account blocker becomes a ``target_type='account'`` provisioning task whose ``target_id`` is the provider integration id. The task's driver call (``revoke_all_access``) removes the leaver from EVERY group they belong to once (the account itself is preserved), is idempotent, and resolves to a no-op success if the leaver has no account on that provider — so a blocker created off the integration's mere existence never wrongly blocks a leaver who never used Jira. """ if not account_blockers: return # Defer-execution gate. Without this, every Jira / # Atlassian-product full-access-revoke fires within 30s of HRIS # sync regardless of how far the leaver_date is. Mirrors # ``_apply_software_policy`` above. Pre-launch incident # 2026-05-28 (Thalie Zhuang). leaver_person = await self.db.get(IdentityPerson, proc.person_id) not_before = _leaver_revoke_not_before( leaver_person, proc.leaver_date, ) for blocker, integration_id in account_blockers: context_data: dict[str, Any] = { "offboarding_process_id": str(proc.id), "offboarding_blocker_id": str(blocker.id), "policy": POLICY_AUTO_REVOKE, "revoke_reason": ( f"Offboarded via Helix (process {proc.id})" ), } task = await self.provisioning.create_task(ProvisioningTaskCreate( user_id=proc.person_id, software_id=None, action=ACTION_REVOKE, target_type="account", target_id=integration_id, source=SOURCE_OFFBOARDING, context_data=context_data, not_before=not_before, )) blocker.provisioning_task_id = task.id await self.audit.log( trace_id=trace_id, actor_type="system" if not actor_user_id else "user", actor_user_id=actor_user_id, action="offboarding.blocker_full_access_revoke_scheduled", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "task_id": str(task.id), "integration_id": str(integration_id), "not_before": not_before.isoformat(), }, ) await self.db.flush() async def _recompute_status(self, proc: OffboardingProcess) -> None: pending = ( await self.db.execute( select(func.count()).select_from(OffboardingBlocker).where( and_( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.resolution == RESO_PENDING, ) ) ) ).scalar() or 0 if proc.status in {PROC_CANCELLED, PROC_COMPLETED}: return if pending == 0: if proc.status != PROC_READY: proc.status = PROC_READY proc.ready_at = datetime.now(UTC) else: proc.status = PROC_BLOCKED # ───────────────────────── Blocker resolution ───────────────────────── async def _auto_successor_for_seat( self, *, person_id: UUID, software_id: UUID | None, ) -> tuple[bool, UUID | None, str | None, str]: """Resolve a candidate successor for a seat revoke during the scan. Returns ``(needs_successor, user_id, email, reason)``. ``needs_successor`` tells the caller whether the driver cares; when it's False the remaining values are irrelevant. When True and ``user_id`` is None, the ``reason`` explains why auto-pick fell through (no manager / inactive manager / manager has no seat) so the UI can show it. """ from app.core.coordinators.provisioning_coordinator import ( resolve_revoke_requires_successor, ) if software_id is None: return False, None, None, "" needs_successor, _provider = await resolve_revoke_requires_successor( self.db, software_id=software_id, ) if not needs_successor: return False, None, None, "" target = await self.db.get(IdentityPerson, person_id) if target is None or target.manager_person_id is None: return True, None, None, "员工无主管记录" manager = await self.db.get(IdentityPerson, target.manager_person_id) # ``deleted_at`` guard: a soft-deleted person keeps a possibly-stale # ``employment_status='active'`` (reference_soft_deleted_person_stale_ # employment_status), so the status check alone can auto-select a # deleted manager as the seat-handover recipient. Match the explicit # deleted_at guard the sibling ``resolve_blocker_by_handover_to_ # successor`` path already applies. if ( manager is None or manager.deleted_at is not None or manager.employment_status != "active" ): return True, None, None, "主管已离职或停用" # Manager must also hold an active seat on the software so # the vendor-side transfer lands on an account that exists. manager_seat = await self.db.scalar( select(SoftwareSeatAllocation) .join(SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id) .where( SoftwareSeatAllocation.user_id == manager.id, SoftwareLicensePlan.software_id == software_id, SoftwareSeatAllocation.status == "active", ), ) if manager_seat is None: return True, None, None, "主管在该软件上无生效坐席" return True, manager.id, manager.work_email, "" async def _validate_successor_for_seat( self, *, person_id: UUID, software_id: UUID, successor_user_id: UUID, ) -> tuple[UUID, str]: """Validate an admin-picked successor for a seat revoke. Raises ``AppException`` on any failure. Returns ``(successor_user_id, successor_email)``. """ if successor_user_id == person_id: raise AppException( code="SUCCESSOR_INVALID", message="Successor must be a different person from the leaver.", status_code=400, ) successor = await self.db.get(IdentityPerson, successor_user_id) # ``deleted_at`` guard: a soft-deleted person keeps a possibly-stale # ``employment_status='active'``, so treat a soft-deleted row as # not-found here — a deleted person must never be written into a seat # revoke's ``successor_user_id`` as the data-handoff recipient. Mirrors # ``resolve_blocker_by_handover_to_successor``'s explicit guard. if successor is None or successor.deleted_at is not None: raise AppException( code="SUCCESSOR_NOT_FOUND", message="Successor user not found.", status_code=400, ) if successor.employment_status != "active": raise AppException( code="SUCCESSOR_INACTIVE", message="Successor is not an active employee.", status_code=400, ) successor_seat = await self.db.scalar( select(SoftwareSeatAllocation) .join(SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id) .where( SoftwareSeatAllocation.user_id == successor_user_id, SoftwareLicensePlan.software_id == software_id, SoftwareSeatAllocation.status == "active", ), ) if successor_seat is None: raise AppException( code="SUCCESSOR_NO_SEAT", message=( "Successor must hold an active seat on the same " "software before data can be transferred to them." ), status_code=400, ) return successor.id, successor.work_email async def get_blocker_revoke_preview( self, blocker_id: UUID, ) -> dict[str, Any]: """Preview payload for the revoke Modal — tells the UI whether a successor picker must be shown and who the default would be.""" from app.core.coordinators.provisioning_coordinator import ( resolve_revoke_requires_successor, ) blocker = await self._get_blocker(blocker_id) payload: dict[str, Any] = { "requires_successor": False, "provider": None, "suggested_successor_user_id": None, "suggested_successor_display_name": None, "reason": None, } if blocker.resource_type != RES_SOFTWARE_SEAT or blocker.software_id is None: return payload proc = await self._get_process(blocker.process_id) needs_successor, provider = await resolve_revoke_requires_successor( self.db, software_id=blocker.software_id, ) payload["requires_successor"] = needs_successor payload["provider"] = provider if not needs_successor: return payload (_needs, suggested_id, _email, reason) = await self._auto_successor_for_seat( person_id=proc.person_id, software_id=blocker.software_id, ) payload["reason"] = reason or None if suggested_id is not None: suggested = await self.db.get(IdentityPerson, suggested_id) if suggested is not None: payload["suggested_successor_user_id"] = str(suggested.id) payload["suggested_successor_display_name"] = suggested.display_name return payload async def resolve_blocker_by_handover_to_successor( self, blocker_id: UUID, *, successor_person_id: UUID, trace_id: str, actor_user_id: UUID, actor: Any, ) -> OffboardingBlocker: """Hand a generic-flow resource (SSO grant / domain registration) from the leaver to a named successor and mark the blocker ``transferred``. Reuses each domain's ``handover()`` so the same audit log + person rewrite shows up regardless of whether the call came from the requester's self-service path or this offboarding path. """ from app.domains.offboarding.models import ( RES_AI_TOOL_APPLICATION, RES_APPROVAL_FLOW_OWNER, RES_DOMAIN_REGISTRATION, RES_GOOGLE_ORPHAN_OWNER, RES_IDENTITY_CONSUMER_OWNER, RES_SSO_GRANT, RES_SYSTEM_OWNER, ) # Pre-launch P1 fix (Round 45, CR-405): lock so concurrent # resolve clicks serialize. blocker = await self._get_blocker(blocker_id, lock=True) if blocker.resolution != RESO_PENDING: raise AppException( code="BLOCKER_RESOLVED", message=f"Blocker already resolved ({blocker.resolution})", status_code=400, ) # ai_tool added 2026-06-13 (lifecycle-verb parity): a paid named # AI-tool seat can be handed to the successor instead of revoked # (auto-resolver default stays revoke — this is the admin's # opt-in transfer choice, same as domain_registration / sso). supported_types = { RES_SSO_GRANT, RES_DOMAIN_REGISTRATION, RES_GOOGLE_ORPHAN_OWNER, RES_AI_TOOL_APPLICATION, RES_IDENTITY_CONSUMER_OWNER, RES_SYSTEM_OWNER, RES_APPROVAL_FLOW_OWNER, } if blocker.resource_type not in supported_types: raise AppException( code="UNSUPPORTED_RESOURCE_TYPE", message=( f"Cannot handover {blocker.resource_type!r} via this endpoint." ), status_code=400, ) # Pre-launch P1 fix: validate the successor is an active # employee BEFORE delegating to per-domain handover. The # per-domain ``handover()`` methods only check # "successor exists and != current holder"; without this # gate an admin could hand SSO / domain reg / orphan owner to # a leaver_pending or left person, which would instantly # resurface as a blocker on the next offboarding scan. Mirrors # ``_validate_successor_for_seat`` ([service.py:1213]). successor = await self.db.get(IdentityPerson, successor_person_id) if successor is None or successor.deleted_at is not None: raise AppException( code="SUCCESSOR_NOT_FOUND", message=f"Successor person {successor_person_id} not found", status_code=400, ) if successor.employment_status != "active": raise AppException( code="SUCCESSOR_NOT_ACTIVE", message=( f"Successor {successor.work_email} is " f"{successor.employment_status!r} — pick an active " "employee." ), status_code=409, ) if blocker.resource_type == RES_SSO_GRANT: from app.domains.sso.service import SsoGrantService sso_svc = SsoGrantService(self.db) grant = await sso_svc.handover( grant_id=str(blocker.resource_id), successor_person_id=str(successor_person_id), actor=actor, trace_id=trace_id, ) blocker.resolution_ref = str(grant.id) elif blocker.resource_type == RES_DOMAIN_REGISTRATION: from app.domains.domain_registration.service import ( DomainRegistrationService, ) dr_svc = DomainRegistrationService(self.db) reg = await dr_svc.handover( reg_id=str(blocker.resource_id), successor_person_id=str(successor_person_id), actor=actor, trace_id=trace_id, ) blocker.resolution_ref = str(reg.id) elif blocker.resource_type == RES_AI_TOOL_APPLICATION: from app.domains.ai_tool_application.service import ( AiToolApplicationService, ) ai_svc = AiToolApplicationService(self.db) app_row = await ai_svc.handover( app_id=str(blocker.resource_id), successor_person_id=str(successor_person_id), actor=actor, trace_id=trace_id, ) blocker.resolution_ref = str(app_row.id) elif blocker.resource_type == RES_SYSTEM_OWNER: from app.core.coordinators import ( system_registry_coordinator as _registry, ) # The coordinator moves primary ownership AND clears a backup # slot still pointing at the leaver, in one pass — otherwise # the next scan re-raises the same blocker for the backup # half and the admin has to resolve it twice. proc_for_system = await self._get_process(blocker.process_id) moved = await _registry.handover_systems( self.db, from_person_id=proc_for_system.person_id, to_person_id=successor_person_id, actor_person_id=actor_user_id, note="离职交接", system_ids=[blocker.resource_id], ) if not moved: raise AppException( code="SYSTEM_ALREADY_TRANSFERRED", message=( "This system is no longer owned by the leaver — " "rescan the process." ), status_code=409, ) blocker.resolution_ref = str(blocker.resource_id) elif blocker.resource_type == RES_APPROVAL_FLOW_OWNER: from app.core.coordinators import ( approval_registry_coordinator as _flow_registry, ) proc_for_flow = await self._get_process(blocker.process_id) moved_flows = await _flow_registry.handover_flows( self.db, from_person_id=proc_for_flow.person_id, to_person_id=successor_person_id, actor_person_id=actor_user_id, note="离职交接", flow_ids=[blocker.resource_id], ) if not moved_flows: raise AppException( code="APPROVAL_FLOW_ALREADY_TRANSFERRED", message=( "This approval flow is no longer owned by the " "leaver — rescan the process." ), status_code=409, ) blocker.resolution_ref = str(blocker.resource_id) elif blocker.resource_type == RES_IDENTITY_CONSUMER_OWNER: from app.identity.consumers.ownership import transfer_consumer_owner consumer = await transfer_consumer_owner( self.db, consumer_id=blocker.resource_id, successor_person_id=successor_person_id, trace_id=trace_id, actor_user_id=actor_user_id, ) blocker.resolution_ref = str(consumer.id) else: # RES_GOOGLE_ORPHAN_OWNER from app.domains.google_workspace.orphan_service import ( OrphanAccountService, ) orphan_svc = OrphanAccountService(self.db) await orphan_svc.assign_owner( blocker.resource_id, successor_person_id, purpose=None, trace_id=trace_id, actor_user_id=actor_user_id, ) blocker.resolution_ref = str(blocker.resource_id) blocker.resolution = RESO_TRANSFERRED blocker.updated_at = datetime.now(UTC) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.blocker.handover", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "blocker_resource_type": blocker.resource_type, "blocker_resource_id": str(blocker.resource_id), "successor_person_id": str(successor_person_id), }, ) proc = await self._get_process(blocker.process_id, lock=True) await self._recompute_status(proc) return blocker async def resolve_blocker_by_revoke( self, blocker_id: UUID, *, notes: str | None, trace_id: str, actor_user_id: UUID, successor_user_id: UUID | None = None, ) -> OffboardingBlocker: """Create a revoke provisioning task for this blocker (admin-initiated). Task is created in 'pending'; admin dispatches it from provisioning page. Once the task completes, a later call to reconcile or the admin page will flip the blocker to 'revoked_auto'. For software seats on drivers that need data handoff (Zoom/Google), a ``successor_user_id`` must be supplied (or defaulted to the leaver's manager) and must satisfy the same validation as the manual ``/assets/seat-allocations/{id}/revoke`` endpoint. """ # Pre-launch P1 fix (Round 45, CR-405): lock so concurrent # resolve clicks serialize. blocker = await self._get_blocker(blocker_id, lock=True) if blocker.resolution != RESO_PENDING: raise AppException( code="BLOCKER_RESOLVED", message=f"Blocker already resolved ({blocker.resolution})", status_code=400, ) proc = await self._get_process(blocker.process_id, lock=True) # ── VPN / AI-tool: revoke directly at the source domain ── # These two blocker types are per-person access records, not # provisioning targets — they don't go through the # ProvisioningTask pipeline. Pre-launch P0 fix: previously # they 400'd here as ``UNSUPPORTED_RESOURCE_TYPE`` because the # target_type_map didn't include them, leaving IT Admin with # no path to revoke from the offboarding console. from app.domains.offboarding.models import ( RES_AI_TOOL_APPLICATION as _RES_AI_TOOL_APPLICATION, ) from app.domains.offboarding.models import ( RES_VPN_APPLICATION as _RES_VPN_APPLICATION, ) if blocker.resource_type in ( _RES_VPN_APPLICATION, _RES_AI_TOOL_APPLICATION, ): actor_person = await self.db.get(IdentityPerson, actor_user_id) if actor_person is None: raise AppException( code="ACTOR_NOT_FOUND", message="Acting admin not found", status_code=400, ) if blocker.resource_type == _RES_VPN_APPLICATION: from app.domains.vpn_application.service import ( # noqa: PLC0415 VpnApplicationService, ) await VpnApplicationService(self.db).revoke( app_id=str(blocker.resource_id), actor=actor_person, reason=notes or "offboarding", trace_id=trace_id, ) ref_prefix = "offboarding_revoke:vpn_application" else: from app.domains.ai_tool_application.service import ( # noqa: PLC0415 AiToolApplicationService, ) await AiToolApplicationService(self.db).revoke( app_id=str(blocker.resource_id), actor=actor_person, reason=notes or "offboarding", trace_id=trace_id, ) ref_prefix = "offboarding_revoke:ai_tool_application" blocker.resolution = RESO_REVOKED_AUTO blocker.resolution_ref = f"{ref_prefix}:{blocker.resource_id}" blocker.notes = notes blocker.updated_at = datetime.now(UTC) await self._recompute_status(proc) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.blocker.revoke_admin", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "blocker_resource_type": blocker.resource_type, "blocker_resource_id": str(blocker.resource_id), }, ) return blocker # ── Cloud access grant: revoke directly at the cmdb domain ── # Cloud grants are scope-bound access records, not provisioning # targets — the provisioning pipeline has no driver wiring for # them. Without this branch, an admin clicking "revoke" on a # ``cloud_access_grant`` blocker hit ``UNSUPPORTED_RESOURCE_TYPE`` # below; the only working revoke path was the once-daily T+0 # auto-cascade in ``hr_lifecycle.advance_service``. Historical # leavers whose process advanced before that auto-cascade existed # (or whose force-transfer hit the Round-21 RBAC bug) had no UI # path forward and surfaced indefinitely as T+1 LEAVER_ORPHAN # incidents the admin could not action. # # Symmetric with the VPN / AI-tool branch above. Pass # ``actor_role_names=[GLOBAL_ADMIN_ROLE]`` so the lifecycle # service's defence-in-depth RBAC accepts the admin-initiated # revoke of a leaver's grant (the admin is neither the grant # holder nor necessarily the scope owner). ``suppress_driver_ # error=False`` — admin-initiated, fail loud so the UI surfaces # provider-side errors instead of silently flipping the row # while the cloud-side role assignment lingers. from app.domains.offboarding.models import ( # noqa: PLC0415 RES_CLOUD_ACCESS_GRANT as _RES_CLOUD_ACCESS_GRANT, ) if blocker.resource_type == _RES_CLOUD_ACCESS_GRANT: from app.core.rbac import GLOBAL_ADMIN_ROLE # noqa: PLC0415 from app.domains.cmdb.lifecycle_service import ( # noqa: PLC0415 CloudLifecycleService, ) from app.domains.cmdb.models import ( # noqa: PLC0415 GRANT_REVOKE_REASON_OFFBOARDING, ) await CloudLifecycleService(self.db).revoke_grant( str(blocker.resource_id), reason=GRANT_REVOKE_REASON_OFFBOARDING, actor_user_id=actor_user_id, trace_id=trace_id, actor_role_names=[GLOBAL_ADMIN_ROLE], suppress_driver_error=False, ) blocker.resolution = RESO_REVOKED_AUTO blocker.resolution_ref = ( f"offboarding_revoke:cloud_access_grant:{blocker.resource_id}" ) blocker.notes = notes blocker.updated_at = datetime.now(UTC) await self._recompute_status(proc) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.blocker.revoke_admin", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "blocker_resource_type": blocker.resource_type, "blocker_resource_id": str(blocker.resource_id), }, ) return blocker # ── Mail-group membership: remove at the source (PR3) ── from app.domains.offboarding.models import ( # noqa: PLC0415 RES_EMAIL_GROUP_MEMBER as _RES_EG_MEMBER, ) if blocker.resource_type == _RES_EG_MEMBER: from app.domains.email_group.membership_scan import ( # noqa: PLC0415 remove_person_from_group, ) from app.domains.email_group.models import ( # noqa: PLC0415 EmailGroup as _EmailGroup, ) group = await self.db.get(_EmailGroup, blocker.resource_id) leaver = await self.db.get(IdentityPerson, proc.person_id) if group is None or leaver is None: raise AppException( code="EMAIL_GROUP_MEMBER_NOT_FOUND", message="The mail group or the leaver no longer exists.", status_code=404, ) await remove_person_from_group( self.db, group=group, person=leaver, trace_id=trace_id, actor_user_id=actor_user_id, reason=notes or "offboarding", ) blocker.resolution = RESO_REVOKED_AUTO blocker.resolution_ref = ( f"offboarding_revoke:email_group_member:{blocker.resource_id}" ) blocker.notes = notes blocker.updated_at = datetime.now(UTC) await self._recompute_status(proc) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.blocker.revoke_admin", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "blocker_resource_type": blocker.resource_type, "blocker_resource_id": str(blocker.resource_id), }, ) return blocker # ── DAX personal dataset grant: revoke at the data_access domain ── # (migration 606). System-context revoke — the admin acting on a # leaver's blocker is neither the holder nor the dataset owner. The # semantic flip + absent effect land now; the apply-absent reconciler # performs the BigQuery write on its next tick. from app.domains.offboarding.models import ( # noqa: PLC0415 RES_DATA_ACCESS_GRANT as _RES_DATA_ACCESS_GRANT, ) if blocker.resource_type == _RES_DATA_ACCESS_GRANT: from app.domains.data_access.lifecycle import ( # noqa: PLC0415 DataAccessLifecycleService, ) handled = await DataAccessLifecycleService(self.db).revoke_for_offboarding( blocker.resource_id, leaver_person_id=proc.person_id, actor_person_id=actor_user_id, trace_id=trace_id, ) if not handled: raise AppException( code="DATA_ACCESS_BINDING_NOT_LEAVERS", message="This data-access grant no longer belongs to the leaver.", status_code=409, ) blocker.resolution = RESO_REVOKED_AUTO blocker.resolution_ref = ( f"offboarding_revoke:data_access_grant:{blocker.resource_id}" ) blocker.notes = notes blocker.updated_at = datetime.now(UTC) await self._recompute_status(proc) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.blocker.revoke_admin", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "blocker_resource_type": blocker.resource_type, "blocker_resource_id": str(blocker.resource_id), }, ) return blocker # Map resource_type → provisioning target_type target_type_map = { RES_SOFTWARE_SEAT: "seat", RES_SOFTWARE_PERMISSION: "permission", RES_API_KEY: "api_key", RES_HARDWARE: "hardware", } target_type = target_type_map.get(blocker.resource_type) if not target_type: raise AppException( code="UNSUPPORTED_RESOURCE_TYPE", message=f"Cannot auto-revoke {blocker.resource_type}", status_code=400, ) context_data: dict[str, Any] = { "offboarding_process_id": str(proc.id), "offboarding_blocker_id": str(blocker.id), "notes": notes, } # Seat-specific successor handling. if blocker.resource_type == RES_SOFTWARE_SEAT and blocker.software_id: from app.core.coordinators.provisioning_coordinator import ( resolve_revoke_requires_successor, ) needs_successor, _provider = await resolve_revoke_requires_successor( self.db, software_id=blocker.software_id, ) if needs_successor: # Default to leaver's manager if admin didn't specify. if successor_user_id is None: target = await self.db.get(IdentityPerson, proc.person_id) if target is not None and target.manager_person_id is not None: successor_user_id = target.manager_person_id if successor_user_id is None: raise AppException( code="SUCCESSOR_REQUIRED", message=( "Driver requires a successor for seat revoke " "(Zoom meetings / Google Drive handoff). " "Pick one in the offboarding UI." ), status_code=400, ) picked_id, picked_email = await self._validate_successor_for_seat( person_id=proc.person_id, software_id=blocker.software_id, successor_user_id=successor_user_id, ) context_data["successor_user_id"] = str(picked_id) context_data["successor_email"] = picked_email task = await self.provisioning.create_task(ProvisioningTaskCreate( user_id=proc.person_id, software_id=blocker.software_id, action=ACTION_REVOKE, target_type=target_type, target_id=blocker.resource_id, source=SOURCE_OFFBOARDING, context_data=context_data, )) blocker.provisioning_task_id = task.id blocker.notes = notes # Resolution stays 'pending' until the task completes await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.blocker_revoke_task_created", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "task_id": str(task.id), "successor_user_id": str(successor_user_id) if successor_user_id else None, }, ) return blocker async def mark_blocker_resolved_manual( self, blocker_id: UUID, *, notes: str | None, trace_id: str, actor_user_id: UUID, ) -> OffboardingBlocker: """Admin directly marks blocker as resolved without calling a connector (e.g. leaver returned their laptop physically).""" # Pre-launch P1 fix (Round 45, CR-405): lock the row to # serialise concurrent skip / mark_manual / handover / revoke # clicks. Without the lock, two admins clicking simultaneously # both pass the pending-check, both write resolution/notes, # and downstream side-effects (AssetOperation rows, provisioning # task enqueues) duplicate. blocker = await self._get_blocker(blocker_id, lock=True) if blocker.resolution != RESO_PENDING: raise AppException( code="BLOCKER_RESOLVED", message=f"Blocker already resolved ({blocker.resolution})", status_code=400, ) blocker.resolution = RESO_REVOKED_MANUAL # Round-20 audit (A3F6): gate the notes overwrite. Previous shape # unconditionally assigned ``notes = notes`` even when caller # passed empty/None — clobbering any scan-time notes the auto- # revoke had written (e.g. "Auto-revoke failed: connector # returned 403"). Forensic context lost on retry-after-failure. # Append rather than overwrite so the prior context survives. if notes: if blocker.notes: blocker.notes = f"{blocker.notes}\n---\n{notes}" else: blocker.notes = notes # A HARDWARE blocker resolved manually means "the leaver physically # returned this device" (per the docstring) — so the asset must # actually leave the leaver's hands. Without this the row stays # ``status='assigned', assigned_to=``; after the person # flips to ``left`` every roster/governance view shows an asset # assigned to a departed employee and ``hr.post_leaver_verify`` # raises a compliance incident for residue the admin believed was # resolved. Contrast ``skip_blocker`` (device stays WITH the leaver # → ``held_by_leaver``); a manual RESOLVE is the opposite — the # device came back, so return it to the office (falls back to idle # stock when no office resolves). Best-effort, mirroring # ``skip_blocker``: a missing / already-non-assigned asset is logged # but must NOT fail the resolve. returned = False if blocker.resource_type == RES_HARDWARE: from app.domains.asset.schemas import AssetReturnRequest from app.domains.asset.service import AssetService asset_svc = AssetService(self.db) try: asset, _op = await asset_svc.return_asset( blocker.resource_id, AssetReturnRequest( return_target="office", notes=( f"[offboarding manual-resolve] " f"proc={blocker.process_id} blocker={blocker.id}" ), ), operator_id=actor_user_id, ) returned = True except NotFoundError: logger.warning( "offboarding.mark_blocker_resolved_manual: hardware " "asset %s not found while resolving blocker %s — likely " "deleted / disposed mid-flight; blocker still resolved.", blocker.resource_id, blocker.id, ) except AppException as exc: # Asset not in a returnable state (already returned / held / # disposed by another path). Not a resolve-blocking error — # the residue we care about is gone either way. logger.info( "offboarding.mark_blocker_resolved_manual: asset %s not " "returnable (%s) while resolving blocker %s; blocker " "still resolved.", blocker.resource_id, exc.code, blocker.id, ) proc = await self._get_process(blocker.process_id, lock=True) await self._recompute_status(proc) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.blocker_marked_manual", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={"notes": notes, "asset_returned": returned}, ) return blocker async def skip_blocker( self, blocker_id: UUID, *, reason: str, trace_id: str, actor_user_id: UUID, ) -> OffboardingBlocker: # Lock the blocker row so the resolution-pending gate below is # serialised against concurrent skip / mark_manual / handover / # revoke clicks (round-21 pass-2 review: skip + auto-mark # writes both a blocker mutation AND an AssetOperation, so a # race here would emit duplicate ops + audit rows). blocker = await self._get_blocker(blocker_id, lock=True) if blocker.resolution != RESO_PENDING: raise AppException( code="BLOCKER_RESOLVED", message=f"Blocker already resolved ({blocker.resolution})", status_code=400, ) blocker.resolution = RESO_SKIPPED blocker.notes = reason # Non-standard but real-world: hardware skip leaves the asset # physically with the departed employee (HK / overseas / dispute). # Flip the asset to ``held_by_leaver`` so it's visibly distinct # from a healthy ``assigned`` row in every list / governance view # and so subsequent claim/borrow/transfer attempts fail loudly # until an admin runs ``recover_from_leaver``. ``assigned_to`` is # preserved so the audit trail / last-holder attribution stays # intact. Best-effort: a missing asset row (deleted / already # disposed mid-flight) is logged but does NOT fail the skip. held_marked = False if blocker.resource_type == RES_HARDWARE: from app.domains.asset.models import ( ASSET_STATUS_HELD_BY_LEAVER, ) from app.domains.asset.service import AssetService asset_svc = AssetService(self.db) # SAVEPOINT, not a bare ``except``: ``mark_held_by_leaver`` # writes the asset row and an operation record. Swallowing a # failed flush would deactivate the transaction, and the two # statements right after this block — ``_get_process(lock=True)`` # and ``self.audit.log`` — would then raise. "Must not block the # operator's skip click" was therefore untrue for exactly the # DB failure it names. # # ``resource_id`` is read into a local BEFORE the block: a # savepoint rollback expires every object the body dirtied, and # a sync attribute read afterwards would raise MissingGreenlet. resource_id_for_log = blocker.resource_id blocker_id_for_log = blocker.id marked: list[Any] = [] async with best_effort( self.db, label=f"mark asset {resource_id_for_log} held_by_leaver", log=logger, ) as mark_step: asset, _op = await asset_svc.mark_held_by_leaver( resource_id_for_log, reason=( f"[offboarding skip] proc={blocker.process_id} " f"blocker={blocker.id} reason={reason}" ), operator_id=actor_user_id, source="offboarding", ) marked.append(asset.status == ASSET_STATUS_HELD_BY_LEAVER) if mark_step.ok: held_marked = bool(marked and marked[0]) elif isinstance(mark_step.error, NotFoundError): logger.warning( "offboarding.skip_blocker: hardware asset %s not " "found while skipping blocker %s — likely deleted " "or disposed mid-flight; blocker still flipped to " "skipped.", resource_id_for_log, blocker_id_for_log, ) elif isinstance(mark_step.error, AppException): # Asset was in a status that can't transition to # held_by_leaver (e.g. already disposed). Don't block # the operator's skip click. logger.warning( "offboarding.skip_blocker: cannot mark asset %s " "held_by_leaver: %s — blocker still flipped to " "skipped.", resource_id_for_log, mark_step.reason(), ) else: # A DB / driver error from the asset write must not # block the operator's skip click. The blocker is # already marked SKIPPED on the in-memory ORM object # above; the auto-mark is the BEST-EFFORT half. Audit # row carries asset_held_by_leaver=False so the # downstream chip stays honest about what actually # happened. logger.warning( "offboarding.skip_blocker: unexpected error while " "marking asset %s held_by_leaver — blocker still " "flipped to skipped (best-effort path): %s", resource_id_for_log, mark_step.reason(), ) proc = await self._get_process(blocker.process_id, lock=True) await self._recompute_status(proc) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.blocker_skipped", status="success", resource_type="offboarding_blocker", resource_id=str(blocker.id), detail={ "reason": reason, "resource_type": blocker.resource_type, "asset_held_by_leaver": held_marked, }, ) return blocker async def set_hardware_disposition( self, *, process_id: UUID, dispositions: dict[UUID, Any], actor_user_id: UUID, trace_id: str, ) -> OffboardingProcess: """Persist the per-asset hardware disposition map. Validated upstream by ``HardwareDispositionRequest`` so by the time we get here every entry has a legal action; we trust the Pydantic check and write the JSON directly. Replaces the entire map — PUT semantics, not PATCH. Refuses to mutate disposition once the process is past ``PROC_READY`` (i.e. the T+0 handler already ran) so an admin can't accidentally edit a decision that already executed. """ proc = await self._get_process(process_id, lock=True) if proc.status in (PROC_COMPLETED, PROC_CANCELLED): raise AppException( code="PROCESS_TERMINAL", message=( f"Cannot edit hardware disposition on a {proc.status} " "offboarding process." ), status_code=409, ) # Resolve the set of asset ids that ARE pending hardware blockers # on this process. Entries for any other asset id (template # bleed, stale UI state, hostile payload) are dropped so the # JSON column never accumulates garbage. The docstring promised # this behaviour pre-PR-#406 review; the implementation now # matches. valid_asset_ids = { row[0] for row in ( await self.db.execute( select(OffboardingBlocker.resource_id).where( and_( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.resource_type == RES_HARDWARE, OffboardingBlocker.resolution == RESO_PENDING, ) ) ) ).all() if row[0] is not None } from pydantic import BaseModel # noqa: PLC0415 # Convert HardwareDispositionEntry models (Pydantic) into plain # JSON-serialisable dicts before writing to the JSON column. payload: dict[str, Any] = {} dropped: list[str] = [] for asset_id, entry in dispositions.items(): if asset_id not in valid_asset_ids: dropped.append(str(asset_id)) continue if isinstance(entry, BaseModel): entry_dict = entry.model_dump(mode="json", exclude_none=True) else: entry_dict = dict(entry) # Stringify UUID values so the JSON round-trips cleanly # (MySQL JSON stores strings). for k, v in list(entry_dict.items()): if hasattr(v, "hex") and not isinstance(v, str): entry_dict[k] = str(v) payload[str(asset_id)] = entry_dict proc.hardware_disposition = payload # Audit. The disposition map is large-ish; log a summary rather # than the full payload to keep audit_log row size bounded. actions_summary = {} for entry in payload.values(): action = entry.get("action", "unknown") actions_summary[action] = actions_summary.get(action, 0) + 1 await AuditService(self.db).log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.set_hardware_disposition", status="success", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "process_id": str(proc.id), "person_id": str(proc.person_id), "asset_count": len(payload), "actions_summary": actions_summary, # Capped sample of dropped ids so an admin debugging a # missing entry can grep the audit log. Full list (large) # would bloat audit_logs rows on bulk-template misuse. "dropped_count": len(dropped), "dropped_sample": dropped[:10], }, ) await self.db.flush() return proc async def preview_hardware_disposition( self, process_id: UUID, ) -> list[dict[str, Any]]: """Return suggested default disposition for each hardware blocker. Default action is always ``recycle_to_office`` to the leaver's home office (resolved via ``AssetService.resolve_office_for_person``). When no office can be inferred, ``suggested_office_id`` is None and the UI shows a "no home office" hint so the admin manually picks one (or falls back to ``keep``). """ proc = await self._get_process(process_id, lock=False) from app.domains.asset.models import ( # noqa: PLC0415 AssetItem, AssetOfficeAddress, ) from app.domains.asset.service import AssetService # noqa: PLC0415 asset_svc = AssetService(self.db) home_office = await asset_svc.resolve_office_for_person( proc.person_id, ) # Pull pending hardware blockers. blockers = ( await self.db.execute( select(OffboardingBlocker).where( and_( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.resource_type == RES_HARDWARE, OffboardingBlocker.resolution == RESO_PENDING, ) ) ) ).scalars().all() asset_ids = [b.resource_id for b in blockers if b.resource_id] assets: dict[UUID, AssetItem] = {} if asset_ids: rows = ( await self.db.execute( select(AssetItem).where( AssetItem.id.in_(asset_ids), AssetItem.deleted_at.is_(None), ) ) ).scalars().all() assets = {a.id: a for a in rows} out: list[dict[str, Any]] = [] for blocker in blockers: asset = assets.get(blocker.resource_id) if asset is None: continue existing = (proc.hardware_disposition or {}).get( str(asset.id), ) if existing and existing.get("action"): action = existing["action"] office_id = existing.get("office_id") resolution_kind = "admin_choice" else: action = "recycle_to_office" office_id = ( str(home_office.id) if home_office is not None else None ) resolution_kind = ( "work_location_match" if home_office is not None else "no_match" ) office_name = None if office_id: office = await self.db.get( AssetOfficeAddress, UUID(office_id) if isinstance( office_id, str, ) else office_id, ) if office is not None: office_name = office.office_name or office.city out.append({ "asset_id": asset.id, "asset_tag": asset.asset_tag, "asset_name": asset.asset_name, "category": asset.category, "current_status": asset.status, "suggested_action": action, "suggested_office_id": ( UUID(office_id) if isinstance(office_id, str) and office_id else office_id ), "suggested_office_name": office_name, "suggested_office_resolution": resolution_kind, }) return out async def reconcile_blockers( self, process_id: UUID, *, trace_id: str, actor_user_id: UUID | None, ) -> OffboardingProcess: """Look at each pending blocker with a provisioning_task_id and, if the task completed, flip the blocker to 'revoked_auto'. Also syncs accepted transfer requests → 'transferred'. """ proc = await self._get_process(process_id, lock=True) # Pre-launch P2 fix: refuse to reconcile a process that is # already in a terminal state. The previous behaviour called # ``_rescan`` first — which can ADD pending blockers — but # ``_recompute_status`` short-circuits on COMPLETED/CANCELLED, # so a completed process could end up with new pending # blockers attached and admin would never see them on the UI # (status reads "completed"). Reject explicitly so the operator # re-opens the process if a new resource truly needs tracking. if proc.status in (PROC_COMPLETED, PROC_CANCELLED): raise AppException( code="PROCESS_TERMINAL", message=( f"Cannot reconcile blockers on a {proc.status} " f"offboarding. Re-open via cancel + restart if a " f"new resource needs tracking." ), status_code=409, ) # Pre-launch hardening 2026-05-04 (P0 L7): rescan first so any # NEW resources pointed at the leaver since the last scan # become tracked blockers. Without this rescan, an admin who # hands a new asset / grants a seat / opens a permission # AFTER the process hit PROC_READY would silently bypass the # blocker gate and the leaver could walk out still holding it. await self._rescan( proc, trace_id=trace_id, actor_user_id=actor_user_id, ) blockers = ( await self.db.execute( select(OffboardingBlocker).where( and_( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.resolution == RESO_PENDING, ) ) ) ).scalars().all() # Batch-load tasks and xfers before the loop to avoid: # (a) N+1 round-trips and (b) autoflush-induced deadlocks — each # in-loop SELECT would autoflush dirty blocker rows from previous # iterations, racing with concurrent SLA ticks on the same rows. task_ids = [b.provisioning_task_id for b in blockers if b.provisioning_task_id] xfer_ids = [b.transfer_request_id for b in blockers if b.transfer_request_id] tasks_by_id: dict = {} xfers_by_id: dict = {} if task_ids: tasks_by_id = { t.id: t for t in ( await self.db.execute( select(ProvisioningTask).where( ProvisioningTask.id.in_(task_ids) ) ) ).scalars().all() } if xfer_ids: xfers_by_id = { x.id: x for x in ( await self.db.execute( select(AssetTransferRequest).where( AssetTransferRequest.id.in_(xfer_ids) ) ) ).scalars().all() } for b in blockers: # Check provisioning task if b.provisioning_task_id: task = tasks_by_id.get(b.provisioning_task_id) if task and task.status == TASK_COMPLETED: b.resolution = RESO_REVOKED_AUTO b.resolution_ref = task.external_ref continue # Check transfer request if b.transfer_request_id: xfer = xfers_by_id.get(b.transfer_request_id) if xfer and xfer.status == XFER_COMPLETED: b.resolution = RESO_TRANSFERRED b.resolution_ref = str(xfer.id) continue # Belt-and-suspenders: if the linked transfer was cancelled # or rejected (should have been cleared by cancel_transfer / # respond_to_transfer already), clear the pointer here so a # new transfer can be linked. if xfer and xfer.status in (XFER_CANCELLED, XFER_REJECTED): b.transfer_request_id = None await self._recompute_status(proc) await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id else "system", actor_user_id=actor_user_id, action="offboarding.reconciled", status="success", resource_type="offboarding_process", resource_id=str(proc.id), detail={"status_after": proc.status}, ) return proc # ───────────────────────── SSO suspend + complete ───────────────────────── async def suspend_sso( self, process_id: UUID, *, trace_id: str, actor_user_id: UUID | None, ) -> OffboardingProcess: """Execute every still-pending auto_revoke provisioning task for this leaver, then mark SSO as suspended. As of policy-aware scan, every auto_revoke task (including Google Workspace user_suspend and AWS IAM DeleteUser) is already created by ``_rescan`` when the offboarding process starts. This method dispatches any that haven't executed yet and flips ``sso_suspended_at`` to now so the operator gets a single "everything has been shut off" timestamp on the process. Safe to call multiple times — already-completed tasks are no-ops on re-execute; the method stays idempotent. Only allowed when the process is in PROC_READY (all blockers resolved). Refuses on PROC_BLOCKED so the "resolve all blockers before cutting SSO" gate can't be skipped by calling this endpoint directly. PROC_COMPLETED / PROC_CANCELLED are also rejected. """ proc = await self._get_process(process_id, lock=True) if proc.status != PROC_READY: raise AppException( code="PROCESS_NOT_READY", message=( f"Cannot suspend SSO: process is {proc.status}. " "Resolve all pending blockers first." ), status_code=400, ) # Idempotency: if SSO was already suspended (e.g. advance_service # called this and then the admin calls it again), return the # existing row unchanged. Without this, a second call re-queries # all pending tasks (finds zero) and overwrites sso_suspended_at # with a new timestamp, erasing the original "when did SSO cut off" # record that compliance reports rely on. if proc.sso_suspended_at is not None: return proc person = ( await self.db.execute( select(IdentityPerson).where(IdentityPerson.id == proc.person_id) ) ).scalar_one_or_none() if not person: raise AppException( code="PERSON_NOT_FOUND", message="Leaver identity not found", status_code=404, ) pending = ( await self.db.execute( select(ProvisioningTask).where( and_( ProvisioningTask.user_id == proc.person_id, ProvisioningTask.action == ACTION_REVOKE, ProvisioningTask.source == SOURCE_OFFBOARDING, ProvisioningTask.status == TASK_PENDING, # Scope to THIS process so tasks from a prior # cancelled/completed cycle (same person_id, # different process_id) can't leak into this # suspend run. cancel_process already cancels # pending tasks on cancel, so this is defence- # in-depth against edge-case state drift. ProvisioningTask.context_data["offboarding_process_id"].as_string() == str(proc.id), ) ) ) ).scalars().all() # P0-2 fix (2026-05-08): per-task try/except. Earlier code looped # without wrapping ``execute_task`` — a single connector raise # (lock-wait timeout, FK violation on the audit row, vendor 5xx # surfacing as ``OperationalError``) broke the loop and skipped # every subsequent task PLUS skipped the ``sso_suspended_at`` # write. The operator would see status=READY, no timestamp, and # be unsure whether retry was safe. Mirrors the same fix # ``ProvisioningService.bulk_execute`` got at PR #207 — that fix # didn't carry over to this dedicated suspend_sso loop. # # Each failure is recorded, the loop continues, and (Round-49) # the ``sso_suspended_at`` timestamp is set ONLY when no revoke # task failed. Without this conditional, a partial-failure # suspend would let ``complete_process`` (which gates on # ``sso_suspended_at is not None``) finalise the offboarding # while the leaver's vendor sessions are still live through # natural JWT TTL — and the compliance report would lie about # "vendor sessions terminated at X". Failed tasks stay in # their connector-set status (FAILED / IN_PROGRESS) and # surface in the provisioning admin page for retry. executed = 0 failed_task_ids: list[str] = [] executed_task_ids: list[str] = [] not_before_overridden: list[str] = [] # ``suspend_sso`` is an explicit admin (or T+0 cron) decision to # dispatch the revokes NOW. Clear every pending task's # ``not_before`` defer-gate (set at task-creation time by # ``_apply_*_policy``, migration 221) so ``execute_task``'s # defensive re-check (which re-runs a ``SELECT ... FOR UPDATE`` # and may bypass the session identity map under autoflush # quirks) sees the cleared value persisted in DB rather than # only in-memory. Without this flush, an admin manually # clicking "Suspend SSO" on a future-dated process could loop # with every task silently deferred — no real revoke runs but # ``sso_suspended_at`` would still stamp because no task # FAILED, and ``complete_process`` would accept the bogus # finalisation. The T+0 cron path # (advance_service.run_tick) is unaffected: leaver_date <= # today there, so the gate is already past — the clears are # no-ops on those rows. for task in pending: if task.not_before is not None: not_before_overridden.append(str(task.id)) task.not_before = None if not_before_overridden: await self.db.flush() for task in pending: try: # Capture the RETURN value: execute_task re-acquires the # task under a row lock and may operate on a freshly # re-fetched instance, so read its status off the returned # object rather than the loop variable. ran = await self.provisioning.execute_task( task, trace_id=trace_id, actor_user_id=actor_user_id, ) # ``execute_task`` does NOT raise when the connector # reports ``ProvisioningResult(ok=False)`` — it records # the failure by setting ``task.status=FAILED`` and # returning normally. The original ``except`` below only # catches genuine exceptions, so a clean ok=False return # left ``failed_task_ids`` empty and ``sso_suspended_at`` # was stamped anyway — letting ``complete_process`` # finalise the offboarding while the vendor seat was # still live and making the compliance "vendor sessions # terminated at X" claim a lie. Read the post-call status # so an ok=False revoke is counted as a failure too. A # task auto-downgraded to manual_notify (NOTIFIED) is NOT # a failure: it is expected, awaiting a human, and must # not block the SSO-suspend stamp. # # Gate POSITIVELY on the success states rather than only # excluding FAILED: a transient connector error (429/5xx/ # timeout) routes through ``_maybe_schedule_transient_retry``, # which leaves the task at PENDING (re-queued with backoff, # vendor revoke NOT done) and returns it normally. The old # ``!= FAILED`` test counted that PENDING as executed and # stamped ``sso_suspended_at``, letting complete_process # finalise offboarding while vendor sessions were still live. # COMPLETED + NOTIFIED are the only non-blocking outcomes; # FAILED and PENDING both mean "revoke not done". if (ran or task).status in (TASK_COMPLETED, TASK_NOTIFIED): executed += 1 executed_task_ids.append(str(task.id)) else: failed_task_ids.append(str(task.id)) except Exception as exc: # noqa: BLE001 failed_task_ids.append(str(task.id)) logger.exception( "suspend_sso: provisioning task %s raised %s — " "continuing with remaining tasks, operator must " "retry the failed task from the provisioning page", task.id, type(exc).__name__, ) await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id is not None else "system", actor_user_id=actor_user_id, action="offboarding.suspend_sso_task_raised", status="failure", resource_type="provisioning_task", resource_id=str(task.id), detail={ "offboarding_process_id": str(proc.id), "error": format_exception_for_log( exc, max_len=500, include_type=False, ), "error_type": type(exc).__name__, }, ) # Round-49: only stamp ``sso_suspended_at`` when no revoke # tasks failed (clean run) or there were no tasks to run at # all. The prior unconditional stamp let ``complete_process`` # accept the offboarding even when every revoke failed, lying # to the compliance report about "vendor sessions terminated # at X". With this gate, complete_process refuses on a # partial-failure suspend, surfacing failed tasks to the # admin via /provisioning so they can be retried before # completion. # A task that hard-FAILED on a PRIOR suspend run is left in # status=FAILED (a transient one at IN_PROGRESS), so the ``pending`` # query above — filtered to ``status == PENDING`` — never re-selects # it and ``failed_task_ids`` comes back empty this run. Without also # checking for lingering non-success revoke tasks tied to THIS # process, a second suspend_sso call (or one reached after a blocker # was resolved by a non-task path while its revoke task lingered) # would stamp ``sso_suspended_at`` while a revoke is still failed — # letting ``complete_process`` finalise and the compliance "vendor # sessions terminated at X" claim lie. Block the stamp while any # offboarding revoke task for this process sits in FAILED/IN_PROGRESS. lingering_unresolved = ( await self.db.execute( select(ProvisioningTask.id).where( and_( ProvisioningTask.user_id == proc.person_id, ProvisioningTask.action == ACTION_REVOKE, ProvisioningTask.source == SOURCE_OFFBOARDING, ProvisioningTask.status.in_( (TASK_FAILED, TASK_IN_PROGRESS), ), ProvisioningTask.context_data["offboarding_process_id"].as_string() == str(proc.id), ) ).limit(1) ) ).first() if not failed_task_ids and lingering_unresolved is None: proc.sso_suspended_at = datetime.now(UTC) # Flush before audit so the timestamp + audit row land in the # same flush batch — keeps "if you see the audit row, the # timestamp is durable" as a hard invariant. await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id is not None else "system", actor_user_id=actor_user_id, action="offboarding.sso_suspended", status=( "success" if not failed_task_ids and lingering_unresolved is None else "partial" ), resource_type="offboarding_process", resource_id=str(proc.id), detail={ "revoke_tasks_executed": executed, "executed_task_ids": executed_task_ids, "failed_task_count": len(failed_task_ids), "failed_task_ids": failed_task_ids, "not_before_overridden_task_ids": not_before_overridden, "sso_suspended_at_stamped": ( proc.sso_suspended_at.isoformat() if proc.sso_suspended_at else None ), }, ) # Stash the result on the proc instance for the router so the # response body can surface failed task ids to the operator. # SQLAlchemy ignores attributes that don't map to columns — this # rides the in-memory object only and does not persist. proc._suspend_sso_result = { # type: ignore[attr-defined] "executed": executed, "executed_task_ids": executed_task_ids, "failed_task_ids": failed_task_ids, } return proc async def complete_process( self, process_id: UUID, *, trace_id: str, actor_user_id: UUID | None, ) -> OffboardingProcess: # ``actor_user_id`` is ``None`` when invoked by the autonomous # T+0 advance / orphan-recovery cron (no human actor); the audit # row records ``actor_type='system'`` in that case. A human # clicking "Complete" in the admin UI passes their user id. proc = await self._get_process(process_id, lock=True) # Pre-launch P1 fix (Round 1, SEC-002): tighten the gate. The # previous check only refused PROC_BLOCKED — admin clicks could # complete from SCANNING (mid-rescan, blockers not yet enumerated), # CANCELLED (resurrecting a cancelled flow), READY without # ``sso_suspended_at`` (skip SSO suspend entirely), and even # COMPLETED (overwrite ``completed_at``). # The legitimate transitions are READY-with-SSO-suspended → COMPLETED # and SUSPENDED → COMPLETED. Idempotent re-call on COMPLETED is a # no-op (200 with the existing row), not an overwrite. if proc.status == PROC_COMPLETED: return proc if proc.status in (PROC_CANCELLED,): raise AppException( code="PROCESS_CANCELLED", message=( "Cannot complete a cancelled offboarding — reopen " "the process first" ), status_code=400, ) if proc.status == PROC_SCANNING: raise AppException( code="PROCESS_SCANNING", message=( "Process is still scanning for blockers; " "wait for the scan to finish before completing" ), status_code=400, ) if proc.status == PROC_BLOCKED: raise AppException( code="PROCESS_BLOCKED", message="Cannot complete while blockers are pending", status_code=400, ) # READY (or any post-READY suspended state) requires SSO to have # actually been suspended. ``suspend_sso`` is the gate that # severs vendor sessions; without it, ``complete_process`` # would mark the offboarding done while the leaver still has # active Google / Feishu sessions through the natural JWT TTL. if proc.sso_suspended_at is None: raise AppException( code="PROCESS_NOT_READY", message=( "Cannot complete: SSO has not been suspended yet. " "Run suspend_sso first." ), status_code=400, ) # The COMPLETED invariant is completed ⟹ left: a finished offboarding # MUST leave the person in 'left', never an active-employee status. # The autonomous T+0 advance cron satisfies this by flipping the # person via LeaverBlockGuard *before* it calls complete_process # (advance_service._advance_single). The manual "Complete" button on # the cockpit has no separate flip action — force-transfer and # suspend-SSO are exposed, but flip-to-left is not — so an admin who # drives the steps by hand was wedged: complete refused with # PERSON_NOT_LEFT and nothing on the cockpit could clear it short of # waiting for the next cron tick. Drive the same guarded flip here so # the manual and autonomous paths are symmetric. flip_to_left # re-asserts "zero pending blockers" and raises LeaverBlockedError if # any remain, so the invariant still cannot be skipped — we ensure # 'left' rather than merely asserting it. person_row = await self.db.get(IdentityPerson, proc.person_id) should_flip_person = False if person_row is not None and person_row.employment_status != "left": # Only a person actively in the offboarding window # (``leaver_pending``) is safe to flip here. Any OTHER non-left # status means the person is NOT mid-offboarding — e.g. rehired # back to ``active`` (or ``pre_hire``) while a stale READY process # lingered. Flipping them to ``left`` would resurrect-and-kill a # currently-employed person, a worse failure than refusing. Keep # the original PERSON_NOT_LEFT refusal for that case. if person_row.employment_status != "leaver_pending": raise AppException( code="PERSON_NOT_LEFT", message=( "Cannot complete offboarding: person employment_status " f"is {person_row.employment_status!r}. Transition to " "'left' before completing the process." ), status_code=400, ) # Never finalize before the leaver's last working day has ended in # their own wall-clock — flipping early would strip access while # they are still an active employee. The cron enforces this with # its ``leaver_date <= today`` eligibility query; mirror it. # NOTE: this deliberately ALLOWS a manual completion ON the # leaver_date (``>`` not ``>=``) — the cockpit's manual "Complete" # is an explicit admin action and is intentionally less # conservative than the autonomous advance cron (which defers to # the day after). See test_complete_flips_leaver_pending_to_left. if proc.leaver_date > _local_today_for_person(person_row): raise AppException( code="LEAVER_DATE_NOT_REACHED", message=( "Cannot complete offboarding before the leaver_date " f"({proc.leaver_date.isoformat()}); the person is an " "active employee until then." ), status_code=400, ) should_flip_person = True # DAX owns only the current resource-owner fact. Offboarding owns the # successor choice and completion lifecycle. Lock the current dataset # and table owner rows before resolving that existing handover fact so # completion cannot race a concurrent owner change. Historical request # snapshots, approvals, bindings/effects and provider IAM stay intact. data_access_handover_plan = None data_access_successor = None if person_row is not None: from app.domains.data_access.service import ( # noqa: PLC0415 DataAccessService, ) data_access_service = DataAccessService() data_access_handover_plan = ( await data_access_service.plan_offboarding_owner_handover( self.db, leaver_person_id=person_row.id, ) ) if data_access_handover_plan.required: data_access_successor = await self._terminal_successor( proc, person_row, ) if not data_access_successor.ok: raise AppException( code="DATA_ACCESS_OWNER_HANDOVER_UNRESOLVED", message=( "Cannot complete offboarding while this person owns " "datasets or tables and no active handover person " "can be resolved." ), status_code=409, ) if should_flip_person: from app.domains.leaver_blocked.guard import ( # noqa: PLC0415 LeaverBlockGuard, ) from app.domains.leaver_blocked.models import ( # noqa: PLC0415 LEAVER_VIA_ADMIN_API, LEAVER_VIA_PROCESS_ADVANCE, ) guard = LeaverBlockGuard(self.db) await guard.flip_to_left( person_row, # actor_user_id is None only on the cron path (which already # flipped above, so this branch is normally skipped there); # a human click carries the admin's id. via=( LEAVER_VIA_PROCESS_ADVANCE if actor_user_id is None else LEAVER_VIA_ADMIN_API ), trace_id=trace_id, actor_user_id=actor_user_id, last_synced_at=datetime.now(UTC), ) if ( data_access_handover_plan is not None and data_access_handover_plan.required and data_access_successor is not None and data_access_successor.person is not None ): await data_access_service.apply_offboarding_owner_handover( self.db, plan=data_access_handover_plan, leaver_person_id=proc.person_id, new_owner=data_access_successor.person, offboarding_process_id=proc.id, successor_resolution_detail=data_access_successor.as_detail(), trace_id=trace_id, actor_user_id=actor_user_id, ) proc.status = PROC_COMPLETED proc.completed_at = datetime.now(UTC) # Strip privileged platform RBAC grants on completion — a finished # offboarding must not leave a departed account holding # admin/auditor/finance/cloud authority (the "系统内权限" cleanup # gap). Idempotent and self-auditing; ``LAST_GLOBAL_ADMIN`` is # swallowed inside the helper so completing the only remaining # admin's offboarding never wedges here. The daily # ``offboarding.revoke_leaver_platform_roles`` reconciler is the # backstop for drift and for leavers completed before this step # existed. from app.auth.service import AuthService # noqa: PLC0415 revoked_roles = await AuthService(self.db).revoke_platform_roles_for_leaver( proc.person_id, trace_id=trace_id, actor_user_id=actor_user_id, ) # Freeze this leaver's evidence on the day it is true. "Prove # their access was removed" is the offboarding control question, # and reconstructing it months later means querying tables that # have moved on. The coordinator never raises: by the time we # get here the access is already gone, so failing a real # offboarding over an evidence query would trade a security # action for a paperwork one. See # ``core.coordinators.offboarding_evidence_coordinator``. from app.core.coordinators.offboarding_evidence_coordinator import ( # noqa: PLC0415 archive_offboarding_evidence, ) evidence = await archive_offboarding_evidence( self.db, person_id=proc.person_id, process_id=proc.id, trace_id=trace_id, actor_user_id=actor_user_id, # The process's own last day, which is NOT NULL — unlike the # person's ``termination_date``, which the coordinator would # otherwise fall back on and which is frequently empty. The # anchor is what stops the archive from reporting a # departing admin as having held nothing. anchor_at=proc.leaver_date.isoformat(), cycle=proc.leaver_date.isoformat(), ) await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id is not None else "system", actor_user_id=actor_user_id, action="offboarding.completed", status="success", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "auto": actor_user_id is None, "revoked_roles": revoked_roles, "evidence_archived": [ b["query_key"] for b in evidence["archived"] ], "evidence_failed": [ f["query_key"] for f in evidence["failed"] ], }, ) return proc # ───────────────────────── Post-commit dispatch ───────────────────────── async def dispatch_post_commit_actions( self, person_id: UUID, process_id: UUID, *, trace_id: str, actor_user_id: UUID | None, ) -> dict[str, Any]: """Run side effects that MUST happen after the start_process transaction has committed. D-2 fix (2026-05-08): previously start_process called Google's ``users.signOut`` mid-transaction. If the same transaction later rolled back (FK violation on a downstream audit row, deadlock, any raise inside the rescan), the leaver's Google session was already killed but Helix had no record — invisible drift between Helix state and Google state. Moving these effects to a post-commit dispatcher means ``start_process`` is fully transactional: either the process row + blockers + initial audit are durable, or none of them are. Idempotent: * Google ``signOut`` is idempotent vendor-side. * Notification dispatch writes a notification_outbox row first; if a duplicate row exists for (process_id, kind), the second call no-ops. Future-dated leaver gate (pre-launch incident 2026-05-28): when the process's ``leaver_date > local_today`` (in the leaver's wall-clock zone) we return early with ``deferred_until_leaver_date`` set on the result dict and an ``offboarding.post_commit_deferred_future_leaver`` audit row written. Force-logout and start-of-cycle notifications wait for either the admin's manual "Re-notify" action (which calls this method again) or the T+0 flow on or after leaver_date. The process row itself stays durable from ``start_process``'s own commit so the offboarding panel renders normally. Returns a summary dict the caller can echo back to the operator. Failures inside this method are logged and surfaced as ``status='partial'`` audit rows, but do NOT raise — by the time we get here the process is already durable, so the caller shouldn't have to handle exceptions for what is effectively cleanup work. """ result: dict[str, Any] = { "google_force_logout_pushed": False, "notifications": {"leaver": None, "manager": None}, } person = await self.db.get(IdentityPerson, person_id) if person is None or person.deleted_at is not None: logger.warning( "dispatch_post_commit_actions: person %s not found or " "deleted — skipping force-logout and notifications", person_id, ) return result # ``leaver_date`` gate: when the offboarding is opened with a # future leaver_date (HR set termination_date 30 days out), # force-logout MUST wait — otherwise the leaver's Google / Feishu # sessions die immediately on HRIS sync, well before their last # working day. Pre-launch incident 2026-05-28: Thalie's # leaver_date=2026-06-29 but force_logout was pushed on sync day # because this method ran unconditionally on every start_process # commit. Now we read ``proc.leaver_date`` and compare against # the leaver's local today; if not yet T+1 we skip force-logout # AND skip leaver/manager notifications (they're sent on the # later catch-up cron via the same dispatcher). Status start / # process row are already durable from start_process's commit, # so the offboarding panel still shows the leaver. proc_row = await self.db.get(OffboardingProcess, process_id) # Safety guard (2026-07-02, account-lifecycle epic): NEVER run # force-logout / Google account-suspend for a CANCELLED offboarding. # A false-leaver whose process was cancelled is an ACTIVE employee # again — and _push_force_logout now SUSPENDS the Google account # (not just kills the session), which would lock out a working # employee. dispatch_post_commit_actions is re-invokable (router + # T+0 advance cron), so a cancel between create and a later re-fire # is a real window. Bail before any vendor account action. if proc_row is not None and proc_row.status == PROC_CANCELLED: logger.warning( "dispatch_post_commit_actions: process %s is cancelled — " "skipping force-logout + account-suspend for %s (false leaver)", process_id, person.work_email, ) result["skipped_cancelled"] = True await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=actor_user_id, action="offboarding.post_commit_skipped_cancelled", status="success", resource_type="offboarding_process", resource_id=str(process_id), detail={"person_id": str(person_id), "work_email": person.work_email}, ) return result # Review 2026-07-08 fix (#1748): PERSON_ACTIVE guard, pushed DOWN # from the sibling ``reenforce_session_cutoff`` (service.py ~893) so # EVERY caller of this dispatcher — the manual /re-notify router, the # MCP mark-leaver retry, and any future re-invoker — is protected, # not just the one reenforce path. A rehired / boomerang employee is # flipped back to ``employment_status='active'`` while their previous # COMPLETED offboarding row survives (start_process only opens a NEW # process on the next termination; it never resurrects the old one). # That stale row has a PAST ``leaver_date``, so the future-leaver # defer gate below does NOT catch it — a /re-notify click would sail # through and ``_push_force_logout`` would SUSPEND the active # employee's Google account (account-level disable, not just a # session kill), locking a working person out of the entire Google # suite. Genuine leavers are ``leaver_pending`` / ``left`` at dispatch # time (both the manual mark-leaver and HRIS-confirm paths flip # employment_status BEFORE calling this), so this only ever trips for # the rehire/mis-flag class. Raise 409 with the same code + semantics # as reenforce_session_cutoff; the automatic callers all wrap this in # try/except and log+rollback, so the raise is a hard stop only for # the manual /re-notify router (exactly the surface #1748 flagged). if person.employment_status == "active": raise AppException( code="PERSON_ACTIVE", message=( "Refusing to run offboarding post-commit actions " "(force-logout + Google account suspend): this person is " "an active employee (rehired / mis-flagged leaver). " "Cutting their sessions would lock out a working employee." ), status_code=409, ) # Handover push — post-commit (so a rollback never announces a # process that does not exist) but deliberately ABOVE the # future-leaver defer gate below. # # Everything under that gate waits for the last working day: that # is right for force-logout and for the "process started" cards, # whose copy assumes T+0. It is exactly WRONG for this one. The # whole value of telling the lead is the notice period — time to # hand it to whoever actually took over the work BEFORE the data # moves. Deferring it to T+0 would deliver it the day access is # cut, which is no notice at all. # # Retroactively-opened processes are excluded: the historical # backfill opened 48 rows in one batch with leaver_dates months # past, and "you are X's handover person" about someone who left # long ago is noise with no action attached. if proc_row is not None and proc_row.default_handover_person_id: _local_today = _local_today_for_person(person) _created = ( proc_row.created_at.date() if proc_row.created_at else None ) _retroactive = ( proc_row.leaver_date is not None and _local_today > proc_row.leaver_date and _created is not None and (_created - proc_row.leaver_date).days > RETROACTIVE_OPEN_GRACE_DAYS ) if not _retroactive: await self.notify_handover_assigned( proc_row, person, successor_id=proc_row.default_handover_person_id, assigned_reason_key="prefill", trace_id=trace_id, ) if proc_row is not None and proc_row.leaver_date is not None: local_today = _local_today_for_person(person) if local_today <= proc_row.leaver_date: result["deferred_until_leaver_date"] = ( proc_row.leaver_date.isoformat() ) await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=actor_user_id, action="offboarding.post_commit_deferred_future_leaver", status="success", resource_type="offboarding_process", resource_id=str(process_id), detail={ "person_id": str(person_id), "leaver_date": proc_row.leaver_date.isoformat(), "local_today": local_today.isoformat(), "reason": ( "force_logout + start-notifications deferred " "until leaver_date is reached in leaver's " "local TZ" ), }, ) return result # 1) Force-logout (existing best-effort behaviour, just deferred # to post-commit). # Pre-launch P2 fix (Round 1, SEC-008): record the prior # ``force_logout_at`` value BEFORE the push, then re-fetch the # row after to detect whether THIS push set it. The previous # implementation read ``person.force_logout_at`` from the # outer-scope copy that ``_push_force_logout`` doesn't see, so # the success-detection always reflected the pre-push value # (audit metadata correctness only — no security boundary # crossed, but a misleading audit row). prior_flo = person.force_logout_at async with best_effort( self.db, label=f"post-commit force_logout for {person.work_email}", log=logger, ) as flo_step: await self._push_force_logout(person_id, trace_id=trace_id) await self.db.refresh(person, ["force_logout_at"]) result["google_force_logout_pushed"] = ( person.force_logout_at is not None and person.force_logout_at != prior_flo ) if not flo_step.ok: exc = flo_step.error logger.error( "dispatch_post_commit_actions: force_logout failed for %s: %s", person.work_email, flo_step.reason(), ) # Round 2 P2 fix (SEC2-002): redact the raw exception # message before echoing to the admin response. The full # ``str(exc)`` can include credential fragments (Google # API key prefix in URL, SQL with literal user data, # filesystem paths to service-account keyfiles). The # ``logger.exception`` line above keeps the full stack # for ops; the API surface gets a sanitised type+short # message. Round-22: routed through # ``format_exception_for_log`` so AppException-derived # raises (build_driver_by_id credential-decrypt failure, # etc.) surface their ``.message`` instead of an empty # string — same truncation guarantee preserved. result["google_force_logout_error"] = ( flo_step.reason(max_len=200) or "unknown" ) # 2) Notify leaver + manager. try: leaver_outcome, manager_outcome = ( await self._dispatch_offboarding_start_notifications( person=person, process_id=process_id, trace_id=trace_id, ) ) result["notifications"]["leaver"] = leaver_outcome result["notifications"]["manager"] = manager_outcome except Exception as exc: # noqa: BLE001 logger.exception( "dispatch_post_commit_actions: notification dispatch " "failed for %s — process is committed, operator can " "manually re-notify from the offboarding panel", person.work_email, ) # Round 2 P2 fix (SEC2-002): same redaction as the # force_logout error path above. Full stack is in # logger.exception for ops; API surface gets sanitised. # Round-22: routed through ``format_exception_for_log``. result["notification_error"] = format_exception_for_log( exc, max_len=200, ) return result async def _dispatch_offboarding_start_notifications( self, *, person: IdentityPerson, process_id: UUID, trace_id: str, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Send leaver + manager notifications when an offboarding process is freshly started. Idempotency: ``OffboardingReminderLog`` rows keyed by (process_id, ``process_started_leaver`` / ``process_started_manager``) guard against duplicate sends if the dispatcher fires twice. """ from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) from app.notifications.service import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) notif = NotificationService(self.db) leaver_outcome: dict[str, Any] | None = None manager_outcome: dict[str, Any] | None = None leaver_kind = "process_started_leaver" manager_kind = "process_started_manager" # ``reminder_kind`` column is ``String(32)`` — keep the new key # under that bound. Used by the retroactive-process guard below # so that a re-fire (admin /processes/{id}/re-notify, HRIS # confirm replay, MCP mark-leaver retry) does not amplify the # ``offboarding.start_notifications_skipped_past_leaver`` audit # row for the same process_id — the first suppression is # auditable; later passes silently no-op via this marker. retroactive_skip_kind = "start_skipped_retroactive" # Look up any existing reminder rows for this process+kind to # dedupe a re-fire (both the normal start kinds AND the # retroactive-skip marker). existing_logs = ( await self.db.execute( select(OffboardingReminderLog).where( OffboardingReminderLog.process_id == process_id, OffboardingReminderLog.reminder_kind.in_( (leaver_kind, manager_kind, retroactive_skip_kind), ), ) ) ).scalars().all() already_sent = {row.reminder_kind for row in existing_logs} leaver_date_str = "TBD" # Look up the freshly-started process's leaver_date for context. proc_row = await self.db.get(OffboardingProcess, process_id) if proc_row is not None: leaver_date_str = proc_row.leaver_date.isoformat() # ── 1) Leaver self-notification ── from app.notifications.service import ( # noqa: PLC0415 CardSection, CardStat, LocalizedText, ) # Retroactive-process guard (prod incident 2026-05-30, PR #588 # follow-up): the historical-leaver backfill script opened # OffboardingProcess rows for 48 long-departed employees in one # batch. Each had ``leaver_date`` weeks or months in the past, so # the outer ``dispatch_post_commit_actions`` defer-gate (which # defers only when ``leaver_date >= local_today``) fell straight # through to here, and start notifications fired with # "X 天前已离职" cards + present-tense copy # ("请关注下属在离职日前完成"). 48 managers + a clutch of leaver # mailboxes were paged for work that's already covered by the # T+0 ``LeaverEscalationService`` (which uses past-tense copy # and only fires when there are real must-transfer blockers). # # Discriminator: the BACKFILL scenario has ``created_at`` far # AFTER ``leaver_date`` — the process was opened retroactively. # The legitimate T+1 catch-up scenario (process opened weeks # BEFORE leaver_date, outer gate deferred until T+1, dispatcher # re-fires today) has ``created_at`` BEFORE ``leaver_date``, so # the gap is negative. Allowing a one-day grace catches HR # recording a leaver on the day after their last day without # suppressing — anything beyond that is a forensic / backfill # entry that doesn't need a "process just started" FYI. # # Force-logout above this guard still runs unconditionally # for past leavers — cutting access for a retroactively- # opened process is still useful. Threshold constant lives # at module scope (``RETROACTIVE_OPEN_GRACE_DAYS``). local_today = _local_today_for_person(person) proc_leaver_date = proc_row.leaver_date if proc_row is not None else None proc_created_at = proc_row.created_at if proc_row is not None else None created_at_date = proc_created_at.date() if proc_created_at is not None else None is_retroactive = ( proc_leaver_date is not None and local_today > proc_leaver_date and created_at_date is not None and (created_at_date - proc_leaver_date).days > RETROACTIVE_OPEN_GRACE_DAYS ) if is_retroactive: # Mypy does not retain the value narrowing performed while # computing ``is_retroactive`` above. These are the same guards, # made explicit so the forensic values below are non-optional. assert proc_leaver_date is not None assert proc_created_at is not None assert created_at_date is not None # Dedupe re-fires: if the first pass already stamped the # skip marker, no audit row and no log row this time — # the suppression decision is already on the record. # ``re-notify`` admin endpoint, HRIS staged-event replay # and the mark-leaver retry path all re-enter here. if retroactive_skip_kind in already_sent: return None, None past_days = (local_today - proc_leaver_date).days retroactive_days = (created_at_date - proc_leaver_date).days await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.start_notifications_skipped_past_leaver", status="success", resource_type="offboarding_process", resource_id=str(process_id), detail={ "person_id": str(person.id), "leaver_date": proc_leaver_date.isoformat(), "local_today": local_today.isoformat(), "created_at": proc_created_at.isoformat(), "past_days": past_days, "retroactive_open_days": retroactive_days, "reason": ( "OffboardingProcess opened retroactively " f"({retroactive_days} days after leaver_date) — " "start notifications would render past-tense " "FYI with no actionable work; T+0 escalation " "cron covers any real pending blockers" ), }, ) # Stamp the dedupe marker so subsequent ``re-notify`` calls # silently no-op (matches the idempotency promise the # /processes/{id}/re-notify endpoint advertises). Empty # channels list — no actual delivery happened. self.db.add(OffboardingReminderLog( process_id=process_id, reminder_kind=retroactive_skip_kind, channels=[], detail={ "leaver_date": proc_leaver_date.isoformat(), "retroactive_open_days": retroactive_days, }, )) await self.db.flush() return None, None # Compute days remaining for the stat grid. ``leaver_date`` is # a real date column on OffboardingProcess; missing rows fall # back to "—". Reaches here only when ``leaver_date >= today`` # (past-leaver branch above short-circuits), so the # ``delta < 0`` branch is dead — kept for defence-in-depth # against a future caller changing the guard. days_remaining_label = "—" # Same value as ``days_remaining_label``, carried as a localized # triple for the card stat grid and the card subtitles (the plain # Chinese string survives only as this triple's ``zh`` source). days_remaining_text: str | LocalizedText = "—" if proc_row is not None and proc_row.leaver_date is not None: delta = (proc_row.leaver_date - local_today).days if delta < 0: days_remaining_label = f"{abs(delta)} 天前已离职" days_remaining_text = LocalizedText( zh=days_remaining_label, en=( f"left {abs(delta)} day ago" if abs(delta) == 1 else f"left {abs(delta)} days ago" ), ja=f"{abs(delta)}日前に退職済み", ) elif delta == 0: days_remaining_label = "今日" days_remaining_text = LocalizedText( zh=days_remaining_label, en="today", ja="本日", ) else: days_remaining_label = f"{delta} 天后" days_remaining_text = LocalizedText( zh=days_remaining_label, en=( f"in {delta} day" if delta == 1 else f"in {delta} days" ), ja=f"{delta}日後", ) # Per-language fragments of the same value, for the subtitle # f-strings below (which must interpolate it into a sentence rather # than hand the reader a whole ``LocalizedText``). The plain-string # fallback is the "—" placeholder, identical in every language. days_zh, days_en, days_ja = ( ( days_remaining_text.zh, days_remaining_text.en, days_remaining_text.ja, ) if isinstance(days_remaining_text, LocalizedText) else (days_remaining_text, days_remaining_text, days_remaining_text) ) if leaver_kind not in already_sent: env_leaver = NotificationEnvelope( recipient=person, title="📤 离职流程已启动 — 请完成资产与权限交接", body_text=( f"Hi {person.display_name},你的离职流程已启动。" f"请登录 Helix 自助门户,逐项完成 [b]资产归还[/b]、" f"[b]资产回购[/b](自留)、或 [b]权限交接[/b]。" f"未处理的资产会在离职日自动标记为「离职持有」(留在你名下," f"不阻塞流程),IT 会在当天收到通知跟进。" ), channels=["feishu", "email"], template_key="offboarding.process_started_leaver", context={ "person_name": person.display_name or person.work_email, "person_email": person.work_email, "leaver_date": leaver_date_str, # Localizable, not a bare str: the template's three # language variants all end on {cta_line}, so a Chinese # literal here put a Chinese last line on the English and # Japanese cards. Confirmed against production — most of # the workforce reads ``en_US``. "cta_line": LocalizedText( zh="请尽快登录 Helix 完成交接。", en="Please sign in to Helix and complete your handover.", ja="Helix にログインし、引き継ぎを完了してください。", ), }, card_subtitle=LocalizedText( zh=f"预计离职日:{leaver_date_str}({days_zh})", en=f"Expected last day: {leaver_date_str} ({days_en})", ja=f"退職予定日:{leaver_date_str}({days_ja})", ), card_stats=[ CardStat( label=LocalizedText( zh="预计离职日", en="Expected last day", ja="退職予定日", ), value=leaver_date_str, ), CardStat( label=LocalizedText( zh="距今", en="Time remaining", ja="残り期間", ), value=days_remaining_text, ), CardStat( label=LocalizedText( zh="资产归还 / 交接", en="Return / hand over assets", ja="資産の返却・引き継ぎ", ), value="/my/offboarding", hint=LocalizedText( zh="逐项查看资产 / 账号 / 权限", en="Review each asset, account and access right", ja="資産・アカウント・アクセス権限を一つずつご確認ください", ), ), CardStat( label=LocalizedText( zh="资产回购 (自留)", en="Buy back a device (keep it)", ja="デバイスの買い取り(引き続きご利用)", ), value="/my/requests", hint=LocalizedText( zh="提交「离职设备回购」自助申请", en=( "Submit a self-service “device buyback” " "request" ), ja="「退職時デバイス買い取り」の申請をご提出ください", ), ), ], card_sections=[ CardSection( title=LocalizedText( zh="你的三个选项 (每件资产可任选其一)", en="Your three options (choose one per asset)", ja="3つの選択肢(資産ごとにお選びいただけます)", ), body=LocalizedText( zh=( "1) [b]归还[/b] — 公司资产 (笔记本 / 显示器 / 配件) " "线下交回办公室,或在 /my/offboarding 标记为已归还\n" "2) [b]回购[/b] — 想留下设备?在 /my/requests 提交" "「离职设备回购」申请,系统按折旧自动报价\n" "3) [b]不处理[/b] — 离职日后资产自动标记为" "「离职持有」,继续在你手上,IT 会跟进" ), en=( "1) [b]Return[/b] — bring company assets " "(laptop / monitor / accessories) back to the " "office, or mark them as returned in " "/my/offboarding\n" "2) [b]Buy back[/b] — would you like to keep a " "device? Submit a “device buyback” request in " "/my/requests and the system quotes a " "depreciated price automatically\n" "3) [b]Do nothing[/b] — after your last day the " "assets are marked “held by leaver”, stay with " "you, and IT will follow up" ), ja=( "1) [b]返却[/b] — 会社の資産(ノートPC / モニター / " "周辺機器)をオフィスへお返しいただくか、" "/my/offboarding で返却済みとしてご登録ください\n" "2) [b]買い取り[/b] — デバイスをそのままご利用に" "なりたい場合は、/my/requests から「退職時デバイス" "買い取り」を申請してください。減価償却に基づいた" "金額が自動で提示されます\n" "3) [b]そのままにする[/b] — 退職日以降、資産は" "「退職者保有」として記録され、お手元に残ります。" "IT 部門より改めてご連絡いたします" ), ), tone="info", ), CardSection( title=LocalizedText( zh="同时请完成", en="Please also complete", ja="あわせてお願いしたいこと", ), body=LocalizedText( zh=( "· 数据移交 (团队文档 / 项目权限)\n" "· 退出已加入的群 / 邮箱组 / SaaS 共享账号" ), en=( "· Hand over your data (team documents / " "project access)\n" "· Leave the chat groups, mailing lists and " "shared SaaS accounts you belong to" ), ja=( "・データの引き継ぎ(チームのドキュメント / " "プロジェクトのアクセス権)\n" "・参加中のチャットグループ / メーリングリスト / " "共有 SaaS アカウントからの退出" ), ), tone="info", ), ], related_resource_type="offboarding_process", related_resource_id=process_id, ) # SAVEPOINT-isolate (2026-08-06): OffboardingReminderLog carries a # UNIQUE(process_id, reminder_kind); the concurrent double-fire # that constraint exists to stop is exactly what used to poison # the transaction — and then the MANAGER reminder below never # went out either. async with best_effort( self.db, label=f"offboarding leaver reminder {leaver_kind}", log=logger, ) as leaver_step: res = await notif.dispatch(env_leaver, trace_id=trace_id) leaver_outcome = { "delivered": res.delivered_channels, "feishu_ok": res.feishu_ok, "email_ok": res.email_ok, } # Only stamp the dedup marker if something was actually # delivered. ``dispatch`` "never raises" (contract), so a # total Feishu+email failure returns normally with empty # ``delivered_channels`` — writing the marker anyway would # permanently suppress the Helix-level re-send of this # reminder-kind. Mirrors the sibling guard in # ``offboarding/escalation.py`` (CR-314). if res.delivered_channels: self.db.add(OffboardingReminderLog( process_id=process_id, reminder_kind=leaver_kind, channels=res.delivered_channels, detail={"recipient": str(person.id)}, )) await self.db.flush() else: logger.warning( "process_started_leaver: no channel delivered for " "%s — skipping dedup marker so a later tick retries", person.work_email, ) if not leaver_step.ok: logger.warning( "process_started_leaver notify failed for %s: %s", person.work_email, leaver_step.reason(), ) # Round 3 P2 fix (R3-001): redact raw exception # message to avoid leaking credential fragments / # SMTP server URL / Feishu open_id payload through # the admin response surface. leaver_outcome = {"error": leaver_step.reason(max_len=200)} # ── 2) Manager notification ── if ( manager_kind not in already_sent and person.manager_person_id is not None ): manager = await self.db.get(IdentityPerson, person.manager_person_id) if ( manager is not None and manager.deleted_at is None and manager.employment_status == "active" ): env_mgr = NotificationEnvelope( recipient=manager, title=( f"📤 团队成员离职 — {person.display_name or person.work_email}" ), body_text=( f"你的下属 [b]{person.display_name}[/b]({person.work_email})" f"的离职流程已启动。请在离职日前与 TA 确认资产 / 权限去向" " —— 如未处理,资产会自动标记为「离职持有」(留在该员工" "手上,IT 会同步收到通知)。" ), channels=["feishu", "email"], template_key="offboarding.process_started_manager", context={ "manager_name": manager.display_name or manager.work_email, "leaver_name": ( person.display_name or person.work_email ), "leaver_email": person.work_email, "leaver_date": leaver_date_str, # Localizable — see the leaver envelope above. "cta_line": LocalizedText( zh="请关注下属的离职交接。", en=( "Please keep an eye on your team member's " "handover." ), ja="部下の引き継ぎ状況をご確認ください。", ), }, card_subtitle=LocalizedText( zh=f"预计离职日:{leaver_date_str}({days_zh})", en=( f"Expected last day: {leaver_date_str} " f"({days_en})" ), ja=f"退職予定日:{leaver_date_str}({days_ja})", ), card_stats=[ CardStat( label=LocalizedText( zh="离职员工", en="Departing employee", ja="退職する社員", ), value=person.display_name or person.work_email or "—", hint=person.work_email, ), CardStat( label=LocalizedText( zh="预计离职日", en="Expected last day", ja="退職予定日", ), value=leaver_date_str, ), CardStat( label=LocalizedText( zh="距今", en="Time remaining", ja="残り期間", ), value=days_remaining_text, ), CardStat( label=LocalizedText( zh="你需要关注的", en="What needs your attention", ja="ご確認いただきたいこと", ), value=LocalizedText( zh="资产 / 权限交接", en="Asset & access handover", ja="資産・アクセス権限の引き継ぎ", ), hint=LocalizedText( zh="督促下属在离职日前完成", en=( "Make sure they finish before their last " "day" ), ja="退職日までに完了するようフォローをお願いします", ), ), ], card_sections=[ CardSection( title=LocalizedText( zh="兜底机制", en="Safety net", ja="フォールバックの仕組み", ), body=LocalizedText( zh=( "未在离职日前处理的资产会自动保留在离职员工" "名下 (held_by_leaver) 并通知 IT 跟进;离职" "流程本身不会被资产卡住。" ), en=( "Any asset not handled before the last day " "automatically stays under the departing " "employee's name (held_by_leaver) and IT is " "notified to follow up; the offboarding " "process itself is never blocked by assets." ), ja=( "退職日までに処理されなかった資産は、自動的に" "退職者の名義のまま(held_by_leaver)となり、" "IT 部門に通知されてフォローアップされます。" "資産が原因で退職手続きが止まることはありません。" ), ), tone="warn", ), ], related_resource_type="offboarding_process", related_resource_id=process_id, ) # SAVEPOINT-isolate — same UNIQUE-constraint race as the # leaver reminder above. async with best_effort( self.db, label=f"offboarding manager reminder {manager_kind}", log=logger, ) as manager_step: res = await notif.dispatch(env_mgr, trace_id=trace_id) manager_outcome = { "manager_id": str(manager.id), "delivered": res.delivered_channels, "feishu_ok": res.feishu_ok, "email_ok": res.email_ok, } # Only stamp the dedup marker on real delivery — see the # leaver block above (dispatch never raises, so an empty # delivered_channels means total failure and the marker # must NOT permanently suppress the re-send). if res.delivered_channels: self.db.add(OffboardingReminderLog( process_id=process_id, reminder_kind=manager_kind, channels=res.delivered_channels, detail={ "recipient": str(manager.id), "leaver": str(person.id), }, )) await self.db.flush() else: logger.warning( "process_started_manager: no channel delivered " "for manager=%s leaver=%s — skipping dedup marker", manager.work_email, person.work_email, ) if not manager_step.ok: logger.warning( "process_started_manager notify failed for " "manager=%s leaver=%s: %s", manager.work_email, person.work_email, manager_step.reason(), ) # Round 3 P2 fix (R3-001): same redaction. manager_outcome = {"error": manager_step.reason(max_len=200)} # ── 3) Owner-notify for un-integrated software (owner_notify_manual) ── # Reached only AFTER the retroactive-open / future-leaver / cancelled # / active guards above have all passed, so owner notifications share # the exact same "genuine, current leaver" gating as the leaver / # manager pings and never fire for backfilled or future-dated rows. try: await self._notify_manual_close_owners( person=person, process_id=process_id, leaver_date_str=leaver_date_str, days_remaining_text=days_remaining_text, trace_id=trace_id, ) except Exception as exc: # noqa: BLE001 — never blocks the leaver flow logger.warning( "manual_close owner-notify failed for leaver %s: %s", person.work_email, exc, ) return leaver_outcome, manager_outcome async def _notify_manual_close_owners( self, *, person: IdentityPerson, process_id: UUID, leaver_date_str: str, days_remaining_text: str | LocalizedText, trace_id: str, ) -> None: """Ping the owner of every ``owner_notify_manual`` software when a leaver in its configured BU scope departs. Some systems have no Helix connector and no seat sync — a shared org console like TiDB Cloud Org — so the platform cannot deprovision the leaver's account itself. Instead we notify the software's ``owner_person_id`` (Feishu + email) to close the account by hand. BU scope lives in ``asset_software_catalog.offboarding_config`` under ``notify_bus`` (a list of BU names). Empty / missing == notify on every leaver; otherwise only when the leaver's primary-membership BU (``app.identity.bu_helpers``) matches one of the listed names, case-insensitively. Idempotent: a single ``OffboardingReminderLog`` row keyed (process_id, ``owner_manual_close``) guards against re-sends when the dispatcher fires again (admin re-notify / T+0 catch-up). All matching owners are notified in the first pass, then the marker is stamped. """ from app.domains.offboarding.models import ( # noqa: PLC0415 OffboardingReminderLog, ) from app.identity.bu_helpers import bu_for_user_ids # noqa: PLC0415 from app.notifications.service import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) owner_kind = "owner_manual_close" already = ( await self.db.execute( select(OffboardingReminderLog.id).where( OffboardingReminderLog.process_id == process_id, OffboardingReminderLog.reminder_kind == owner_kind, ) ) ).first() if already is not None: return # Which BU does this leaver belong to? Absent == unattributed; those # leavers only match softwares with an empty (all-BU) scope. leaver_bu = ( await bu_for_user_ids(self.db, [person.id]) ).get(str(person.id)) sw_rows = ( await self.db.execute( select(AssetSoftwareCatalog).where( AssetSoftwareCatalog.offboarding_policy == POLICY_OWNER_NOTIFY_MANUAL, AssetSoftwareCatalog.status == "active", ) ) ).scalars().all() if not sw_rows: return leaver_bu_lc = leaver_bu.lower() if leaver_bu else None def _in_scope(sw: AssetSoftwareCatalog) -> bool: cfg = sw.offboarding_config or {} raw = cfg.get("notify_bus") if isinstance(cfg, dict) else None if not raw: return True # no scope == every leaver scope = {str(b).strip().lower() for b in raw if str(b).strip()} if not scope: return True return leaver_bu_lc is not None and leaver_bu_lc in scope matched = [sw for sw in sw_rows if _in_scope(sw)] if not matched: return notif = NotificationService(self.db) from app.notifications.service import ( # noqa: PLC0415 CardSection, CardStat, LocalizedText, ) # Per-language fragments of the "time remaining" value for the # subtitle f-strings below — see the sibling split in the caller. days_zh, days_en, days_ja = ( ( days_remaining_text.zh, days_remaining_text.en, days_remaining_text.ja, ) if isinstance(days_remaining_text, LocalizedText) else (days_remaining_text, days_remaining_text, days_remaining_text) ) notified: list[dict[str, str]] = [] delivered_any = False leaver_label = person.display_name or person.work_email or "(未知)" for sw in matched: if sw.owner_person_id is None: continue # Don't notify the leaver about their own software (edge: the # departing person happens to own an owner_notify_manual row). if sw.owner_person_id == person.id: continue owner = await self.db.get(IdentityPerson, sw.owner_person_id) if ( owner is None or owner.deleted_at is not None or owner.employment_status == "left" ): continue env = NotificationEnvelope( recipient=owner, title=f"⚠️ 有员工离职 — 请手动关闭「{sw.name}」账号", body_text=( f"[b]{leaver_label}[/b]({person.work_email})已离职," f"其在「{sw.name}」的账号需要你作为负责人[b]手动关闭[/b]" f"——该系统未接入 Helix 自动化,平台无法代为回收。" f"请尽快登录该系统停用 / 删除对应账号。" ), channels=["feishu", "email"], template_key="offboarding.owner_manual_close", context={ "owner_name": owner.display_name or owner.work_email, "software_name": sw.name, "leaver_name": leaver_label, "leaver_email": person.work_email, "leaver_date": leaver_date_str, # No ``cta_line`` override here any more: it used to carry a # hardcoded Chinese sentence that an EN/JA owner would still # receive verbatim. The registered三语 template # (``notifications/templates/lifecycle.py``) now states the # "sign in to that system and close the account" action in # the recipient's own language, and deliberately omits # {cta_line}/{portal_url} — this envelope's ``url`` is the # vendor login page (possibly None), so a generic # "open in Helix" CTA would point at the wrong place. }, card_subtitle=LocalizedText( zh=( f"离职员工:{leaver_label}({leaver_date_str}," f"{days_zh})" ), en=( f"Departing employee: {leaver_label} " f"({leaver_date_str}, {days_en})" ), ja=( f"退職する社員:{leaver_label}" f"({leaver_date_str}、{days_ja})" ), ), card_stats=[ CardStat( label=LocalizedText( zh="离职员工", en="Departing employee", ja="退職する社員", ), value=leaver_label, hint=person.work_email, ), CardStat( label=LocalizedText( zh="所属部门", en="Department", ja="所属部門", ), value=leaver_bu or "—", ), CardStat( label=LocalizedText( zh="离职日", en="Last day", ja="退職日", ), value=leaver_date_str, ), CardStat( label=LocalizedText( zh="需要你做的", en="What we need from you", ja="ご対応いただきたいこと", ), value=LocalizedText( zh=f"手动关闭「{sw.name}」账号", en=f"Manually close the {sw.name} account", ja=f"「{sw.name}」のアカウントを手動で無効化", ), hint=LocalizedText( zh="该系统未接入 Helix,平台不会自动回收", en=( "This system is not integrated with Helix, so " "the platform cannot revoke it automatically" ), ja=( "このシステムは Helix と連携していないため、" "プラットフォームからは自動で削除できません" ), ), ), ], card_sections=[ CardSection( title=LocalizedText( zh="为什么收到这条通知", en="Why you are receiving this", ja="この通知が届いた理由", ), body=LocalizedText( zh=( f"你是「{sw.name}」在 Helix 登记的负责人。" "这类系统未接入自动化 offboarding,所以每当" "相关部门有人离职,平台会提醒你手动清理其账号。" ), en=( f"You are the owner of {sw.name} registered in " "Helix. Systems like this are not part of " "automated offboarding, so whenever someone in " "the relevant department leaves, the platform " "reminds you to clean up their account by hand." ), ja=( f"あなたは Helix に登録されている「{sw.name}」の" "管理担当者です。このようなシステムは自動" "オフボーディングの対象外のため、対象部門で" "退職者が出るたびに、アカウントの手動削除を" "お願いする通知をお送りしています。" ), ), tone="warn", ), ], url=f"{sw.login_url}" if sw.login_url else None, related_resource_type="asset_software_catalog", related_resource_id=sw.id, ) try: res = await notif.dispatch(env, trace_id=trace_id) if res.delivered_channels: delivered_any = True notified.append({ "software_id": str(sw.id), "software_name": sw.name, "owner_id": str(owner.id), "delivered": ",".join(res.delivered_channels), }) except Exception as exc: # noqa: BLE001 logger.warning( "owner_manual_close notify failed for software=%s " "owner=%s leaver=%s: %s", sw.name, owner.work_email, person.work_email, exc, ) await self.audit.log( trace_id=trace_id, actor_type="system", actor_user_id=None, action="offboarding.owner_manual_close_notified", status="success", resource_type="offboarding_process", resource_id=str(process_id), detail={ "leaver_id": str(person.id), "leaver_bu": leaver_bu, "notified": notified, }, ) # Stamp the dedupe marker only when at least one owner was actually # reached — a total delivery failure must not permanently suppress # a later re-notify (mirrors the leaver / manager blocks above). If # nobody matched-with-a-valid-owner, ``notified`` is empty and we # also skip the marker so a config fix + re-notify can still fire. if delivered_any: self.db.add(OffboardingReminderLog( process_id=process_id, reminder_kind=owner_kind, channels=["feishu", "email"], detail={"notified": notified}, )) await self.db.flush() # ───────────────────────── Dry-run preview ───────────────────────── async def get_offboarding_preview( self, person_id: UUID, ) -> dict[str, Any]: """Read-only scan that mirrors ``_rescan`` without persisting. D-1 fix (2026-05-08): operators previously had no way to see what an offboarding flow would touch before clicking "Mark as Leaver" — the only way to find out was to actually start the process (which is irreversible-ish: force-logout fires, audit rows are written, blocker rows accumulate). The frontend now calls this preview before opening the confirm modal so the operator sees the exact resource list they're about to commit to. Returns a structured dict consumed by ``OffboardingPreviewResponse``: * person: leaver summary (name, email, manager) * blockers: per-resource preview rows with policy + suggested successor + reason if successor can't be auto-picked * summary: counts by resource type and policy + flags (force_logout_will_fire, must_pick_successor_count) Side effects: NONE. Read-only SELECTs. """ from app.core.coordinators.provisioning_coordinator import ( # noqa: PLC0415 resolve_revoke_requires_successor, ) target = await self.db.get(IdentityPerson, person_id) if target is None or target.deleted_at is not None: raise AppException( code="PERSON_NOT_FOUND", message=f"Person {person_id} not found", status_code=404, ) # Existing process check — operator wants to know if there's # already a flow in progress before triggering a new one. existing_proc = ( await self.db.execute( select(OffboardingProcess).where( OffboardingProcess.person_id == person_id, ) ) ).scalar_one_or_none() manager_summary: dict[str, Any] | None = None if target.manager_person_id is not None: manager = await self.db.get(IdentityPerson, target.manager_person_id) if manager is not None: manager_summary = { "id": str(manager.id), "name": manager.display_name or manager.work_email, "email": manager.work_email, "is_active": ( manager.employment_status == "active" and manager.deleted_at is None ), } # Re-run the SELECT half of ``_rescan``. We don't call ``_rescan`` # itself because that persists rows. Mirroring keeps the preview # in lockstep with reality — the "Note for maintainers" below # spells out the contract. # # Note for maintainers: when adding a new resource scan to # ``_rescan``, mirror the SELECT here so preview stays accurate. # Tests assert preview row count == post-start blocker count for # representative leavers; mismatch surfaces as a regression. from app.domains.asset.models import ( # noqa: PLC0415 ASSET_IN_USE_STATUSES, ) blockers_preview: list[dict[str, Any]] = [] # Hardware hw_rows = ( await self.db.execute( select(AssetItem).where( AssetItem.deleted_at.is_(None), or_( and_( AssetItem.assigned_to == person_id, AssetItem.status.in_(ASSET_IN_USE_STATUSES), ), and_( AssetItem.borrowed_by == person_id, AssetItem.status.in_({ "borrowed", "borrow_pending", "borrow_return_pending", }), ), ), ) ) ).scalars().all() for asset in hw_rows: label_parts = [ asset.asset_tag or "", asset.brand or "", asset.model or "", ] label = " / ".join(p for p in label_parts if p) or str(asset.id) blockers_preview.append({ "resource_type": RES_HARDWARE, "resource_id": str(asset.id), "label": label, "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "硬件需要管理员发起转交", }) # Software seats seat_rows = ( await self.db.execute( select(SoftwareSeatAllocation, SoftwareLicensePlan, AssetSoftwareCatalog) .join( SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id, ) .join( AssetSoftwareCatalog, SoftwareLicensePlan.software_id == AssetSoftwareCatalog.id, ) .where( and_( SoftwareSeatAllocation.user_id == person_id, SoftwareSeatAllocation.status.in_(["active", "pending"]), ) ) ) ).all() # Same MDM gate as the real scan. A preview that promises a # 「Jamf 坐席」 blocker the scan then refuses to raise is worse than # no preview: it re-teaches the belief this change removed, and it # does it to the person about to run the offboarding. from app.core.coordinators.jamf_seat_coordinator import ( mdm_managed_software_ids, ) preview_mdm_software_ids = await mdm_managed_software_ids(self.db) for seat, plan, sw in seat_rows: if sw.id in preview_mdm_software_ids: continue policy = sw.offboarding_policy or POLICY_SSO_CASCADE needs_successor = False auto_id: str | None = None auto_email: str | None = None auto_reason = "" if policy == POLICY_AUTO_REVOKE: ns, _ = await resolve_revoke_requires_successor( self.db, software_id=sw.id, ) needs_successor = ns if ns: n_needs, sid, semail, sreason = ( await self._auto_successor_for_seat( person_id=person_id, software_id=sw.id, ) ) if sid is not None: auto_id = str(sid) auto_email = semail auto_reason = sreason blockers_preview.append({ "resource_type": RES_SOFTWARE_SEAT, "resource_id": str(seat.id), "label": f"{sw.name} — {plan.name}", "software_id": str(sw.id), "software_name": sw.name, "policy": policy, "needs_successor": needs_successor, "auto_successor_id": auto_id, "auto_successor_email": auto_email, "auto_successor_reason": auto_reason, }) # Software permissions try: from app.domains.permission.models import ( # noqa: PLC0415 PERM_ACTIVE, PERM_PENDING, SoftwarePermission, UserPermission, ) perm_rows = ( await self.db.execute( select(UserPermission, SoftwarePermission, AssetSoftwareCatalog) .join( SoftwarePermission, UserPermission.permission_id == SoftwarePermission.id, ) .join( AssetSoftwareCatalog, SoftwarePermission.software_id == AssetSoftwareCatalog.id, ) .where( and_( UserPermission.user_id == person_id, UserPermission.status.in_([PERM_ACTIVE, PERM_PENDING]), ) ) ) ).all() for up, perm, sw in perm_rows: policy = sw.offboarding_policy or POLICY_SSO_CASCADE blockers_preview.append({ "resource_type": RES_SOFTWARE_PERMISSION, "resource_id": str(up.id), "label": f"{sw.name} — {perm.display_name}", "software_id": str(sw.id), "software_name": sw.name, "policy": policy, "needs_successor": False, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "", }) except ImportError: pass # API keys ak_rows = ( await self.db.execute( select(ApiKeyAssignment).where( and_( ApiKeyAssignment.assigned_to == person_id, ApiKeyAssignment.status.in_( ["active", "approved", "pending_approval"], ), ) ) ) ).scalars().all() # API keys are NEVER revoked at offboarding — they auto-transfer # to a successor at T+0 (escalation.MANAGER_AUTO_TRANSFER_RESOURCE_TYPES): # default_handover_person → leaver's manager → IT pool fallback, # via an auto-opened AssetTransferRequest. The key stays active # (ApiKeyService.apply_transfer never touches ``status``). So the # preview must NOT show a scary blank / "needs manual revoke" — it # shows the prospective recipient. default_handover isn't set yet # at preview time (no process exists), so we surface the active # manager as the auto-pick, matching what the modal already shows. ak_auto_id: str | None = None ak_auto_email: str | None = None if manager_summary is not None and manager_summary.get("is_active"): ak_auto_id = manager_summary["id"] ak_auto_email = manager_summary["email"] ak_reason = "离职日(T+0)自动转交给经理/默认接手人,密钥保持有效,不会被吊销" for ak in ak_rows: blockers_preview.append({ "resource_type": RES_API_KEY, "resource_id": str(ak.id), "label": f"API Key {ak.request_number} — {ak.project_name}", "software_id": None, "software_name": None, "policy": "transfer", # Not a "must-pick" — it always auto-resolves a recipient, # so it must not inflate must_pick_successor_count. The # reason carries the disposition for the frontend info tag. "needs_successor": False, "auto_successor_id": ak_auto_id, "auto_successor_email": ak_auto_email, "auto_successor_reason": ak_reason, }) # CMDB domains / DNS / servers / LBs / CDN (always-transfer). # Unrolled rather than looped over a heterogeneous (model, # res_type, label_fn) list so mypy can type-check each block — # the loop form forced ``model`` to a common ``type[Base]`` # upper bound that doesn't carry the per-model column # attributes. # LB + CDN were added 2026-05-18 (PR #346) — migration 173 / # 160 gave both tables ``owner_person_id`` FKs but they were # never plugged into the offboarding force-transfer. from app.domains.cmdb.models import ( # noqa: PLC0415 CmdbCdnDomain, CmdbDnsRecord, CmdbDomain, CmdbLoadBalancer, CmdbServer, ) person_id_str = str(person_id) cmdb_domain_rows = ( await self.db.execute( select(CmdbDomain).where( CmdbDomain.owner_person_id == person_id_str, CmdbDomain.deleted_at.is_(None), ) ) ).scalars().all() for dom in cmdb_domain_rows: label = dom.domain_name or str(dom.id) if dom.registrar: label = f"{label} @ {dom.registrar}" blockers_preview.append({ "resource_type": RES_DOMAIN, "resource_id": str(dom.id), "label": label, "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) cmdb_dns_rows = ( await self.db.execute( select(CmdbDnsRecord).where( CmdbDnsRecord.owner_person_id == person_id_str, CmdbDnsRecord.deleted_at.is_(None), ) ) ).scalars().all() for rec in cmdb_dns_rows: head = rec.name or rec.zone_name or "" if rec.name and rec.zone_name and not rec.name.endswith(rec.zone_name): head = f"{rec.name}.{rec.zone_name}" label = f"{head} {rec.record_type}".strip() blockers_preview.append({ "resource_type": RES_DNS_RECORD, "resource_id": str(rec.id), "label": label or str(rec.id), "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) cmdb_server_rows = ( await self.db.execute( select(CmdbServer).where( CmdbServer.owner_person_id == person_id_str, CmdbServer.deleted_at.is_(None), ) ) ).scalars().all() for srv in cmdb_server_rows: label_bits = [srv.name, srv.instance_id] if srv.region: label_bits.append(srv.region) label = " / ".join(p for p in label_bits if p) or str(srv.id) blockers_preview.append({ "resource_type": RES_CLOUD_SERVER, "resource_id": str(srv.id), "label": label, "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) cmdb_lb_rows = ( await self.db.execute( select(CmdbLoadBalancer).where( CmdbLoadBalancer.owner_person_id == person_id_str, CmdbLoadBalancer.deleted_at.is_(None), ) ) ).scalars().all() for lb in cmdb_lb_rows: label_bits = [lb.name, lb.lb_id, lb.region] label = " / ".join(p for p in label_bits if p) or str(lb.id) blockers_preview.append({ "resource_type": RES_LOAD_BALANCER, "resource_id": str(lb.id), "label": label, "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) cmdb_cdn_rows = ( await self.db.execute( select(CmdbCdnDomain).where( CmdbCdnDomain.owner_person_id == person_id_str, CmdbCdnDomain.deleted_at.is_(None), ) ) ).scalars().all() for cdn in cmdb_cdn_rows: label = cdn.domain_name or str(cdn.id) blockers_preview.append({ "resource_type": RES_CDN_DOMAIN, "resource_id": str(cdn.id), "label": label, "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) # Object storage buckets + manual certificate owners (PR3) from app.domains.cmdb.models import ( # noqa: PLC0415 CmdbCertificate as _CmdbCertificate, ) from app.domains.cmdb.models import ( # noqa: PLC0415 CmdbObjectStorage as _CmdbObjectStorage, ) from app.domains.offboarding.models import ( # noqa: PLC0415 RES_CERTIFICATE as _RES_CERTIFICATE, ) from app.domains.offboarding.models import ( # noqa: PLC0415 RES_EMAIL_GROUP_MEMBER as _RES_EMAIL_GROUP_MEMBER, ) from app.domains.offboarding.models import ( # noqa: PLC0415 RES_OBJECT_STORAGE as _RES_OBJECT_STORAGE, ) oss_preview_rows = ( await self.db.execute( select(_CmdbObjectStorage).where( _CmdbObjectStorage.owner_person_id == person_id_str, _CmdbObjectStorage.deleted_at.is_(None), ) ) ).scalars().all() for oss in oss_preview_rows: blockers_preview.append({ "resource_type": _RES_OBJECT_STORAGE, "resource_id": str(oss.id), "label": f"对象存储桶: {oss.bucket_name}", "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) cert_preview_rows = ( await self.db.execute( select(_CmdbCertificate).where( _CmdbCertificate.owner_person_id == person_id_str, _CmdbCertificate.owner_is_manual.is_(True), _CmdbCertificate.deleted_at.is_(None), ) ) ).scalars().all() for cert in cert_preview_rows: blockers_preview.append({ "resource_type": _RES_CERTIFICATE, "resource_id": str(cert.id), "label": f"证书负责人: {cert.common_name or cert.id}", "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) # Mail-group MEMBERSHIP (auto-revoke at source, no successor). The # preview reads the vendor like the scanner does; an unreadable # roster leaves the family out of the preview rather than guessing. from app.domains.email_group.membership_scan import ( # noqa: PLC0415 static_group_memberships_for_person as _membership_scan, ) preview_person = await self.db.get(IdentityPerson, person_id) if preview_person is not None: _owned_ids = {g.id for g in await self._scan_email_groups(person_id)} _membership = await _membership_scan(self.db, preview_person) for group in _membership.groups: if group.id in _owned_ids: continue blockers_preview.append({ "resource_type": _RES_EMAIL_GROUP_MEMBER, "resource_id": str(group.id), "label": f"邮件组成员: {group.email}", "software_id": None, "software_name": None, "policy": "auto_revoke", "needs_successor": False, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "", }) # Software catalog ownership for fk_col, res_type, label_prefix in [ ( AssetSoftwareCatalog.owner_person_id, RES_SOFTWARE_OWNER, "软件负责人", ), ( AssetSoftwareCatalog.it_contact_person_id, RES_SOFTWARE_IT_CONTACT, "软件 IT 负责人", ), ]: rows = ( await self.db.execute( select(AssetSoftwareCatalog).where(fk_col == person_id) ) ).scalars().all() for sw in rows: blockers_preview.append({ "resource_type": res_type, "resource_id": str(sw.id), "label": f"{label_prefix}: {sw.name}", "software_id": str(sw.id), "software_name": sw.name, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) # Email groups email_groups = await self._scan_email_groups(person_id) for g in email_groups: blockers_preview.append({ "resource_type": RES_EMAIL_GROUP, "resource_id": str(g.id), "label": f"{g.name} <{g.email}>", "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "Google 要求 ≥1 owner,需管理员指定", }) # Cloud scopes + access grants from app.domains.cmdb.models import ( # noqa: PLC0415 GRANT_STATUS_ACTIVE, GRANT_STATUS_PENDING, CmdbCloudAccessGrant, CmdbCloudScope, owner_governed_scope_criteria, ) from app.domains.offboarding.models import ( # noqa: PLC0415 RES_CLOUD_ACCESS_GRANT, RES_CLOUD_SCOPE_OWNER, ) # Same predicate as the blocker scanner — a preview that names a # blocker the process won't create (or vice versa) is worse than # no preview. Pinned by a contract test over both call sites. scope_rows = ( await self.db.execute( select(CmdbCloudScope).where( CmdbCloudScope.owner_person_id == str(person_id), *owner_governed_scope_criteria(), ) ) ).scalars().all() for s in scope_rows: blockers_preview.append({ "resource_type": RES_CLOUD_SCOPE_OWNER, "resource_id": str(s.id), "label": ( f"Scope 负责人: {s.display_name or s.external_id} " f"({s.scope_type})" ), "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) grant_rows = ( await self.db.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.person_id == str(person_id), CmdbCloudAccessGrant.status.in_( (GRANT_STATUS_ACTIVE, GRANT_STATUS_PENDING), ), ) ) ).scalars().all() for g in grant_rows: blockers_preview.append({ "resource_type": RES_CLOUD_ACCESS_GRANT, "resource_id": str(g.id), "label": ( f"云访问 grant: {g.role_display_name or g.role_key} " f"({g.status})" ), "software_id": None, "software_name": None, "policy": "auto_revoke", "needs_successor": False, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "", }) # SSO grants + domain registrations + Google orphans from app.domains.offboarding.models import ( # noqa: PLC0415 RES_DOMAIN_REGISTRATION, RES_GOOGLE_ORPHAN_OWNER, RES_SSO_GRANT, ) from app.domains.sso.models import ( # noqa: PLC0415 SSO_GRANT_STATUS_APPROVED, SsoGrant, ) sso_rows = ( await self.db.execute( select(SsoGrant).where( SsoGrant.person_id == str(person_id), SsoGrant.status == SSO_GRANT_STATUS_APPROVED, ) ) ).scalars().all() for g in sso_rows: blockers_preview.append({ "resource_type": RES_SSO_GRANT, "resource_id": str(g.id), "label": f"SSO 授权: {g.sso_provider} · {g.system_name}", "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) # GitHub 组织成员资格 —— 与 ``_rescan`` 的那一段逐条对齐,包括 # 「exempt 的组织不入队」这条豁免。少了这一行,预览会告诉运维 # 「会移出 4 个组织」而实际只移出 3 个 —— 而这个页面存在的全部 # 理由就是让人在按下按钮之前看到会发生什么。 from app.domains.github_org.constants import ( # noqa: PLC0415 MEMBERSHIP_LIVE_STATES, OFFBOARDING_POLICY_EXEMPT, ) from app.domains.github_org.models import ( # noqa: PLC0415 GithubOrg, GithubOrgMembership, ) from app.domains.offboarding.models import ( # noqa: PLC0415 RES_GITHUB_ORG_MEMBERSHIP, ) gh_preview_rows = ( await self.db.execute( select(GithubOrgMembership, GithubOrg) .join(GithubOrg, GithubOrg.id == GithubOrgMembership.org_id) .where( GithubOrgMembership.person_id == str(person_id), GithubOrgMembership.state.in_(MEMBERSHIP_LIVE_STATES), GithubOrg.deleted_at.is_(None), ) ) ).all() for membership, org in gh_preview_rows: if org.offboarding_policy == OFFBOARDING_POLICY_EXEMPT: continue blockers_preview.append({ "resource_type": RES_GITHUB_ORG_MEMBERSHIP, "resource_id": str(membership.id), "label": ( f"GitHub 组织: {org.slug} · {membership.github_login}" ), "software_id": None, "software_name": None, # 人绑权限,在源头撤销 —— 没有接班人这回事。 "policy": "revoke", "needs_successor": False, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": None, }) from app.domains.domain_registration.models import ( # noqa: PLC0415 DOMAIN_REG_STATUS_APPROVED, DomainRegistration, ) dr_rows = ( await self.db.execute( select(DomainRegistration).where( DomainRegistration.person_id == str(person_id), DomainRegistration.status == DOMAIN_REG_STATUS_APPROVED, ) ) ).scalars().all() for r in dr_rows: blockers_preview.append({ "resource_type": RES_DOMAIN_REGISTRATION, "resource_id": str(r.id), "label": f"已注册域名: {r.domain_name}", "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) # Internal system registry ownership (added 2026-08-25). Unlike # SSO / domain registration, this preview CAN name a successor: # the registry records a backup owner for exactly this moment. from app.core.coordinators import ( # noqa: PLC0415 system_registry_coordinator as _registry_preview, ) from app.domains.offboarding.models import ( # noqa: PLC0415 RES_SYSTEM_OWNER, ) for owned in await _registry_preview.systems_owned_by( self.db, person_id, ): backup_id = owned["backup_owner_person_id"] backup = ( await self.db.get(IdentityPerson, UUID(backup_id)) if backup_id and owned["is_primary_owner"] else None ) usable_backup = ( backup is not None and backup.deleted_at is None and backup.employment_status == "active" and backup.id != person_id ) role = "负责人" if owned["is_primary_owner"] else "备份负责人" blockers_preview.append({ "resource_type": RES_SYSTEM_OWNER, "resource_id": owned["id"], "label": f"系统{role}: {owned['name']}", "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": ( str(backup.id) if usable_backup and backup else None ), "auto_successor_email": ( backup.work_email if usable_backup and backup else None ), "auto_successor_reason": ( "台账已登记备份负责人" if usable_backup else "需管理员指定接班人" ), }) from app.domains.google_workspace.models import ( # noqa: PLC0415 GoogleOrphanAccount, ) orphan_rows = ( await self.db.execute( select(GoogleOrphanAccount).where( GoogleOrphanAccount.owner_user_id == person_id, # Mirror ``_rescan`` (Round-49): a tombstoned orphan # row must not be previewed as a blocker — preview and # the real scan must report the same count. GoogleOrphanAccount.deleted_at.is_(None), ) ) ).scalars().all() for o in orphan_rows: blockers_preview.append({ "resource_type": RES_GOOGLE_ORPHAN_OWNER, "resource_id": str(o.id), "label": ( f"Google 共用账号负责人: {o.primary_email}" + (f" ({o.purpose})" if o.purpose else "") ), "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) # Identity-consumer ownership + cloud IAM-user ownership. # The real scan (see ``_rescan``) creates blockers for both, so the # preview MUST surface them too — otherwise the preview count is lower # than the blockers that actually materialize (same invariant the # Google-orphan ``deleted_at`` guard above enforces). from app.domains.cmdb.models import CmdbIamUser # noqa: PLC0415 from app.domains.offboarding.models import ( # noqa: PLC0415 RES_CLOUD_IAM_USER, RES_IDENTITY_CONSUMER_OWNER, ) from app.identity.consumers.models import ( # noqa: PLC0415 IdentityConsumer, ) consumer_rows = ( await self.db.execute( select(IdentityConsumer).where( IdentityConsumer.owner_person_id == person_id, IdentityConsumer.deleted_at.is_(None), ) ) ).scalars().all() for consumer in consumer_rows: blockers_preview.append({ "resource_type": RES_IDENTITY_CONSUMER_OWNER, "resource_id": str(consumer.id), "label": f"身份消费方负责人: {consumer.name} ({consumer.slug})", "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人,否则 T+0 自动停用", }) iam_user_rows = ( await self.db.execute( select(CmdbIamUser).where( CmdbIamUser.owner_person_id == str(person_id), CmdbIamUser.deleted_at.is_(None), CmdbIamUser.revoked_at.is_(None), ) ) ).scalars().all() for iam in iam_user_rows: blockers_preview.append({ "resource_type": RES_CLOUD_IAM_USER, "resource_id": str(iam.id), "label": f"IAM 用户: {iam.user_name or iam.arn or str(iam.id)}", "software_id": None, "software_name": None, "policy": "transfer", "needs_successor": True, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "需管理员指定接班人", }) # VPN + AI tools (auto-revoke, no successor) from app.domains.offboarding.models import ( # noqa: PLC0415 RES_AI_TOOL_APPLICATION, RES_VPN_APPLICATION, ) from app.domains.vpn_application.models import ( # noqa: PLC0415 VPN_APP_STATUS_APPROVED, VpnApplication, ) vpn_rows = ( await self.db.execute( select(VpnApplication).where( VpnApplication.person_id == str(person_id), VpnApplication.status == VPN_APP_STATUS_APPROVED, ) ) ).scalars().all() for v in vpn_rows: blockers_preview.append({ "resource_type": RES_VPN_APPLICATION, "resource_id": str(v.id), "label": f"VPN 访问: {v.vpn_node}", "software_id": None, "software_name": None, "policy": "auto_revoke", "needs_successor": False, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "", }) from app.domains.ai_tool_application.models import ( # noqa: PLC0415 AI_TOOL_STATUS_APPROVED, AiToolApplication, ) ai_rows = ( await self.db.execute( select(AiToolApplication).where( AiToolApplication.person_id == str(person_id), AiToolApplication.status == AI_TOOL_STATUS_APPROVED, ) ) ).scalars().all() for a in ai_rows: blockers_preview.append({ "resource_type": RES_AI_TOOL_APPLICATION, "resource_id": str(a.id), "label": f"AI 工具席位: {a.ai_name}", "software_id": None, "software_name": None, "policy": "auto_revoke", "needs_successor": False, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "", }) # DAX personal dataset grants (auto-revoke at source, no successor) from app.domains.data_access.lifecycle import ( # noqa: PLC0415 select_personal_active_bindings, ) from app.domains.data_access.models import DataAccessResource # noqa: PLC0415 from app.domains.offboarding.models import RES_DATA_ACCESS_GRANT # noqa: PLC0415 dax_bindings = await select_personal_active_bindings(self.db, person_id) dax_resources = { r.id: r for r in (await self.db.execute( select(DataAccessResource).where( DataAccessResource.id.in_([b.resource_id for b in dax_bindings]), ) )).scalars().all() } if dax_bindings else {} for binding in dax_bindings: res = dax_resources.get(binding.resource_id) locator = ".".join(filter(None, ( res.project_id if res else None, res.dataset_id if res else None, res.table_id if res else None, ))) or str(binding.resource_id) blockers_preview.append({ "resource_type": RES_DATA_ACCESS_GRANT, "resource_id": str(binding.id), "label": f"数据集访问: {locator}", "software_id": None, "software_name": None, "policy": "auto_revoke", "needs_successor": False, "auto_successor_id": None, "auto_successor_email": None, "auto_successor_reason": "", }) # Aggregate summary by_resource: dict[str, int] = {} by_policy: dict[str, int] = {} must_pick_count = 0 for b in blockers_preview: by_resource[b["resource_type"]] = by_resource.get( b["resource_type"], 0, ) + 1 by_policy[b["policy"]] = by_policy.get(b["policy"], 0) + 1 if b["needs_successor"] and b["auto_successor_id"] is None: must_pick_count += 1 return { "person": { "id": str(target.id), "name": target.display_name or target.work_email, "email": target.work_email, "employment_status": target.employment_status, }, "manager": manager_summary, "existing_process": ( { "id": str(existing_proc.id), "status": existing_proc.status, "leaver_date": existing_proc.leaver_date.isoformat(), } if existing_proc is not None else None ), "blockers": blockers_preview, "summary": { "total_count": len(blockers_preview), "by_resource_type": by_resource, "by_policy": by_policy, "must_pick_successor_count": must_pick_count, "force_logout_will_fire": ( target.employment_status == "active" and target.force_logout_at is None ), }, } # ───────────────────────── Reconcile (cron) ───────────────────────── async def reconcile_all_active_processes( self, *, trace_id: str, ) -> dict[str, Any]: """Cron entry point — runs ``reconcile_blockers`` against every process that's currently in ``BLOCKED`` or ``READY``. D-4 (2026-05-08): provisioning tasks completing async did not flip their owning blockers to RESO_REVOKED_AUTO until an admin manually clicked "Reconcile" on the process. Operators forgot to click; processes stayed in BLOCKED with all underlying tasks already COMPLETED. The cron closes that gap on a 5-minute cadence so the dashboard stays in sync without manual action. Skips PROC_SCANNING (some other tx is in flight) and terminal states. Returns per-process counts so the cron summary makes sense in the scheduled-jobs UI. """ rows = ( await self.db.execute( select(OffboardingProcess).where( OffboardingProcess.status.in_((PROC_BLOCKED, PROC_READY)), ) ) ).scalars().all() reconciled: list[str] = [] errors: list[dict[str, str]] = [] skipped_terminal = 0 flipped_to_ready = 0 for proc in rows: try: prior_status = proc.status # Per-process SAVEPOINT so one process whose reconcile # raises (transient DB error, deadlock, an integrity edge) # doesn't poison the AsyncSession for the rest of the batch. # Without it, the first failure leaves the session in a # failed-transaction state and every subsequent # ``reconcile_blockers`` — plus the handler's final # ``session.commit()`` — raises PendingRollbackError, # discarding the whole 5-min tick. Mirrors the symmetric # onboarding.reconcile_all_active_processes isolation. async with self.db.begin_nested(): await self.reconcile_blockers( proc.id, trace_id=trace_id, actor_user_id=None, ) reconciled.append(str(proc.id)) if prior_status != PROC_READY and proc.status == PROC_READY: flipped_to_ready += 1 except Exception as exc: # noqa: BLE001 # Transaction-aborting errors (deadlock/lock-wait/orphaned # savepoint) kill the whole txn on TiDB; swallowing one here # poisons the shared session so every later reconcile_blockers # and the handler's final commit raise PendingRollbackError, # masking the real cause and discarding the whole tick. # Re-raise so the handler's lock-retry wrapper reruns the tick # in a fresh session (reconcile is idempotent). if is_transaction_aborting_db_error(exc): raise # Benign TOCTOU: the batch selected this process as # BLOCKED/READY *without a lock*, but ``reconcile_blockers`` # re-reads it ``FOR UPDATE`` (+populate_existing) and a # concurrent ``complete_process`` / ``cancel_process`` / # advance-service tick moved it terminal — or deleted it — # in the gap. There is nothing to reconcile; it is not a # failure. Counting it into ``failed`` makes the handler # raise and pages ops every time a leaver completes during # a 5-min tick. Skip silently; the operator-facing manual # reconcile (router) still surfaces PROCESS_TERMINAL as 409. if isinstance(exc, AppException) and exc.code in ( "PROCESS_TERMINAL", "PROCESS_NOT_FOUND", ): skipped_terminal += 1 continue logger.exception( "reconcile_all_active_processes: process %s raised", proc.id, ) errors.append({ "process_id": str(proc.id), "error": format_exception_for_log(exc, max_len=500), }) return { "scanned_count": len(rows), "reconciled_count": len(reconciled), "skipped_terminal_count": skipped_terminal, "failed_count": len(errors), "flipped_to_ready_count": flipped_to_ready, "errors": errors, } # ───────────────────────── Queries ───────────────────────── async def analytics_summary(self, *, window_days: int = 30) -> dict[str, Any]: """Single-trip aggregate for the offboarding-admin analytics. Status / SLA / blocker rollups are lifetime-current (the surface ops monitors); the daily-leavers trend is windowed by ``leaver_date``. Blocker distributions count only ``pending`` resolution (the live work), so a resolved/transferred blocker from a closed process doesn't inflate the "what's stuck" view. Every aggregate is a single GROUP BY. """ from app.core.timeseries import day_axis db = self.db now = datetime.now(UTC) axis = day_axis(window_days=window_days, now=now) status_rows = (await db.execute( select(OffboardingProcess.status, func.count()) .group_by(OffboardingProcess.status) )).all() by_status = {s: int(n) for s, n in status_rows} total = sum(by_status.values()) # SLA breach level across OPEN processes only (terminal rows have # no live SLA). Keys are the raw "none"/"warn"/"urgent"/"breach". sla_rows = (await db.execute( select(OffboardingProcess.sla_breach_level, func.count()) .where(OffboardingProcess.status.notin_([PROC_COMPLETED, PROC_CANCELLED])) .group_by(OffboardingProcess.sla_breach_level) )).all() by_sla = {s: int(n) for s, n in sla_rows} # Live blockers (pending resolution) by resource_type + severity. blk_type_rows = (await db.execute( select(OffboardingBlocker.resource_type, func.count()) .where(OffboardingBlocker.resolution == RESO_PENDING) .group_by(OffboardingBlocker.resource_type) )).all() blockers_by_type = {s: int(n) for s, n in blk_type_rows} blk_sev_rows = (await db.execute( select(OffboardingBlocker.severity, func.count()) .where(OffboardingBlocker.resolution == RESO_PENDING) .group_by(OffboardingBlocker.severity) )).all() blockers_by_severity = {s: int(n) for s, n in blk_sev_rows} # Daily leavers by leaver_date within the window. leaver_rows = (await db.execute( select(OffboardingProcess.leaver_date, func.count()) .where(OffboardingProcess.leaver_date >= axis.days[0]) .group_by(OffboardingProcess.leaver_date) )).all() leaver_map = {str(d): int(n) for d, n in leaver_rows} daily_leavers = [ {"date": d.isoformat(), "leavers": leaver_map.get(d.isoformat(), 0)} for d in axis.days ] return { "window_days": window_days, "kpi": { "total": total, "active": total - by_status.get(PROC_COMPLETED, 0) - by_status.get(PROC_CANCELLED, 0), "blocked": by_status.get(PROC_BLOCKED, 0), "completed": by_status.get(PROC_COMPLETED, 0), "sla_warn": by_sla.get("warn", 0), "sla_breach": by_sla.get("breach", 0), }, "by_status": by_status, "by_sla": by_sla, "blockers_by_type": blockers_by_type, "blockers_by_severity": blockers_by_severity, "daily_leavers": daily_leavers, } async def list_processes( self, *, status: str | None = None, page: int = 1, page_size: int = 20, include_blockers: bool = False, search: str | None = None, ) -> tuple[list[OffboardingProcessResponse], int]: q = select(OffboardingProcess) count_q = select(func.count()).select_from(OffboardingProcess) if status: q = q.where(OffboardingProcess.status == status) count_q = count_q.where(OffboardingProcess.status == status) # Server-side person search (name / email / pinyin) — mirrors the # HR lifecycle-events list. Joins IdentityPerson on the process's # ``person_id`` so a match on page 2+ is found instead of the old # current-page-only client filter rendering a false "no results". search_clean = (search or "").strip() if search_clean: from sqlalchemy import or_ like = f"%{search_clean.lower()}%" person_filter = or_( func.lower(IdentityPerson.display_name).like(like), func.lower(IdentityPerson.work_email).like(like), func.lower(IdentityPerson.name_pinyin).like(like), func.lower(IdentityPerson.name_pinyin_abbr).like(like), ) q = q.join( IdentityPerson, IdentityPerson.id == OffboardingProcess.person_id, ).where(person_filter) count_q = count_q.join( IdentityPerson, IdentityPerson.id == OffboardingProcess.person_id, ).where(person_filter) total = (await self.db.execute(count_q)).scalar() or 0 q = q.order_by(OffboardingProcess.created_at.desc()).offset((page - 1) * page_size).limit(page_size) rows = list((await self.db.execute(q)).scalars().all()) # Enrich person_ids: set[UUID] = {r.person_id for r in rows} # Migration 211: pre-load default handover persons in the same # batch so a 20-row page doesn't fan out into 20 extra GETs. for r in rows: if r.default_handover_person_id is not None: person_ids.add(r.default_handover_person_id) people = await self._load_people(person_ids) # Batch the triage counts for the entire page in one SQL # round-trip instead of issuing 2 extra SELECTs per row inside # _to_process_response. For a 20-row page this collapses ~40 # round-trips into 1 (the 4 pre-existing severity counts per # row are an orthogonal pre-existing pattern; not optimized # here to keep the round-18 PR scoped). bucket_counts = await self._batch_triage_counts( [r.id for r in rows], ) # Round-62 P1 fix (N+1 on cockpit page_size=200): when blockers # are requested, batch-fetch them + their joined asset / software # / provisioning-task lookups across the whole page in O(1) # round-trips instead of O(N×4). Without this, page_size=200 × # include_blockers=True (the cockpit's HRIS-bulk-confirm-burst # configuration) issues 600-800 SELECTs per page-load. batched_blockers = ( await self._batch_blockers_for_processes([r.id for r in rows]) if include_blockers else None ) responses: list[OffboardingProcessResponse] = [] for r in rows: resp = await self._to_process_response( r, people, include_blockers=include_blockers, precomputed_bucket_counts=bucket_counts.get(r.id), precomputed_blockers=batched_blockers, ) responses.append(resp) return responses, total async def set_default_handover_person( self, process_id: UUID, *, successor_person_id: UUID | None, trace_id: str, actor_user_id: UUID, ) -> OffboardingProcess: """Set or clear the per-process default handover successor (migration 211). The default prefills the per-blocker "指定接手人" modal. NULL clears the default (admin will type a name on every applicable blocker). A non-NULL value MUST be an active, non-deleted employee — mirrors the validation in ``resolve_blocker_by_handover_to_successor`` so a stale default never silently fails when the admin clicks confirm in the per-blocker flow. Idempotent re-write: setting the same person twice is a no-op and still logs (the audit trail is the source of truth for "who last changed the default"). """ proc = await self._get_process(process_id) prior_id = proc.default_handover_person_id if successor_person_id is not None: successor = await self.db.get(IdentityPerson, successor_person_id) if successor is None or successor.deleted_at is not None: raise AppException( code="SUCCESSOR_NOT_FOUND", message=( f"Default handover person {successor_person_id} " "not found" ), status_code=400, ) if successor.employment_status != "active": raise AppException( code="SUCCESSOR_NOT_ACTIVE", message=( f"Default handover person {successor.work_email} is " f"{successor.employment_status!r} — pick an active " "employee." ), status_code=409, ) # Belt-and-suspenders: a self-handover (leaver = default # successor) would be a no-op but also a confusing admin # mistake. Reject early. if successor_person_id == proc.person_id: raise AppException( code="SELF_HANDOVER_NOT_ALLOWED", message=( "Cannot set the leaver themselves as their own " "default handover person." ), status_code=400, ) proc.default_handover_person_id = successor_person_id await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.default_handover_person_set", status="success", resource_type="offboarding_process", resource_id=str(proc.id), detail={ "prior_default_handover_person_id": ( str(prior_id) if prior_id else None ), "new_default_handover_person_id": ( str(successor_person_id) if successor_person_id else None ), }, ) # Tell the person they now hold this handover (deduped per # process+recipient, so re-saving the same name stays quiet). # Clearing the default (None) notifies nobody — there is no new # holder to inform. if successor_person_id is not None: leaver = await self.db.get(IdentityPerson, proc.person_id) if leaver is not None: await self.notify_handover_assigned( proc, leaver, successor_id=successor_person_id, assigned_reason_key="admin_set", trace_id=trace_id, ) return proc # ── Org-wide fallback handover person (2026-08-13) ─────────────── async def get_fallback_handover_person(self) -> dict[str, Any]: """Resolve the org-wide fallback handover person for display. Last tier of the terminal-disposition successor chain. Returns the person's identity fields plus ``is_stale`` — a configured person who has since left cannot receive a handover, so the chain treats the setting as unset, and the admin surface must say so out loud rather than showing a comforting name that does nothing. """ from app.domains.offboarding.successor_resolver import ( # noqa: PLC0415 SETTING_FALLBACK_HANDOVER_PERSON, coerce_person_id, ) from app.integrations.service import ( # noqa: PLC0415 get_platform_setting, ) raw = await get_platform_setting( SETTING_FALLBACK_HANDOVER_PERSON, default=None, session=self.db, ) person_id = coerce_person_id(raw) if person_id is None: return {"person_id": None, "is_stale": False} person = await self.db.get(IdentityPerson, person_id) if person is None or person.deleted_at is not None: return {"person_id": person_id, "is_stale": True} active = (person.employment_status or "").lower() == "active" return { "person_id": person_id, "display_name": person.display_name, "work_email": person.work_email, "employment_status": person.employment_status, "is_stale": not active, } async def set_fallback_handover_person( self, *, person_id: UUID | None, trace_id: str, actor_user_id: UUID, ) -> dict[str, Any]: """Set or clear the org-wide fallback handover person. Validated like the per-process default (must exist, be active, and not be soft-deleted) — a fallback that cannot receive a handover is worse than none, because it reads as configured while every orphaned leaver still blocks. Clearing (``None``) is legitimate: it means "no org default", and such leavers' accounts stay suspended with IT paged instead of being deleted. """ from app.domains.offboarding.successor_resolver import ( # noqa: PLC0415 SETTING_FALLBACK_HANDOVER_PERSON, ) from app.integrations.service import ( # noqa: PLC0415 PlatformSettingService, ) if person_id is not None: person = await self.db.get(IdentityPerson, person_id) if person is None or person.deleted_at is not None: raise AppException( code="SUCCESSOR_NOT_FOUND", message=f"Fallback handover person {person_id} not found", status_code=400, ) if (person.employment_status or "").lower() != "active": raise AppException( code="SUCCESSOR_NOT_ACTIVE", message=( f"Fallback handover person {person.work_email} is " f"{person.employment_status!r} — pick an active " "employee." ), status_code=409, ) if not (person.work_email or "").strip(): raise AppException( code="SUCCESSOR_NO_EMAIL", message=( "Fallback handover person has no work email — vendor " "data transfers address the recipient by email." ), status_code=409, ) await PlatformSettingService(self.db).upsert( SETTING_FALLBACK_HANDOVER_PERSON, str(person_id) if person_id is not None else None, ( "离职终删兜底交接人:当离职者既没有默认交接人、上级链上也没有" "在职的人时,由这个人接收其 Drive / 日历 / Zoom 内容。留空则" "这类账号不会被删除,改为告警 IT。" ), trace_id=trace_id, actor_user_id=actor_user_id, ) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_user_id, action="offboarding.fallback_handover_person_set", status="success", resource_type="platform_setting", resource_id=SETTING_FALLBACK_HANDOVER_PERSON, detail={ "new_fallback_person_id": ( str(person_id) if person_id else None ), }, ) return await self.get_fallback_handover_person() async def get_process_detail(self, process_id: UUID) -> OffboardingProcessResponse: proc = await self._get_process(process_id) ids: set[UUID] = {proc.person_id} if proc.default_handover_person_id is not None: ids.add(proc.default_handover_person_id) people = await self._load_people(ids) return await self._to_process_response(proc, people, include_blockers=True) async def get_process_by_person(self, person_id: UUID) -> OffboardingProcessResponse | None: row = ( await self.db.execute( select(OffboardingProcess).where(OffboardingProcess.person_id == person_id) ) ).scalar_one_or_none() if not row: return None ids: set[UUID] = {row.person_id} if row.default_handover_person_id is not None: ids.add(row.default_handover_person_id) people = await self._load_people(ids) return await self._to_process_response(row, people, include_blockers=True) async def list_my_team_leavers( self, viewer_id: UUID, *, page: int = 1, page_size: int = 20, ) -> tuple[list[OffboardingProcessResponse], int]: """List offboarding processes the current user is accountable for as a NON-admin stakeholder. Two relationships qualify, mirroring the SLA dispatcher's non-admin recipient paths: * ``hrbp_person_id == viewer_id`` — leaver's HRBP (URGENT/BREACH tier). * ``manager_person_id == viewer_id`` — leaver's direct manager (WARN tier). Backs ``/my/team-leavers``, reached from the Feishu SLA card's "Handle in Helix" button when the recipient is not in the ``global_admin`` pool. Completed / cancelled processes are filtered out — nothing actionable once terminal. IMPORTANT — skip-level (level+2) managers are NOT eligible. The recipient set MUST mirror the SLA dispatcher's non-admin paths, and ``sla_service.py::_dm_manager_chain`` notifies the DIRECT manager only — skip-level is deliberately excluded per the user decision on the lifecycle visualization ("经理链上级 / level+2 不需要知道这个事情"). An earlier revision added a skip-level clause here justified by a false "walks 2 levels" claim; that let a level+2 manager list their indirect reports' offboarding rows (leaver name / work_email / blocker counts) — data the platform decided they must not see. The clause is removed; this list now exactly matches who the dispatch card could have reached. """ eligible_person_ids = ( select(IdentityPerson.id) .where( or_( IdentityPerson.hrbp_person_id == viewer_id, IdentityPerson.manager_person_id == viewer_id, ) ) .where(IdentityPerson.id != viewer_id) # never list the viewer's own offboarding ) base_filters = [ OffboardingProcess.person_id.in_(eligible_person_ids), OffboardingProcess.status.notin_([PROC_COMPLETED, PROC_CANCELLED]), ] base = select(OffboardingProcess).where(*base_filters) count_q = select(func.count()).select_from(OffboardingProcess).where(*base_filters) total = (await self.db.execute(count_q)).scalar() or 0 # MySQL / TiDB do NOT support ``NULLS LAST`` syntax — emit # ``ORDER BY (col IS NULL) ASC, col DESC`` instead. SQLAlchemy # would happily generate ``DESC NULLS LAST`` in the abstract, # but it crashes the live MySQL dialect with `1064`. Same # workaround used in [asset/service.py:860], # [google_workspace/orphan_service.py:97] and [auditor/router.py]. # Pass 3 logic review found this 2026-05-20 BEFORE first prod # render — the new ``/my/team-leavers`` would have 500'd on the # very first HRBP click from a Feishu SLA card otherwise. first_blocked = OffboardingProcess.first_blocked_at q = ( base.order_by(first_blocked.is_(None).asc(), first_blocked.desc()) .offset((page - 1) * page_size) .limit(page_size) ) rows = list((await self.db.execute(q)).scalars().all()) person_ids: set[UUID] = {r.person_id for r in rows} # Migration 211: pre-load default handover persons in batch. for r in rows: if r.default_handover_person_id is not None: person_ids.add(r.default_handover_person_id) people = await self._load_people(person_ids) bucket_counts = await self._batch_triage_counts([r.id for r in rows]) responses: list[OffboardingProcessResponse] = [] for r in rows: # ``include_blockers=False`` mirrors the admin ``list_processes`` # call site — the list page renders aggregate counts only; # fetching the full blocker rows per process would N+1 the # response. ``bucket_counts.get(..., (0, 0, 0))`` keeps zero- # blocker processes (PROC_SCANNING in particular) on the # precomputed fast path instead of falling through to the # one-element inline batch in ``_to_process_response``. responses.append( await self._to_process_response( r, people, include_blockers=False, precomputed_bucket_counts=bucket_counts.get(r.id, (0, 0, 0)), ) ) return responses, total # ───────────────────────── Transfer flow ───────────────────────── async def create_transfer( self, initiator_id: UUID, *, recipient_id: UUID, asset_type: str, asset_ref_id: UUID, reason: str | None, offboarding_process_id: UUID | None, trace_id: str, ) -> AssetTransferRequest: # Pre-launch hardening 2026-05-04 (P0 L6): recipient must be an # active employee, not the initiator, and not soft-deleted. # Without this check two leavers could chain-transfer assets to # each other to clear blockers without anything ever reaching an # active holder. if recipient_id == initiator_id: raise AppException( code="TRANSFER_SELF", message="Cannot transfer an asset to yourself.", status_code=400, ) from app.identity.person_models import IdentityPerson recipient = await self.db.get(IdentityPerson, recipient_id) if recipient is None or recipient.deleted_at is not None: raise AppException( code="TRANSFER_RECIPIENT_NOT_FOUND", message="Transfer recipient does not exist.", status_code=404, ) # Direct attribute access — IdentityPerson always has # employment_status (memory: feedback_no_getattr_default). recip_status = (recipient.employment_status or "").lower() if recip_status in ("leaver_pending", "left"): raise AppException( code="TRANSFER_RECIPIENT_NOT_ACTIVE", message=( f"Transfer recipient employment_status is " f"{recip_status!r}; must be active." ), status_code=409, ) # Validate initiator actually holds the asset label = await self._verify_and_label_asset( initiator_id=initiator_id, asset_type=asset_type, asset_ref_id=asset_ref_id, ) # Duplicate-pending guard. A locking read that matches ZERO rows # locks nothing on TiDB (pessimistic txns take no gap locks on # range scans), so the FOR UPDATE below only serialises once a # pending row EXISTS. To close the both-read-zero window, first # take a real row lock on the initiator's person row — both # concurrent creates (double-click, or self-service racing the # escalation auto-transfer) queue on it, and the loser then sees # the winner's pending row. await self.db.execute( select(IdentityPerson.id) .where(IdentityPerson.id == initiator_id) .with_for_update() ) dup = ( await self.db.execute( select(AssetTransferRequest).where( and_( AssetTransferRequest.initiator_id == initiator_id, AssetTransferRequest.asset_type == asset_type, AssetTransferRequest.asset_ref_id == asset_ref_id, AssetTransferRequest.status == XFER_PENDING, ) ).with_for_update() ) ).scalar_one_or_none() if dup: raise AppException( code="TRANSFER_ALREADY_PENDING", message="A pending transfer request already exists for this asset", status_code=400, ) xfer = AssetTransferRequest( initiator_id=initiator_id, recipient_id=recipient_id, asset_type=asset_type, asset_ref_id=asset_ref_id, asset_label=label, status=XFER_PENDING, reason=reason, offboarding_process_id=offboarding_process_id, ) self.db.add(xfer) await self.db.flush() # If tied to offboarding, link the matching blocker. # Pre-launch P2 fix: only link when the blocker has no # ``transfer_request_id`` yet. The previous behaviour clobbered # an existing link, orphaning the older transfer (which would # stay PENDING forever from the recipient's point of view) — # concurrent self-service transfer + escalation auto-transfer # could both target the same blocker. Refuse the second # attempt with a 409 so the operator picks one. if offboarding_process_id: resource_type = self._transfer_type_to_blocker_type(asset_type) blocker = ( await self.db.execute( select(OffboardingBlocker).where( and_( OffboardingBlocker.process_id == offboarding_process_id, OffboardingBlocker.resource_type == resource_type, OffboardingBlocker.resource_id == asset_ref_id, ) ) ) ).scalar_one_or_none() if blocker: if blocker.transfer_request_id is not None: raise AppException( code="TRANSFER_ALREADY_LINKED", message=( "This blocker is already linked to transfer " f"{blocker.transfer_request_id}. Cancel that " "transfer first or wait for the recipient " "to respond." ), status_code=409, ) blocker.transfer_request_id = xfer.id await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=initiator_id, action="transfer.created", status="success", resource_type="asset_transfer_request", resource_id=str(xfer.id), detail={ "recipient_id": str(recipient_id), "asset_type": asset_type, "asset_ref_id": str(asset_ref_id), }, ) # Persist the notification intent in the SAME transaction. The # router/MCP drains it only after commit, when the initiator lock is # gone; a failed commit therefore cannot emit a ghost notification. await self._enqueue_transfer_recipient_notification( xfer, trace_id=trace_id, ) return xfer async def _enqueue_transfer_recipient_notification( self, xfer: AssetTransferRequest, *, trace_id: str, ) -> None: from app.integrations.service import get_platform_setting from app.notifications import NotificationEnvelope, NotificationService recipient = ( await self.db.execute( select(IdentityPerson).where(IdentityPerson.id == xfer.recipient_id) ) ).scalar_one_or_none() initiator = ( await self.db.execute( select(IdentityPerson).where(IdentityPerson.id == xfer.initiator_id) ) ).scalar_one_or_none() if not recipient: return portal_url = await get_platform_setting( "app.frontend_base_url", default="(未配置门户地址)", session=self.db, ) env = NotificationEnvelope( recipient=recipient, title="有新的资产转让待你确认", body_text="(see template)", channels=["feishu", "email"], template_key="transfer_received", url=f"{portal_url}/my/transfers", context={ "initiator_name": (initiator.display_name if initiator else "(未知)"), "asset_label": xfer.asset_label, "asset_type": xfer.asset_type, "reason": xfer.reason or "(未填写)", "portal_url": f"{portal_url}/my/transfers", }, related_resource_type="asset_transfer_request", related_resource_id=xfer.id, ) outbox = await NotificationService(self.db).enqueue(env) self._pending_transfer_notifications.append((outbox, env)) async def respond_to_transfer( self, transfer_id: UUID, *, accept: bool, response_note: str | None, acting_user_id: UUID, trace_id: str, ) -> AssetTransferRequest: xfer = await self._get_transfer(transfer_id) if xfer.recipient_id != acting_user_id: raise AppException( code="NOT_RECIPIENT", message="Only the recipient can respond to this transfer", status_code=403, ) if xfer.status != XFER_PENDING: raise AppException( code="TRANSFER_NOT_PENDING", message=f"Transfer is already {xfer.status}", status_code=400, ) # Pre-launch P1 fix: re-validate the recipient's employment # status at accept time. The router-level # ``_leaver_runtime_gate`` already covers HTTP callers, but # this service is also reachable from internal paths # (force-transfer, future MCP) where the gate may not run. # Refusing accepts on a leaver-pending recipient prevents the # create→leaver-flip→accept race that would otherwise hand the # asset to a person about to lose access. if accept: recipient = await self.db.get(IdentityPerson, xfer.recipient_id) if recipient is None or recipient.deleted_at is not None: raise AppException( code="RECIPIENT_NOT_FOUND", message="Transfer recipient not found", status_code=400, ) if recipient.employment_status != "active": raise AppException( code="RECIPIENT_NOT_ACTIVE", message=( f"Recipient {recipient.work_email} is " f"{recipient.employment_status!r} — cannot accept " f"transfer. Cancel the transfer and pick a new " f"recipient." ), status_code=409, ) # P1-8 fix (2026-05-08): re-check the asset still belongs to # the initiator. If two transfer requests existed for the # same resource (e.g. admin and leaver both initiated) and # one was already accepted, the asset is now on someone # else. Without this guard the second accept silently # overwrites the first transfer, producing a chain of # ownership rewrites the audit trail can't reconcile. still_owns = await self._initiator_still_holds_asset( xfer.initiator_id, asset_type=xfer.asset_type, asset_ref_id=xfer.asset_ref_id, ) if not still_owns: raise AppException( code="ASSET_NO_LONGER_HELD_BY_INITIATOR", message=( f"Asset {xfer.asset_label!r} is no longer held by " f"the original sender. Another transfer for the " f"same asset has likely already been accepted, " f"or the asset was returned to IT. This transfer " f"is stale — reject it and ask the new owner if " f"you still need it." ), status_code=409, ) xfer.responded_at = datetime.now(UTC) xfer.response_note = response_note if not accept: xfer.status = XFER_REJECTED # Unlink the blocker so the leaver can create a new transfer # for the same asset. Without this, transfer_request_id keeps # pointing at the rejected row and create_transfer raises # TRANSFER_ALREADY_LINKED on the next attempt. await self._unlink_blocker_from_transfer(xfer) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=acting_user_id, action="transfer.rejected", status="success", resource_type="asset_transfer_request", resource_id=str(xfer.id), detail={"note": response_note}, ) return xfer # Accepted → apply ownership change atomically await self._apply_transfer(xfer) xfer.status = XFER_COMPLETED xfer.completed_at = datetime.now(UTC) # If tied to a blocker, mark it transferred if xfer.offboarding_process_id: resource_type = self._transfer_type_to_blocker_type(xfer.asset_type) blocker = ( await self.db.execute( select(OffboardingBlocker).where( and_( OffboardingBlocker.process_id == xfer.offboarding_process_id, OffboardingBlocker.resource_type == resource_type, OffboardingBlocker.resource_id == xfer.asset_ref_id, ) ) ) ).scalar_one_or_none() if blocker and blocker.resolution == RESO_PENDING: blocker.resolution = RESO_TRANSFERRED blocker.resolution_ref = str(xfer.id) proc = await self._get_process( xfer.offboarding_process_id, lock=True, ) await self._recompute_status(proc) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=acting_user_id, action="transfer.accepted", status="success", resource_type="asset_transfer_request", resource_id=str(xfer.id), detail={"asset_type": xfer.asset_type, "asset_ref_id": str(xfer.asset_ref_id)}, ) # Round-14: heads-up to IT admins so the inventory team has # visibility into hardware that changed hands without HRIS being # involved. Only enqueue here; channel delivery is best-effort after # the ownership transaction commits. await self._enqueue_transfer_accepted_it(xfer, trace_id=trace_id) return xfer async def _enqueue_transfer_accepted_it( self, xfer: AssetTransferRequest, *, trace_id: str, ) -> None: """Enqueue the IT-admin push when an AssetTransferRequest is accepted. The recipient is already notified at create time (``_notify_transfer_recipient``) and the leaver gets the SLA reminders; this fills the visibility gap on the IT side so the inventory team isn't surprised by a hardware row whose ``assigned_to`` flipped without an HR ticket attached. """ try: from app.auth.models import AuthRole, AuthUserRole # noqa: PLC0415 from app.integrations.service import ( # noqa: PLC0415 get_platform_setting, ) from app.notifications import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) # Resolve every active global_admin / it_admin person. admin_ids = list(( await self.db.execute( select(AuthUserRole.user_id) .join(AuthRole, AuthRole.id == AuthUserRole.role_id) .where(AuthRole.name.in_(("global_admin", "it_admin"))) ) ).scalars().all()) admins: list[IdentityPerson] = [] seen: set[UUID] = set() for aid in admin_ids: if aid in seen: continue seen.add(aid) admin = await self.db.get(IdentityPerson, aid) if ( admin is not None and admin.employment_status == "active" and admin.deleted_at is None ): admins.append(admin) if not admins: return initiator = await self.db.get(IdentityPerson, xfer.initiator_id) recipient = await self.db.get(IdentityPerson, xfer.recipient_id) portal_url = await get_platform_setting( "app.frontend_base_url", default="", session=self.db, ) # Only attach a CTA URL when the platform setting resolves # to a real http(s):// origin. With an unset setting the # resolver used to return ``(未配置门户地址)`` and we'd # render a button that 404s on click; better to omit the # button entirely and rely on the body alone. # # CTA target: the offboarding process detail page when the # transfer is part of a leaver flow (so admin lands on the # blocker timeline directly); otherwise the asset detail # page (``/admin/assets/:id``) — both routes exist. We used # to emit ``/admin/transfers/{xfer.id}`` which does NOT # exist as a route and 404'd on every click. if portal_url and portal_url.startswith(("http://", "https://")): if xfer.offboarding_process_id is not None: # Offboarding-linked: jump to the process detail page # where the blocker timeline + accepted transfer rows # show up. cta_path = ( f"/admin/offboarding/process/{xfer.offboarding_process_id}" ) elif xfer.asset_type == "hardware": # Peer-to-peer hardware transfer: ``asset_ref_id`` is # an ``asset_items`` UUID — the asset detail page # renders fine. cta_path = f"/admin/assets/{xfer.asset_ref_id}" else: # Peer-to-peer non-hardware (domain, DNS, cloud # resource…): no per-resource admin "transfer" page; # the lifecycle hub is the canonical "where IT looks # at recent flows" route. cta_path = "/admin/lifecycle" cta_url = f"{portal_url}{cta_path}" else: cta_url = None # Body uses the template's ``{cta_line}`` placeholder; we # do NOT inline portal_url into body_text because the # action button (rendered from ``url=``) is the only CTA - # this stops dev / staging from leaking ``localhost`` and # keeps prod messages tidy when portal.base_url is unset. context_label = ( "离职流转 (offboarding)" if xfer.offboarding_process_id is not None else "日常转让 (peer-to-peer)" ) accepted_at_str = ( xfer.completed_at.isoformat() if xfer.completed_at else "-" ) ctx = { "asset_label": xfer.asset_label, "asset_type": xfer.asset_type, "initiator_name": ( initiator.display_name if initiator else "(未知)" ), "initiator_email": ( initiator.work_email if initiator else "-" ), "recipient_name": ( recipient.display_name if recipient else "(未知)" ), "recipient_email": ( recipient.work_email if recipient else "-" ), "context_label": context_label, "accepted_at": accepted_at_str, "portal_url": cta_url or "", } notify = NotificationService(self.db) for admin in admins: env = NotificationEnvelope( recipient=admin, title=( f"[资产转让] {recipient.display_name if recipient else '?'} " f"已接收 {initiator.display_name if initiator else '?'} " f"的 {xfer.asset_label}" ), body_text="(see template)", channels=["feishu", "email"], template_key="transfer_accepted_it", context=ctx, url=cta_url, related_resource_type="asset_transfer_request", related_resource_id=xfer.id, ) outbox = await notify.enqueue(env) self._pending_transfer_notifications.append((outbox, env)) except Exception: # noqa: BLE001 # Enqueue is part of the atomic transfer/outbox transaction. # Propagate so the caller cannot commit state without its # durable notification intent. logger.exception( "transfer_accepted_it enqueue failed for %s", xfer.id, ) raise async def dispatch_transfer_notifications_after_commit( self, *, trace_id: str, ) -> None: """Drain transfer notifications after the business commit. Unexpected delivery/commit failures leave the already-durable outbox row pending for ``notification.outbox_retry`` and never roll back the completed ownership change. """ from app.notifications import NotificationService pending, self._pending_transfer_notifications = ( self._pending_transfer_notifications, [] ) notify = NotificationService(self.db) for outbox, env in pending: try: await notify.deliver(outbox, env, trace_id=trace_id) await self.db.commit() except Exception: # noqa: BLE001 — durable retry owns recovery logger.exception( "post-commit transfer notification delivery failed " "for outbox %s transfer %s", outbox.id, env.related_resource_id, ) try: await self.db.rollback() except Exception: # noqa: BLE001 logger.exception( "rollback after transfer notification failure failed" ) async def cancel_transfer( self, transfer_id: UUID, *, acting_user_id: UUID, trace_id: str, is_admin_override: bool = False, ) -> AssetTransferRequest: """Cancel a pending transfer. Pre-launch P2 fix (Round 1, SEC-007): the prior implementation only let the initiator cancel. After the initiator left the company, peer-to-peer transfers (those with ``offboarding_process_id IS NULL``) became un-cancellable forever — the recipient saw stale rows in their inbox with no admin escape hatch. ``is_admin_override=True`` lets a global_admin clear those rows (router enforces the role). """ xfer = await self._get_transfer(transfer_id) if not is_admin_override and xfer.initiator_id != acting_user_id: raise AppException( code="NOT_INITIATOR", message="Only the initiator can cancel this transfer", status_code=403, ) if xfer.status != XFER_PENDING: raise AppException( code="TRANSFER_NOT_PENDING", message=f"Transfer is already {xfer.status}", status_code=400, ) xfer.status = XFER_CANCELLED # Unlink the blocker so a fresh transfer can be linked to this # asset. Matches the same fix in the rejection path. await self._unlink_blocker_from_transfer(xfer) await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=acting_user_id, action=( "transfer.cancelled_admin_override" if is_admin_override else "transfer.cancelled" ), status="success", resource_type="asset_transfer_request", resource_id=str(xfer.id), detail={ "is_admin_override": is_admin_override, "initiator_id": str(xfer.initiator_id), "recipient_id": str(xfer.recipient_id), }, ) return xfer async def list_transfers_for_user( self, user_id: UUID, *, direction: str = "incoming", status: str | None = None, limit: int = 50, offset: int = 0, ) -> list[AssetTransferResponse]: col = AssetTransferRequest.recipient_id if direction == "incoming" else AssetTransferRequest.initiator_id conds = [col == user_id] if status: conds.append(AssetTransferRequest.status == status) # Bound the result: asset_transfer_requests accrues a row per transfer # (offboarding force-transfers + voluntary handovers) and is never # trimmed, so an unbounded per-user history grows without limit. rows = list( (await self.db.execute( select(AssetTransferRequest) .where(and_(*conds)) .order_by(AssetTransferRequest.created_at.desc()) .limit(max(1, min(limit, 200))) .offset(max(0, offset)) )).scalars().all() ) user_ids = {r.initiator_id for r in rows} | {r.recipient_id for r in rows} people = await self._load_people(user_ids) return [self._to_transfer_response(r, people) for r in rows] async def count_transfers_for_user( self, user_id: UUID, *, direction: str = "incoming", status: str | None = None, ) -> int: """Total transfer count for the (user, direction[, status]) filter — paired with the paginated ``list_transfers_for_user`` so callers can report an accurate ``total`` without materializing every row.""" col = AssetTransferRequest.recipient_id if direction == "incoming" else AssetTransferRequest.initiator_id conds = [col == user_id] if status: conds.append(AssetTransferRequest.status == status) return int( (await self.db.execute( select(func.count()) .select_from(AssetTransferRequest) .where(and_(*conds)) )).scalar() or 0 ) # ───────────────────────── Internal helpers ───────────────────────── async def _unlink_blocker_from_transfer( self, xfer: AssetTransferRequest, ) -> None: """Clear transfer_request_id on the blocker linked to ``xfer``. Called from cancel_transfer and the rejection branch of respond_to_transfer so the blocker is immediately free to accept a new transfer without waiting for the next reconcile tick. No-op when xfer has no offboarding_process_id or no matching blocker. """ if not xfer.offboarding_process_id: return resource_type = self._transfer_type_to_blocker_type(xfer.asset_type) blocker = ( await self.db.execute( select(OffboardingBlocker).where( and_( OffboardingBlocker.process_id == xfer.offboarding_process_id, OffboardingBlocker.resource_type == resource_type, OffboardingBlocker.resource_id == xfer.asset_ref_id, OffboardingBlocker.transfer_request_id == xfer.id, ) ) ) ).scalar_one_or_none() if blocker: blocker.transfer_request_id = None async def _get_process( self, process_id: UUID, *, lock: bool = False, ) -> OffboardingProcess: """Load a process by id, optionally with ``SELECT ... FOR UPDATE``. State-mutating callers (``cancel_process``, ``suspend_sso``, ``complete_process``, ``reconcile_blockers``, ``resolve_blocker_*``) MUST pass ``lock=True``. Without the lock two concurrent admin actions can both read a non-terminal status, both proceed, and emit duplicate audit / vendor calls. The lock is released when the surrounding session commits / rolls back. Read-only callers (``get_process_detail``, ``list_processes``) leave ``lock=False`` to avoid lock-wait timeouts on the dashboard. """ stmt = select(OffboardingProcess).where( OffboardingProcess.id == process_id, ) if lock: # populate_existing: the reconcile loop loads BLOCKED/READY # processes unlocked before re-locking each here. Without it # the FOR UPDATE returns the stale cached row, so a process a # concurrent txn just moved to COMPLETED/CANCELLED reads as # still-active and the rescan resurrects blockers on it. stmt = stmt.with_for_update().execution_options(populate_existing=True) row = (await self.db.execute(stmt)).scalar_one_or_none() if not row: raise AppException( code="PROCESS_NOT_FOUND", message="Offboarding process not found", status_code=404, ) return row async def _get_blocker( self, blocker_id: UUID, *, lock: bool = False, ) -> OffboardingBlocker: """Fetch a single blocker row. ``lock=True`` adds ``FOR UPDATE`` so concurrent mutations (skip / mark_manual / handover / revoke) that read ``resolution`` before flipping it serialize against each other. Without the lock, two simultaneous "skip" clicks would both pass the ``resolution != pending`` gate at the top of ``skip_blocker``, both write ``RESO_SKIPPED`` + an audit row + invoke ``mark_held_by_leaver`` — the asset is safe (the second ``mark_held_by_leaver`` falls into the idempotent no-op branch) but two audit rows + two RESO writes is a contract violation. Round-21 pass-2 review. """ stmt = select(OffboardingBlocker).where( OffboardingBlocker.id == blocker_id, ) if lock: stmt = stmt.with_for_update() row = (await self.db.execute(stmt)).scalar_one_or_none() if not row: raise AppException( code="BLOCKER_NOT_FOUND", message="Blocker not found", status_code=404, ) return row async def _get_transfer(self, transfer_id: UUID) -> AssetTransferRequest: # Row-level lock: both callers (respond_to_transfer, cancel_transfer) # mutate status. Without the lock two concurrent accept clicks both # pass `status == XFER_PENDING`, double-apply ownership changes, and # double-emit audit entries. row = ( await self.db.execute( select(AssetTransferRequest) .where(AssetTransferRequest.id == transfer_id) .with_for_update() ) ).scalar_one_or_none() if not row: raise AppException( code="TRANSFER_NOT_FOUND", message="Transfer request not found", status_code=404, ) return row async def _load_people(self, ids: set[UUID]) -> dict[UUID, IdentityPerson]: if not ids: return {} rows = ( await self.db.execute( select(IdentityPerson).where(IdentityPerson.id.in_(ids)) ) ).scalars().all() return {r.id: r for r in rows} async def _initiator_still_holds_asset( self, initiator_id: UUID, *, asset_type: str, asset_ref_id: UUID, ) -> bool: """Returns True iff ``initiator_id`` is still the recorded owner of the underlying resource. Used by ``respond_to_transfer`` to catch stale transfers where another transfer for the same asset already completed (P1-8 fix, 2026-05-08). For email-group / sso-grant / domain-registration transfers the owner concept is a string-typed person id; we compare against ``str(initiator_id)`` accordingly. """ if asset_type == "hardware": owner_id = await self.db.scalar( select(AssetItem.assigned_to).where( AssetItem.id == asset_ref_id, AssetItem.deleted_at.is_(None), ) ) return bool(owner_id == initiator_id) if asset_type == "software_seat": # Pre-launch P2 (Round 1, LG-007): software_seat transfers # are intentionally rejected at create time # (``_verify_and_label_asset`` raises TRANSFER_NOT_SUPPORTED). # This branch survives only as a forensic guard: if a row # somehow exists with this asset_type (e.g. a bad migration # or a future feature flag), surface it as "no longer held" # so accept fails-closed and the seat-revoke pipeline (the # supported path) handles it. Owner-check kept for parity # but contributors should NOT re-enable seat transfers # via this branch — see LG-007 in the round-1 audit. owner_id = await self.db.scalar( select(SoftwareSeatAllocation.user_id).where( SoftwareSeatAllocation.id == asset_ref_id, ) ) return bool(owner_id == initiator_id) if asset_type == "api_key": # Round-46 (2026-05-20): also gate on status. ``revoke_key`` # clears ``key_value`` and flips status to ``revoked`` but # PRESERVES ``assigned_to`` for forensics. A transfer # request accepted AFTER the source was revoked would # otherwise pass the still-holds check and run # ``apply_transfer`` on a dead row — flipping assigned_to # to the recipient on a revoked key, re-pointing the # disabled CmdbCostAllocationRule, and retagging a now- # deleted IAM user (the AWS call NoSuchEntity-errors and # is best-effort-swallowed). Recipient ends up "owning" # a credential that doesn't work. Treat revoked / # rejected / pending_approval as "no longer held". row = ( await self.db.execute( select( ApiKeyAssignment.assigned_to, ApiKeyAssignment.status, ).where(ApiKeyAssignment.id == asset_ref_id) ) ).one_or_none() if row is None: return False owner_id, status = row if status not in _API_KEY_TRANSFERABLE_STATUSES: return False return bool(owner_id == initiator_id) if asset_type == RES_EMAIL_GROUP: from app.domains.email_group.models import ( # noqa: PLC0415 EmailGroupOwner, ) # Email groups have N-owners via the ``email_group_owners`` # join table. Initiator still holds it iff a row exists. owner_row = await self.db.scalar( select(EmailGroupOwner.id).where( EmailGroupOwner.email_group_id == asset_ref_id, EmailGroupOwner.person_id == initiator_id, ) ) return owner_row is not None # Unknown asset_type — fall through to "no" to surface the bug # via the explicit error rather than silently allowing the # accept. Adding a new asset_type to ``_apply_transfer`` MUST # also add a branch here. logger.warning( "_initiator_still_holds_asset: unknown asset_type %r for ref %s — " "treating as 'no longer held' to fail safe", asset_type, asset_ref_id, ) return False async def _batch_triage_counts( self, process_ids: list[UUID], ) -> dict[UUID, tuple[int, int, int]]: """Return ``{process_id: (needs_it, auto_in_progress, resolved)}``. One SQL round-trip via LEFT JOIN against ``provisioning_tasks``. Avoids the N+1 trap when ``list_processes`` serializes a page of leavers: previously the per-row ``_to_process_response`` fired 2 extra SELECTs to fetch pending blockers + their task metadata; for a 20-row page that's 40 round-trips on top of the existing 4-counts-per-row pattern. This collapses the new cost to 1 query total. Triage-bucket logic mirrors :func:`compute_triage_bucket` — the CASE expression below MUST stay in lock-step with that function. The expression encodes the same priority ordering: resolved wins, then error_count >= 1, then task.status=failed, then manual_notify+notified, then severity=blocker, then advisory default. """ if not process_ids: return {} from sqlalchemy import case # noqa: PLC0415 # NULL-safety: the LEFT JOIN against provisioning_tasks # returns NULL for blockers with no linked task. Plain # comparisons like ``pt.status = 'failed'`` yield NULL (not # FALSE) for those rows, which then propagates through OR and # turns ``NOT (...)`` into NULL — and ``CASE WHEN NULL`` takes # the ELSE branch, miscounting advisory pending rows into # zero. COALESCE forces the comparison fields to non-NULL # sentinels so the boolean algebra is FALSE-clean. pt_status = func.coalesce(ProvisioningTask.status, "") pt_mode = func.coalesce(ProvisioningTask.execution_mode, "") needs_it_inner = ( (OffboardingBlocker.error_count >= 1) | (pt_status == "failed") | ((pt_mode == "manual_notify") & (pt_status == "notified")) | (OffboardingBlocker.severity == SEVERITY_BLOCKER) ) needs_it_predicate = ( (OffboardingBlocker.resolution == RESO_PENDING) & needs_it_inner ) auto_predicate = ( (OffboardingBlocker.resolution == RESO_PENDING) & ~needs_it_inner ) resolved_predicate = OffboardingBlocker.resolution != RESO_PENDING q = ( select( OffboardingBlocker.process_id, func.sum(case((needs_it_predicate, 1), else_=0)).label("needs_it"), func.sum(case((auto_predicate, 1), else_=0)).label("auto"), func.sum(case((resolved_predicate, 1), else_=0)).label("resolved"), ) .select_from(OffboardingBlocker) .outerjoin( ProvisioningTask, ProvisioningTask.id == OffboardingBlocker.provisioning_task_id, ) .where(OffboardingBlocker.process_id.in_(process_ids)) .group_by(OffboardingBlocker.process_id) ) result: dict[UUID, tuple[int, int, int]] = {} for row in (await self.db.execute(q)).all(): result[row.process_id] = ( int(row.needs_it or 0), int(row.auto or 0), int(row.resolved or 0), ) # Processes with zero blockers don't appear in the GROUP BY # result; default them to (0, 0, 0) so the caller doesn't have # to defend against missing keys. for pid in process_ids: result.setdefault(pid, (0, 0, 0)) return result async def _batch_blockers_for_processes( self, process_ids: list[UUID], ) -> dict | None: """Batch-fetch blockers + joined asset / software / task data. Round-62 P1 fix: a single ``list_processes`` page with ``include_blockers=True`` previously triggered 4 SQL round-trips per process row inside ``_to_process_response`` (blockers query + asset status + software catalog + provisioning task meta). At page_size=200 that is 800 round-trips per page-load — and page_size=200 is exactly the configuration the offboarding cockpit uses (HRIS bulk-confirm bursts). This helper does ALL four lookups across the entire page in constant round-trips. Returns a dict with the four pre-grouped lookups; callers pass it to ``_to_process_response`` via the ``precomputed_blockers`` kwarg and the per-row blocker branch reads from the dict instead of issuing per-row queries. Returns None when ``process_ids`` is empty so the caller can treat absence uniformly. """ if not process_ids: return None from app.domains.asset.models import ( # noqa: PLC0415 ASSET_STATUS_HELD_BY_LEAVER, AssetItem, ) # 1. All blockers for the batch in ONE query. blocker_rows = list( ( await self.db.execute( select(OffboardingBlocker) .where(OffboardingBlocker.process_id.in_(process_ids)) .order_by( OffboardingBlocker.process_id, OffboardingBlocker.resource_type, OffboardingBlocker.created_at, ) ) ).scalars().all() ) blockers_by_proc: dict[UUID, list[OffboardingBlocker]] = {} for b in blocker_rows: blockers_by_proc.setdefault(b.process_id, []).append(b) # 2. Held-by-leaver asset status across the batch. hw_asset_ids = { b.resource_id for b in blocker_rows if b.resource_type == RES_HARDWARE } held_asset_ids: set[UUID] = set() if hw_asset_ids: status_rows = ( await self.db.execute( select(AssetItem.id, AssetItem.status).where( AssetItem.id.in_(hw_asset_ids), ) ) ).all() held_asset_ids = { r.id for r in status_rows if r.status == ASSET_STATUS_HELD_BY_LEAVER } # 3. Software catalog rows for any software-typed blockers. sw_ids = {b.software_id for b in blocker_rows if b.software_id} sw_map: dict[UUID, AssetSoftwareCatalog] = {} if sw_ids: sw_rows = ( await self.db.execute( select(AssetSoftwareCatalog).where( AssetSoftwareCatalog.id.in_(sw_ids), ) ) ).scalars().all() sw_map = {s.id: s for s in sw_rows} # 4. Provisioning task metadata for blockers with a linked task. task_ids = { b.provisioning_task_id for b in blocker_rows if b.provisioning_task_id is not None } task_meta: dict[UUID, tuple[str, str]] = {} if task_ids: task_rows = ( await self.db.execute( select( ProvisioningTask.id, ProvisioningTask.status, ProvisioningTask.execution_mode, ).where(ProvisioningTask.id.in_(task_ids)) ) ).all() for r in task_rows: task_meta[r.id] = (r.status, r.execution_mode) return { "blockers_by_proc": blockers_by_proc, "held_asset_ids": held_asset_ids, "sw_map": sw_map, "task_meta": task_meta, } async def _to_process_response( self, proc: OffboardingProcess, people: dict[UUID, IdentityPerson], *, include_blockers: bool, precomputed_bucket_counts: tuple[int, int, int] | None = None, precomputed_blockers: dict | None = None, ) -> OffboardingProcessResponse: person = people.get(proc.person_id) total_count = ( await self.db.execute( select(func.count()).select_from(OffboardingBlocker).where( OffboardingBlocker.process_id == proc.id, ) ) ).scalar() or 0 pending_count = ( await self.db.execute( select(func.count()).select_from(OffboardingBlocker).where( and_( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.resolution == RESO_PENDING, ) ) ) ).scalar() or 0 # Round-14 split counts. ``pending_action_required_count`` is the # red-banner number (severity=blocker, resolution=pending) and # ``pending_advisory_count`` is the auto-handled queue # (severity=advisory, resolution=pending). Sum equals # ``pending_count``. Indexed by ix_offboarding_blockers_proc_sev_res # so this is a covering scan even at high blocker volume. pending_action_required = ( await self.db.execute( select(func.count()).select_from(OffboardingBlocker).where( and_( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.resolution == RESO_PENDING, OffboardingBlocker.severity == SEVERITY_BLOCKER, ) ) ) ).scalar() or 0 pending_advisory = ( await self.db.execute( select(func.count()).select_from(OffboardingBlocker).where( and_( OffboardingBlocker.process_id == proc.id, OffboardingBlocker.resolution == RESO_PENDING, OffboardingBlocker.severity == SEVERITY_ADVISORY, ) ) ) ).scalar() or 0 # Triage-bucket counts (migration 148). Two code paths: # # 1. Caller (e.g. list_processes serializing a page) provides # ``precomputed_bucket_counts`` — use directly. Zero extra # SELECTs in this hot path; the upstream batch query # (``_batch_triage_counts``) does the work for all process # ids in one round-trip. # 2. Single-process callers (e.g. get_process_detail) leave # it as None — we delegate to _batch_triage_counts with a # one-element list, which still issues a single SELECT. # # ``task_meta`` is populated below ONLY when # ``include_blockers=True`` and is used for per-row # triage_bucket serialization on each OffboardingBlockerResponse. if precomputed_bucket_counts is not None: needs_it_count, auto_in_progress_count, resolved_count = ( precomputed_bucket_counts ) else: single = await self._batch_triage_counts([proc.id]) needs_it_count, auto_in_progress_count, resolved_count = ( single.get(proc.id, (0, 0, 0)) ) # Sanity check: the SQL CASE expression in _batch_triage_counts # is meant to be in lock-step with compute_triage_bucket. If # the sum drifts from total_blocker_count, we have a logic # mismatch; clamp to total_count to keep the API contract # ("sum of buckets <= total") and log a warning so a regression # test would catch it next iteration. bucket_sum = needs_it_count + auto_in_progress_count + resolved_count if bucket_sum != int(total_count): logger.warning( "_to_process_response: triage bucket sum %d != total %d " "for proc %s — SQL/Python triage logic out of sync", bucket_sum, int(total_count), proc.id, ) task_meta: dict[UUID, tuple[str, str]] = {} blockers: list[OffboardingBlockerResponse] = [] if include_blockers: # Round-62 P1 fix: use precomputed batch data when caller # provides it (``list_processes`` does, single-process # callers don't). Avoids the 4-queries-per-process N+1 that # turns a page_size=200 cockpit load into 800 round-trips. if precomputed_blockers is not None: rows = precomputed_blockers["blockers_by_proc"].get(proc.id, []) held_asset_ids = precomputed_blockers["held_asset_ids"] sw_map = precomputed_blockers["sw_map"] full_task_meta = dict(precomputed_blockers["task_meta"]) # Merge any task ids already in the locally-built # task_meta (single-process caller path may have # populated it earlier in this function). Precomputed # value wins on conflict — it was fetched in the same # request and is as fresh as can be. for tid, meta in task_meta.items(): full_task_meta.setdefault(tid, meta) else: rows = ( await self.db.execute( select(OffboardingBlocker).where(OffboardingBlocker.process_id == proc.id) .order_by(OffboardingBlocker.resource_type, OffboardingBlocker.created_at) ) ).scalars().all() # Round-21 review fix-up: batch-fetch current status of every # hardware-typed blocker's underlying asset so the response # can stamp ``asset_held_by_leaver`` honestly. Without this # the UI would render "已转为离职持有" on a skipped hardware # blocker where the auto-mark fell through (asset deleted / # disposed mid-flight). from app.domains.asset.models import ( ASSET_STATUS_HELD_BY_LEAVER, AssetItem, ) hw_asset_ids = { b.resource_id for b in rows if b.resource_type == RES_HARDWARE } held_asset_ids: set[UUID] = set() if hw_asset_ids: rows_status = ( await self.db.execute( select(AssetItem.id, AssetItem.status).where( AssetItem.id.in_(hw_asset_ids), ) ) ).all() held_asset_ids = { r.id for r in rows_status if r.status == ASSET_STATUS_HELD_BY_LEAVER } software_ids = {b.software_id for b in rows if b.software_id} softwares = ( await self.db.execute( select(AssetSoftwareCatalog).where(AssetSoftwareCatalog.id.in_(software_ids)) ) if software_ids else None ) sw_map = {s.id: s for s in softwares.scalars().all()} if softwares else {} # Batch-fetch provisioning task metadata for ALL blockers # (including resolved ones) so the per-blocker triage_bucket # reflects task state on the historical / resolved rows too # — useful for the "已完成 (N)" expanded view in the admin # console. all_task_ids = { b.provisioning_task_id for b in rows if b.provisioning_task_id is not None } full_task_meta: dict[UUID, tuple[str, str]] = dict(task_meta) missing_task_ids = all_task_ids - set(full_task_meta.keys()) if missing_task_ids: extra_rows = ( await self.db.execute( select( ProvisioningTask.id, ProvisioningTask.status, ProvisioningTask.execution_mode, ).where(ProvisioningTask.id.in_(missing_task_ids)) ) ).all() for r in extra_rows: full_task_meta[r.id] = (r.status, r.execution_mode) for b in rows: sw = sw_map.get(b.software_id) if b.software_id else None task_status, task_mode = ( full_task_meta.get(b.provisioning_task_id, (None, None)) if b.provisioning_task_id is not None else (None, None) ) bucket = compute_triage_bucket( resolution=b.resolution, severity=b.severity, error_count=b.error_count or 0, provisioning_task_status=task_status, provisioning_task_execution_mode=task_mode, ) blockers.append(OffboardingBlockerResponse( id=b.id, process_id=b.process_id, resource_type=b.resource_type, resource_id=b.resource_id, label=b.label, software_id=b.software_id, software_name=sw.name if sw else None, severity=b.severity, resolution=b.resolution, resolution_ref=b.resolution_ref, provisioning_task_id=b.provisioning_task_id, transfer_request_id=b.transfer_request_id, notes=b.notes, last_error_message=b.last_error_message, last_error_at=b.last_error_at, error_count=b.error_count or 0, triage_bucket=bucket, asset_held_by_leaver=( b.resource_type == RES_HARDWARE and b.resource_id in held_asset_ids ), created_at=b.created_at, updated_at=b.updated_at, )) # Resolve default handover person (migration 211). The caller's # ``people`` dict is the canonical place to look — list_processes, # get_process_detail, and get_process_by_person all pre-load the # default handover person row when present. Falling back to a # single GET keeps a hand-written caller (or a future tests) safe. default_handover_person = None if proc.default_handover_person_id is not None: default_handover_person = people.get( proc.default_handover_person_id, ) if default_handover_person is None: default_handover_person = await self.db.get( IdentityPerson, proc.default_handover_person_id, ) return OffboardingProcessResponse( id=proc.id, person_id=proc.person_id, person_name=person.display_name if person else None, person_email=person.work_email if person else "", person_employee_no=person.employee_no if person else None, person_employment_status=person.employment_status if person else None, department=person.primary_department if person else None, leaver_date=proc.leaver_date, status=proc.status, pending_action_required_count=int(pending_action_required), pending_advisory_count=int(pending_advisory), pending_blocker_count=int(pending_count), total_blocker_count=int(total_count), needs_it_count=needs_it_count, auto_in_progress_count=auto_in_progress_count, resolved_count=resolved_count, scanned_at=proc.scanned_at, ready_at=proc.ready_at, completed_at=proc.completed_at, sso_suspended_at=proc.sso_suspended_at, session_enforcement=proc.session_enforcement, first_blocked_at=proc.first_blocked_at, sla_breach_level=proc.sla_breach_level, default_handover_person_id=proc.default_handover_person_id, default_handover_person_name=( default_handover_person.display_name if default_handover_person else None ), default_handover_person_email=( default_handover_person.work_email if default_handover_person else None ), default_handover_person_employee_no=( default_handover_person.employee_no if default_handover_person else None ), default_handover_person_employment_status=( default_handover_person.employment_status if default_handover_person else None ), blockers=blockers, created_at=proc.created_at, updated_at=proc.updated_at, ) def _to_transfer_response( self, xfer: AssetTransferRequest, people: dict[UUID, IdentityPerson], ) -> AssetTransferResponse: initiator = people.get(xfer.initiator_id) recipient = people.get(xfer.recipient_id) return AssetTransferResponse( id=xfer.id, initiator_id=xfer.initiator_id, initiator_name=initiator.display_name if initiator else None, initiator_employee_no=initiator.employee_no if initiator else None, initiator_employment_status=initiator.employment_status if initiator else None, recipient_id=xfer.recipient_id, recipient_name=recipient.display_name if recipient else None, recipient_employee_no=recipient.employee_no if recipient else None, recipient_employment_status=recipient.employment_status if recipient else None, asset_type=xfer.asset_type, asset_ref_id=xfer.asset_ref_id, asset_label=xfer.asset_label, status=xfer.status, reason=xfer.reason, response_note=xfer.response_note, responded_at=xfer.responded_at, completed_at=xfer.completed_at, offboarding_process_id=xfer.offboarding_process_id, created_at=xfer.created_at, updated_at=xfer.updated_at, ) @staticmethod def _transfer_type_to_blocker_type(asset_type: str) -> str: return { "hardware": RES_HARDWARE, "software_seat": RES_SOFTWARE_SEAT, "software_permission": RES_SOFTWARE_PERMISSION, "api_key": RES_API_KEY, RES_EMAIL_GROUP: RES_EMAIL_GROUP, }.get(asset_type, asset_type) async def _verify_and_label_asset( self, *, initiator_id: UUID, asset_type: str, asset_ref_id: UUID, ) -> str: """Confirm the initiator holds this asset and return a human label. Design: only hardware and API keys are transferrable. Software seats / permissions are person-specific — if someone else needs them, they apply on their own; on departure they're revoked. """ if asset_type in ("software_seat", "software_permission"): raise AppException( code="TRANSFER_NOT_SUPPORTED", message=( "软件坐席/权限不支持转让。请由接收人自行申请;" "原持有人离职时会自动回收。" ), status_code=400, ) if asset_type == "hardware": # Pre-launch P0 fix (Round 45, CR-403): accept "claimed" # in addition to "assigned". claim_asset sets status to # "claimed", and change_assignee keeps it that way for # most paths — the previous filter rejected the dominant # claimed state, breaking voluntary transfer creation for # the majority of employees. Both are valid "active # ownership" states. row = ( await self.db.execute( select(AssetItem).where( and_( AssetItem.id == asset_ref_id, AssetItem.assigned_to == initiator_id, AssetItem.status.in_(("assigned", "claimed")), ) ) ) ).scalar_one_or_none() if not row: raise AppException( code="ASSET_NOT_OWNED", message="Initiator does not hold this hardware", status_code=400, ) parts = [row.asset_tag or "", row.brand or "", row.model or ""] return " / ".join(p for p in parts if p) or str(row.id) if asset_type == "software_seat": row = ( await self.db.execute( select(SoftwareSeatAllocation, SoftwareLicensePlan, AssetSoftwareCatalog) .join(SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id) .join(AssetSoftwareCatalog, SoftwareLicensePlan.software_id == AssetSoftwareCatalog.id) .where( and_( SoftwareSeatAllocation.id == asset_ref_id, SoftwareSeatAllocation.user_id == initiator_id, SoftwareSeatAllocation.status == "active", ) ) ) ).first() if not row: raise AppException( code="ASSET_NOT_OWNED", message="Initiator does not hold this seat", status_code=400, ) seat, plan, sw = row return f"{sw.name} — {plan.name}" if asset_type == "api_key": row = ( await self.db.execute( select(ApiKeyAssignment).where( and_( ApiKeyAssignment.id == asset_ref_id, ApiKeyAssignment.assigned_to == initiator_id, ApiKeyAssignment.status.in_( _API_KEY_TRANSFERABLE_STATUSES ), ) ) ) ).scalar_one_or_none() if not row: raise AppException( code="ASSET_NOT_OWNED", message="Initiator does not hold this API key", status_code=400, ) return f"API Key {row.request_number} — {row.project_name}" if asset_type == RES_EMAIL_GROUP: from app.domains.email_group.models import EmailGroup, EmailGroupOwner row = ( await self.db.execute( select(EmailGroup) .join(EmailGroupOwner, EmailGroupOwner.email_group_id == EmailGroup.id) .where( and_( EmailGroup.id == asset_ref_id, EmailGroupOwner.person_id == initiator_id, EmailGroup.status == "active", ) ) ) ).scalar_one_or_none() if not row: raise AppException( code="ASSET_NOT_OWNED", message="Initiator is not an owner of this mail group", status_code=400, ) return f"{row.name} <{row.email}>" # software_permission covered by PR 6 raise AppException( code="UNSUPPORTED_ASSET_TYPE", message=f"Unsupported asset_type for transfer: {asset_type}", status_code=400, ) async def _apply_transfer(self, xfer: AssetTransferRequest) -> None: """Re-assign ownership on the underlying resource row. Call only from inside respond_to_transfer after verifying accept.""" if xfer.asset_type == "hardware": row = ( await self.db.execute( select(AssetItem).where(AssetItem.id == xfer.asset_ref_id) ) ).scalar_one_or_none() if row: # Pre-launch P1 fix (asset history hole): record the # ownership change in ``asset_history`` so the asset # detail page shows WHEN/WHY ownership flipped. # Without this row, admins see "owner is now Bob" with # no provenance — the highest-stakes ownership change # (offboarding-driven) had the worst trail. Mirrors # the AssetHistory writes ``dispose_asset`` / # ``claim_asset`` already perform. from app.domains.asset.models import ( # noqa: PLC0415 AssetHistory, AssetOperation, ) # Pre-launch P0 fix (Round 45, CR-401): refuse to # transfer hardware in a non-claimable state. Without # this, a leaver could initiate transfer at T-3, an # admin disposes the asset at T-1, and the recipient # accepts at T+0 → assigned_to=recipient on a disposed # asset (assigned_to + status='disposed' invariant # break). The admin operations view doesn't see the # ghost row but /me/assets does. non_claimable = { "disposed", "disposal_pending", "held_by_leaver", } if (row.status or "").lower() in non_claimable: raise AppException( code="TRANSFER_ASSET_NOT_CLAIMABLE", message=( f"Cannot accept transfer — asset is in status " f"{row.status!r}. Initiator no longer holds " "a claimable asset." ), status_code=409, ) prev_owner = row.assigned_to # Incoming-hire reservation reconciliation (备机闭环 # review fix, 2026-06-12): the leaver-initiated # transfer was accepted by a human recipient — that # acceptance wins; the earmark must not survive as # dual-state. fulfilled/superseded noted in history. from app.domains.asset.reservation import ( resolve_reservation_on_ownership_move, ) reservation_outcome = resolve_reservation_on_ownership_move( row, xfer.recipient_id, ) row.assigned_to = xfer.recipient_id row.assigned_at = datetime.now(UTC) self.db.add(AssetHistory( asset_id=row.id, action="transfer", from_user=prev_owner, to_user=xfer.recipient_id, operator_id=xfer.recipient_id, notes=( f"Voluntary transfer (xfer={xfer.id})" + ( f" via offboarding {xfer.offboarding_process_id}" if xfer.offboarding_process_id else "" ) + ( f" [hire reservation {reservation_outcome}]" if reservation_outcome else "" ) ), )) # Pre-launch P0 fix (Round 45, CR-402): the admin # Operations view reads asset_operations, not # asset_history. Without this row the canonical ops # log under-counts every voluntary transfer — the # highest-stakes ownership change (leaver hand-off to # peer) becomes invisible. Mirrors transfer_asset's # AssetOperation write that Round-44 added. from app.domains.asset.service import ( # noqa: PLC0415 _generate_operation_number, ) self.db.add(AssetOperation( operation_number=_generate_operation_number(), asset_id=row.id, operation_type="transfer", status="confirmed", from_user_id=prev_owner, to_user_id=xfer.recipient_id, operator_id=xfer.recipient_id, notes=( f"Voluntary transfer accepted (xfer={xfer.id})" ), detail={ "via": "voluntary_transfer", "xfer_id": str(xfer.id), "offboarding_process_id": ( str(xfer.offboarding_process_id) if xfer.offboarding_process_id else None ), # Structured mirror of the history-notes tag so # the supersede is queryable like the other two # automation call sites (review-r2). "hire_reservation": reservation_outcome, }, confirmed_at=datetime.now(UTC), confirmed_by=xfer.recipient_id, )) elif xfer.asset_type == "software_seat": # Software seats are person-bound (per the offboarding policy # doc) — ``_verify_and_label_asset`` rejects them at create # time. This branch exists only as a fail-loud guard: if a # future contributor enables seat transfers WITHOUT wiring # an audit row + vendor-side handoff, we want the request # to error visibly rather than silently flip ``user_id`` and # lose the audit chain. raise AppException( code="TRANSFER_SOFTWARE_SEAT_NOT_SUPPORTED", message=( "Software seat transfers are not supported. " "Recipient must apply separately; the seat is " "revoked on departure." ), status_code=400, ) elif xfer.asset_type == "api_key": # PR2 (2026-05-19): api_key transfer now does more than flip # ``assigned_to`` — it also stamps the prior-owner FK on the # row, re-tags the cloud IAM user so AWS Cost Explorer / etc. # attribute new spend to the new owner's email, and swaps # the ``assign_person_id`` on the auto-grant cost-allocation # rule. The "from transfer-effective date" semantic the # user asked for falls out of the rule-update timing: facts # already ingested keep their stamped ``attributed_person_id`` # (set at ingest time); new ingests after the rule update # land on the new owner. Heavy lifting is delegated to # ``ApiKeyService.apply_transfer`` so the apikey-specific # logic (provider bucket iteration, cloud-driver dispatch, # audit row) stays in its own domain. from app.domains.apikey.service import ApiKeyService # noqa: PLC0415 await ApiKeyService(self.db).apply_transfer( assignment_id=xfer.asset_ref_id, new_owner_id=xfer.recipient_id, transfer_request_id=xfer.id, offboarding_process_id=xfer.offboarding_process_id, ) elif xfer.asset_type == RES_EMAIL_GROUP: # Ownership transfer is a 3-step dance against Google: # add new owner → remove old owner → update local row. # Delegate to EmailGroupService so the Google call and local # write stay in one place. If Google rejects, propagate so # the transfer stays in "pending" and the accept doesn't # complete — otherwise we'd orphan a live group on Google. from app.domains.email_group.models import EmailGroup from app.domains.email_group.service import EmailGroupService from app.identity.person_models import IdentityPerson group = await self.db.get(EmailGroup, xfer.asset_ref_id) from_person = await self.db.get(IdentityPerson, xfer.initiator_id) to_person = await self.db.get(IdentityPerson, xfer.recipient_id) if group is None or from_person is None or to_person is None: raise AppException( code="EMAIL_GROUP_TRANSFER_REFS_MISSING", message=( "邮件组或人员记录缺失,无法完成 owner 转移。" ), status_code=500, ) eg_service = EmailGroupService(self.db) await eg_service.transfer_group_owner( group=group, from_person=from_person, to_person=to_person, actor_user_id=xfer.recipient_id, trace_id=str(xfer.id), ) else: # Fail-loud guard mirroring ``_verify_and_label_asset``: an # unmapped asset_type must never silently no-op the ownership # rewrite and then let ``respond_to_transfer`` mark the transfer # COMPLETED (a false "transferred" with no FK change). Today the # create paths only allow hardware / api_key / email_group, so # this is defence-in-depth for any future transferable type. raise AppException( code="UNSUPPORTED_ASSET_TYPE", message=( f"Cannot apply transfer for asset_type " f"{xfer.asset_type!r}" ), status_code=400, )