"""T+1 离职残留:按 ``RES_*`` 注册表驱动,而不是各扫各的(访问对象闭环 PR3)。 ``hr.post_leaver_verify`` 是离职流程的安全网:T+0 那一轮应该把每一样东西撤掉或 交出去,这一轮回头核对「真的没有指向这个人的东西了吗」。它此前**自己写了一遍** 「这个人还持有什么」—— 于是有了两个后果,而且两个都在生产上活了很久: * **少扫 15 种。** 注册表里有 VPN / AI 工具 / GitHub 组织成员 / 邮件组(所有权与 成员资格)/ 软件负责人 / 软件 IT 联系人 / 系统负责人 / 审批流负责人 / 身份消费方 / 软件权限 / DAX 授权 …… T+1 一种都没看。安全网只网住了它自己记得的那一半。 * **名字对不上。** 四种网络资源用的是它自己发明的 ``cloud_domain`` / ``cloud_dns_record`` / ``cloud_cdn_domain`` / ``cloud_load_balancer``,而离职扫描器、 转岗复核包、前端标签用的是 ``domain`` / ``dns_record`` / ``cdn_domain`` / ``load_balancer``。同一条残留在两处是两个名字,合规事件的去重键因此也对不上。 (迁移 609 把历史事件的名字改了过来。) 所以这里不再有第二份枚举:**残留 = 注册表里每一种资源类型 × 一个谓词**,谓词 默认就是转岗复核包的那一个(``mover_holdings._LOADERS``)。两边问的确实是同一个 问题「这个人现在还持有什么」。 **差异必须显式声明,不能靠巧合。** 有两种情况 T+1 和转岗问得不一样,写在 ``_T1_OVERRIDES`` 里并各自说明理由;有一种资源类型 T+1 根本不适用,写在 ``NOT_RESIDUAL`` 里并说明理由。``test_leaver_residual_registry_covers_every_type`` 保证注册表里每一种类型要么有 loader、要么有一条明写的豁免 —— 新增一种资源类型时 必须做一次决定,而不是默默漏掉(这正是上面第一条的成因)。 """ from __future__ import annotations import logging import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.offboarding.models import ( RES_SOFTWARE_ACCOUNT, RES_SOFTWARE_SEAT, ) from app.hr_lifecycle import mover_holdings from app.hr_lifecycle.mover_holdings import Holding, _holding from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) Loader = Callable[[AsyncSession, uuid.UUID], Awaitable[list[Holding]]] # ── T+1-only predicates ───────────────────────────────────────────── async def _seats_including_pending_and_mdm( db: AsyncSession, person_id: uuid.UUID, ) -> list[Holding]: """Seats a leaver still holds — wider than the mover packet's, twice over. * ``pending`` counts. It is a REAL, live seat status (an in-flight self-service grant that already counts against quota), and the offboarding scanner flags pending seats as revoke blockers — so a departed employee's still-pending seat is residual by the same rule. * MDM-managed software is NOT excluded. The mover packet drops those because enrolment follows the DEVICE, so a move raises no question about them; a LEAVER's row is different — the allocation still names a departed person and nothing else will notice. """ from app.domains.asset.models import ( AssetSoftwareCatalog, SoftwareLicensePlan, SoftwareSeatAllocation, ) rows = (await db.execute( select(SoftwareSeatAllocation, SoftwareLicensePlan, AssetSoftwareCatalog) .join( SoftwareLicensePlan, SoftwareLicensePlan.id == SoftwareSeatAllocation.plan_id, ) .join( AssetSoftwareCatalog, AssetSoftwareCatalog.id == SoftwareLicensePlan.software_id, ) .where( SoftwareSeatAllocation.user_id == person_id, SoftwareSeatAllocation.status.in_(("active", "pending")), ) )).all() return [ _holding( RES_SOFTWARE_SEAT, seat.id, f"{sw.name} — {plan.name}", software=sw.name, plan=plan.name, status=seat.status, ) for seat, plan, sw in rows ] #: Where the T+1 question differs from the mover question, and why. _T1_OVERRIDES: dict[str, Loader] = { RES_SOFTWARE_SEAT: _seats_including_pending_and_mdm, } #: Resource types that are deliberately NOT part of the T+1 residual scan. #: Present so the coverage guard sees a decision rather than a gap. NOT_RESIDUAL: dict[str, str] = { RES_SOFTWARE_ACCOUNT: ( "The Jira/Atlassian full-access blocker is created off the " "INTEGRATION's existence, not off a tracked per-person row, so " "there is nothing here to detect as residual. The vendor-truth " "answer for this system is the account-assurance sweep " "(offboarding_account_assurance, system='atlassian'), which reads " "Atlassian's own roster — a far stronger signal than any local row." ), } def residual_loaders() -> dict[str, Loader]: """RES type → predicate, mover loaders by default, overrides applied.""" loaders: dict[str, Loader] = { rtype: loader for rtype, loader in mover_holdings._LOADERS.items() # noqa: SLF001 if rtype not in NOT_RESIDUAL } loaders.update(_T1_OVERRIDES) return loaders @dataclass(slots=True) class ResidualScan: residuals: list[dict[str, Any]] = field(default_factory=list) #: Types whose predicate raised. Reported, never silently dropped: a #: safety net that fails quietly is worse than no safety net, because #: the empty result reads as "clean". failed_types: list[str] = field(default_factory=list) async def scan_person_residuals( db: AsyncSession, person: IdentityPerson, ) -> ResidualScan: """Everything still pointing at ``person`` after their T+0 tick. Per-type isolation, same contract as the mover scan: one domain's query failing records the type and lets the rest complete. """ scan = ResidualScan() for rtype, loader in sorted(residual_loaders().items()): try: found = await loader(db, person.id) except Exception: # noqa: BLE001 — one domain must not end the sweep logger.exception( "leaver_residuals: %s scan failed for person=%s", rtype, person.id, ) scan.failed_types.append(rtype) continue for h in found: scan.residuals.append({ "resource_type": h.resource_type, "resource_id": h.resource_id, "label": h.label, "detail": h.detail, }) return scan __all__ = [ "NOT_RESIDUAL", "ResidualScan", "residual_loaders", "scan_person_residuals", ]