"""``cmdb.leaver_grant_sweep`` cron — auto-revoke residual cloud_access_ grants whose holder is already ``employment_status='left'``. Prod 2026-05-14: P0546 已离职 但仍持有 cloud_access_grant — i.e. the person transitioned to ``left`` but the cmdb_cloud_access_grants row is still ``active`` / ``pending``. The T+0 force-transfer is the primary path; this sweep is the safety net for residuals it can't reach (historical leavers from before the auto-revoke code existed, drift grants synced AFTER the person departed, etc.). No ``recent_days`` cutoff — those residuals are EXACTLY the ones the T+1 ``post_leaver_verify`` recent-only sweep can't see. This file pins: R1 — sweep discovers grants on left persons (any age) and revokes each via ``CloudLifecycleService.revoke_grant`` with ``suppress_driver_error=True`` (Helix-side invariant wins) and ``reason=GRANT_REVOKE_REASON_OFFBOARDING``. R2 — sweep skips grants on active / leaver_pending persons (those are owned by ``hr.process_advance``, not us). R3 — sweep skips already-revoked / already-expired grants (no double-work + no audit-row spam). R4 — sweep counters report (scanned, revoked, cloud_side_failed). The MCP-tool surface (``revoke_offboarding_blocker``) is covered by the smoke test in ``test_mcp_tools_surface.py`` (registers + tool count hint matches). """ from __future__ import annotations import uuid from datetime import UTC, datetime from unittest.mock import patch import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.domains.cmdb.lifecycle_service import ( CloudLifecycleService, sweep_active_grants_on_left_persons, ) from app.domains.cmdb.models import ( GRANT_REVOKE_REASON_OFFBOARDING, GRANT_STATUS_ACTIVE, GRANT_STATUS_EXPIRED, GRANT_STATUS_PENDING, GRANT_STATUS_REVOKED, SCOPE_STATUS_ACTIVE, CmdbCloudAccessGrant, CmdbCloudScope, ) from app.identity.person_models import IdentityPerson from app.integrations.models import Integration @pytest.fixture def patch_async_session_factory(db_engine, monkeypatch): """Re-bind ``app.core.database.async_session_factory`` to the test engine so the sweep's per-grant ``async with async_session_factory()`` blocks observe seeded test rows instead of querying production. Same monkeypatch pattern used by ``test_worker_watchdog_2026_05_12`` — the module-level factory in app.core.database was created at import time and binds to whichever engine the production DATABASE_URL points at; without the rebind the sweep's discovery query reads zero rows and "got Future attached to a different loop" errors flutter through asyncmy. """ test_factory = async_sessionmaker( db_engine, class_=AsyncSession, expire_on_commit=False, ) monkeypatch.setattr( "app.core.database.async_session_factory", test_factory, ) # ``lifecycle_service`` imported the factory by name at module # load — rebinding `app.core.database.async_session_factory` does # not retroactively rewrite that local binding. Patch it inside # the lifecycle module too so callers there land on the test # engine. monkeypatch.setattr( "app.domains.cmdb.lifecycle_service.async_session_factory", test_factory, ) return test_factory async def _make_person( db: AsyncSession, *, employment_status: str, ) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), source_system="test", hris_person_id=f"P{uuid.uuid4().hex[:8]}", employee_no=f"E{uuid.uuid4().hex[:8]}", work_email=f"u{uuid.uuid4().hex[:8]}@x.test", display_name="Test Person", employment_status=employment_status, ) db.add(p) await db.flush() return p async def _make_grant( db: AsyncSession, *, holder: IdentityPerson, status: str = GRANT_STATUS_ACTIVE, ) -> tuple[Integration, CmdbCloudScope, CmdbCloudAccessGrant]: integ = Integration( id=uuid.uuid4(), kind="cloud", provider="azure", name=f"azure-{uuid.uuid4().hex[:6]}", slug=f"azure-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["servers"], ) db.add(integ) await db.flush() scope = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integ.id), scope_type="azure_subscription", external_id=uuid.uuid4().hex[:16], display_name=f"sub-{uuid.uuid4().hex[:6]}", status=SCOPE_STATUS_ACTIVE, first_seen_at=datetime.now(UTC), last_synced_at=datetime.now(UTC), environment="dev", ) db.add(scope) await db.flush() grant = CmdbCloudAccessGrant( id=uuid.uuid4(), scope_id=str(scope.id), person_id=str(holder.id), external_principal_email=holder.work_email, role_key="8e3af657-a8ff-443c-a75c-2fe8c4bcb635", role_display_name="Owner", status=status, granted_at=datetime.now(UTC), external_ref=f"ra-{uuid.uuid4().hex[:8]}", ) db.add(grant) await db.flush() return integ, scope, grant # ───────────────────────── R1 ───────────────────────── @pytest.mark.asyncio async def test_sweep_revokes_grants_on_left_persons( db_session: AsyncSession, patch_async_session_factory, ): """A grant on a person with ``employment_status='left'`` must be revoked by the sweep. The lifecycle service is called with ``suppress_driver_error=True`` + ``reason=offboarding`` — the Helix-side invariant wins regardless of cloud outcome.""" leaver = await _make_person(db_session, employment_status="left") _, _, grant = await _make_grant(db_session, holder=leaver) grant_id = str(grant.id) await db_session.commit() captured: list[dict[str, object]] = [] async def fake_revoke_grant(self_, grant_id_str, **kw): captured.append({"grant_id": grant_id_str, **kw}) # Match the real revoke_grant return shape — the sweep reads # ``grant.detail["requires_manual_cloud_revoke"]`` (JSON sidecar # column, NOT an ORM attribute) to split the cloud-side-failed # counter. Stand-in row exposes ``.detail`` as the real model # does. Empty dict → not flagged as cloud-side failed. return type("G", (), {"detail": {}})() with patch.object( CloudLifecycleService, "revoke_grant", new=fake_revoke_grant, ): summary = await sweep_active_grants_on_left_persons() assert summary["scanned"] == 1 assert summary["revoked"] == 1 assert summary["cloud_side_failed"] == 0 assert summary["errors"] == [] assert len(captured) == 1 assert captured[0]["grant_id"] == grant_id assert captured[0]["reason"] == GRANT_REVOKE_REASON_OFFBOARDING assert captured[0]["suppress_driver_error"] is True assert captured[0]["trace_id"] == "cmdb.leaver_grant_sweep" # System context — no admin user attributed (sweep is autonomous). assert captured[0]["actor_user_id"] is None # ───────────────────────── R2 ───────────────────────── @pytest.mark.asyncio async def test_sweep_skips_grants_on_active_or_pending_persons( db_session: AsyncSession, patch_async_session_factory, ): """The sweep MUST NOT touch grants held by active employees or leaver_pending persons. Those are managed by the primary T+0 force-transfer in ``hr_lifecycle.advance_service`` — racing it would split the audit trail and confuse forensics.""" active = await _make_person(db_session, employment_status="active") leaver_pending = await _make_person( db_session, employment_status="leaver_pending", ) await _make_grant(db_session, holder=active) await _make_grant(db_session, holder=leaver_pending) await db_session.commit() async def fake_revoke_grant(self_, grant_id_str, **kw): pytest.fail( f"sweep called revoke_grant on a non-left person's grant: " f"{grant_id_str}" ) with patch.object( CloudLifecycleService, "revoke_grant", new=fake_revoke_grant, ): summary = await sweep_active_grants_on_left_persons() assert summary["scanned"] == 0 assert summary["revoked"] == 0 # ───────────────────────── R3 ───────────────────────── @pytest.mark.asyncio async def test_sweep_skips_already_revoked_or_expired_grants( db_session: AsyncSession, patch_async_session_factory, ): """Already-terminal grants (revoked, expired) must be ignored even if the holder is left — no double-revoke, no audit-row spam.""" leaver = await _make_person(db_session, employment_status="left") await _make_grant(db_session, holder=leaver, status=GRANT_STATUS_REVOKED) await _make_grant(db_session, holder=leaver, status=GRANT_STATUS_EXPIRED) await db_session.commit() async def fake_revoke_grant(self_, grant_id_str, **kw): pytest.fail( f"sweep called revoke_grant on a terminal-status grant: " f"{grant_id_str}" ) with patch.object( CloudLifecycleService, "revoke_grant", new=fake_revoke_grant, ): summary = await sweep_active_grants_on_left_persons() assert summary["scanned"] == 0 # ───────────────────────── R4 ───────────────────────── @pytest.mark.asyncio async def test_sweep_counters_split_cloud_side_failures( db_session: AsyncSession, patch_async_session_factory, ): """``suppress_driver_error=True`` means a cloud-side failure flips the Helix row to revoked BUT sets ``requires_manual_cloud_revoke= True`` on the returned grant. The sweep summary must surface that as a separate counter so the daily digest can highlight cloud-side work that still needs human follow-up.""" leaver_a = await _make_person(db_session, employment_status="left") leaver_b = await _make_person(db_session, employment_status="left") _, _, grant_a = await _make_grant(db_session, holder=leaver_a) _, _, grant_b = await _make_grant( db_session, holder=leaver_b, status=GRANT_STATUS_PENDING, ) await db_session.commit() async def fake_revoke_grant(self_, grant_id_str, **kw): # Grant A revokes cleanly; grant B has cloud-side trouble so # ``detail["requires_manual_cloud_revoke"]`` flips True on the # returned row. Real code path: see CloudLifecycleService. # _revoke_grant_locked, lines ~2253-2261 — the flag lives in # the JSON sidecar, not an ORM column. detail: dict[str, object] = {} if grant_id_str == str(grant_b.id): detail["requires_manual_cloud_revoke"] = True return type("G", (), {"detail": detail})() with patch.object( CloudLifecycleService, "revoke_grant", new=fake_revoke_grant, ): summary = await sweep_active_grants_on_left_persons() assert summary["scanned"] == 2 assert summary["revoked"] == 2 assert summary["cloud_side_failed"] == 1 assert summary["errors"] == [] # ───────────────────────── Dead-code regression ───────────────────────── def test_revoke_all_grants_for_leaver_removed(): """``revoke_all_grants_for_leaver`` was dead code from 2026-04 — never imported, never tested, never wired to any cron or service. Removed in the 2026-05-14 PR that wired the real sweep (``sweep_active_grants_on_left_persons``) into the cmdb.leaver_ grant_sweep cron. Reintroducing it would re-create the same lie in the codebase about offboarding doing more than it does.""" import app.domains.cmdb.lifecycle_service as lifecycle_mod assert not hasattr(lifecycle_mod, "revoke_all_grants_for_leaver"), ( "Dead function revoke_all_grants_for_leaver was re-introduced. " "Use sweep_active_grants_on_left_persons() + the " "cmdb.leaver_grant_sweep cron instead — the new function " "actually runs." ) assert "revoke_all_grants_for_leaver" not in lifecycle_mod.__all__