"""Offboarding leaver → cloud-principal assignment sweep (``app/domains/cmdb/leaver_sso_sweep.py``). Root-cause fix for AWS Identity Center blank-name orphans: when a person leaves we delete every account assignment their still-live principal holds, BEFORE SCIM deprovision deletes the Identity Store user. These tests pin the guardrails: * non-prod, untracked assignment → deleted + revoked-grant history row carrying the REAL person_id + reason ``leaver_principal_swept``; * prod scope while the prod-write lock is engaged → skipped, no provider delete; * account with no Helix scope row → skipped (can't prove non-prod); * assignment already tracked as an active grant → left to the standard offboarding revoke path; * ``dry_run=True`` → plan only, zero provider deletes, zero history. """ from __future__ import annotations import uuid from unittest.mock import AsyncMock import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb import lifecycle_service as _ls from app.domains.cmdb.leaver_sso_sweep import ( OUTCOME_ALREADY_ABSENT, OUTCOME_DELETED, OUTCOME_SKIPPED_PROD_LOCKED, OUTCOME_SKIPPED_TRACKED_GRANT, OUTCOME_SKIPPED_UNMANAGED, OUTCOME_WOULD_DELETE, sweep_leaver_sso_assignments, ) from app.domains.cmdb.models import ( GRANT_REVOKE_REASON_LEAVER_PRINCIPAL_SWEPT, GRANT_STATUS_ACTIVE, GRANT_STATUS_REVOKED, CmdbCloudAccessGrant, CmdbCloudScope, ) from app.identity.person_models import IdentityPerson from app.integrations.driver_base import ( CloudGrantResult, PrincipalAssignment, ) from app.integrations.models import Integration _EMAIL = "leaver@pingcap.com" _PRINCIPAL = "95670a3c83-11111111-2222-3333-4444-555555555555" _ROLE = "arn:aws:sso:::permissionSet/ssoins-x/ps-dbaasleader" _ROLE_NAME = "DBaaS-DevLeader-Role" async def _mk_person(db: AsyncSession) -> IdentityPerson: person = IdentityPerson( id=uuid.uuid4(), hris_person_id=f"hris-{uuid.uuid4().hex[:8]}", work_email=_EMAIL, display_name="Departing Person", employment_status="left", ) db.add(person) await db.commit() return person async def _mk_integration(db: AsyncSession) -> Integration: integ = Integration( id=uuid.uuid4(), kind="cloud", provider="aws", name="Test AWS SSO", slug=f"test-aws-sweep-{uuid.uuid4().hex[:8]}", enabled=True, config_public={}, ) db.add(integ) await db.commit() await db.refresh(integ) return integ async def _mk_scope( db: AsyncSession, *, integration_id: str, external_id: str, env: str, ) -> CmdbCloudScope: scope = CmdbCloudScope( id=uuid.uuid4(), integration_id=integration_id, scope_type="aws_account", external_id=external_id, display_name=external_id, environment=env, ) db.add(scope) await db.commit() return scope def _bind_test_session_factory(monkeypatch): """Point the factory the sweep opens internally at the test DB, on a fresh NullPool engine bound to the current loop (mirrors the orphan_cleanup test).""" from sqlalchemy.ext.asyncio import ( async_sessionmaker, create_async_engine, ) from sqlalchemy.pool import NullPool from app.domains.cmdb import leaver_sso_sweep as _sw from tests.conftest import TEST_DB_URL engine = create_async_engine(TEST_DB_URL, echo=False, poolclass=NullPool) test_factory = async_sessionmaker( engine, class_=AsyncSession, expire_on_commit=False, ) monkeypatch.setattr(_sw, "async_session_factory", test_factory) return engine def _fake_driver( assignments: list[PrincipalAssignment], delete_mock: AsyncMock, ): class _FakeDriver: def __init__(self) -> None: self.list_calls: list[str] = [] async def list_principal_assignments(self, email: str): self.list_calls.append(email) return list(assignments) delete_orphan_assignment = delete_mock async def close(self) -> None: return None return _FakeDriver() @pytest.mark.asyncio async def test_sweep_deletes_nonprod_untracked_and_records_history( db_session: AsyncSession, monkeypatch, ): engine = _bind_test_session_factory(monkeypatch) try: person = await _mk_person(db_session) integ = await _mk_integration(db_session) scope = await _mk_scope( db_session, integration_id=str(integ.id), external_id="448141240262", env="dev", ) delete_mock = AsyncMock(return_value=CloudGrantResult(ok=True)) driver = _fake_driver( [PrincipalAssignment( account_id="448141240262", principal_id=_PRINCIPAL, role_key=_ROLE, role_display_name=_ROLE_NAME, external_ref=f"448141240262::{_ROLE}::{_PRINCIPAL}", )], delete_mock, ) monkeypatch.setattr( _ls, "_load_cloud_driver", AsyncMock(return_value=driver), ) result = await sweep_leaver_sso_assignments( person.id, actor_user_id=None, trace_id="test-sweep", ) assert len(result.results) == 1 assert result.results[0].outcome == OUTCOME_DELETED assert result.deleted_count == 1 assert delete_mock.await_count == 1 # A revoked-grant history row with the REAL person_id + reason. rows = (await db_session.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == str(scope.id), ) )).scalars().all() assert len(rows) == 1 row = rows[0] assert row.status == GRANT_STATUS_REVOKED assert row.person_id == str(person.id) assert row.revoke_reason == GRANT_REVOKE_REASON_LEAVER_PRINCIPAL_SWEPT assert row.external_principal_id == _PRINCIPAL assert row.external_principal_email == _EMAIL finally: await engine.dispose() @pytest.mark.asyncio async def test_sweep_skips_prod_scope_while_locked( db_session: AsyncSession, monkeypatch, ): engine = _bind_test_session_factory(monkeypatch) try: person = await _mk_person(db_session) integ = await _mk_integration(db_session) await _mk_scope( db_session, integration_id=str(integ.id), external_id="900000000001", env="prod", ) delete_mock = AsyncMock(return_value=CloudGrantResult(ok=True)) driver = _fake_driver( [PrincipalAssignment( account_id="900000000001", principal_id=_PRINCIPAL, role_key=_ROLE, role_display_name=_ROLE_NAME, )], delete_mock, ) monkeypatch.setattr( _ls, "_load_cloud_driver", AsyncMock(return_value=driver), ) # No platform_settings row → prod-write lock engaged (default). result = await sweep_leaver_sso_assignments( person.id, actor_user_id=None, trace_id="test-sweep", ) assert len(result.results) == 1 assert result.results[0].outcome == OUTCOME_SKIPPED_PROD_LOCKED # The provider delete was NEVER issued for the locked prod scope. assert delete_mock.await_count == 0 finally: await engine.dispose() @pytest.mark.asyncio async def test_sweep_skips_unmanaged_account( db_session: AsyncSession, monkeypatch, ): engine = _bind_test_session_factory(monkeypatch) try: person = await _mk_person(db_session) _integ = await _mk_integration(db_session) # NO scope row for this account_id → can't classify environment. delete_mock = AsyncMock(return_value=CloudGrantResult(ok=True)) driver = _fake_driver( [PrincipalAssignment( account_id="777777777777", principal_id=_PRINCIPAL, role_key=_ROLE, role_display_name=_ROLE_NAME, )], delete_mock, ) monkeypatch.setattr( _ls, "_load_cloud_driver", AsyncMock(return_value=driver), ) result = await sweep_leaver_sso_assignments( person.id, actor_user_id=None, trace_id="test-sweep", ) assert len(result.results) == 1 assert result.results[0].outcome == OUTCOME_SKIPPED_UNMANAGED assert delete_mock.await_count == 0 finally: await engine.dispose() @pytest.mark.asyncio async def test_sweep_skips_already_tracked_grant( db_session: AsyncSession, monkeypatch, ): engine = _bind_test_session_factory(monkeypatch) try: person = await _mk_person(db_session) integ = await _mk_integration(db_session) scope = await _mk_scope( db_session, integration_id=str(integ.id), external_id="123456789012", env="dev", ) # An active Helix grant for the same (scope, person, role) — owned # by the standard offboarding RES_CLOUD_ACCESS_GRANT revoke path. db_session.add(CmdbCloudAccessGrant( id=uuid.uuid4(), scope_id=str(scope.id), person_id=str(person.id), role_key=_ROLE, status=GRANT_STATUS_ACTIVE, )) await db_session.commit() delete_mock = AsyncMock(return_value=CloudGrantResult(ok=True)) driver = _fake_driver( [PrincipalAssignment( account_id="123456789012", principal_id=_PRINCIPAL, role_key=_ROLE, role_display_name=_ROLE_NAME, )], delete_mock, ) monkeypatch.setattr( _ls, "_load_cloud_driver", AsyncMock(return_value=driver), ) result = await sweep_leaver_sso_assignments( person.id, actor_user_id=None, trace_id="test-sweep", ) assert len(result.results) == 1 assert result.results[0].outcome == OUTCOME_SKIPPED_TRACKED_GRANT assert delete_mock.await_count == 0 finally: await engine.dispose() @pytest.mark.asyncio async def test_sweep_dry_run_plans_without_deleting( db_session: AsyncSession, monkeypatch, ): engine = _bind_test_session_factory(monkeypatch) try: person = await _mk_person(db_session) integ = await _mk_integration(db_session) scope = await _mk_scope( db_session, integration_id=str(integ.id), external_id="448141240262", env="dev", ) delete_mock = AsyncMock(return_value=CloudGrantResult(ok=True)) driver = _fake_driver( [PrincipalAssignment( account_id="448141240262", principal_id=_PRINCIPAL, role_key=_ROLE, role_display_name=_ROLE_NAME, )], delete_mock, ) monkeypatch.setattr( _ls, "_load_cloud_driver", AsyncMock(return_value=driver), ) result = await sweep_leaver_sso_assignments( person.id, actor_user_id=None, trace_id="test-sweep", dry_run=True, ) assert result.dry_run is True assert len(result.results) == 1 assert result.results[0].outcome == OUTCOME_WOULD_DELETE assert result.would_delete_count == 1 # No provider delete, no history row. assert delete_mock.await_count == 0 rows = (await db_session.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == str(scope.id), ) )).scalars().all() assert rows == [] finally: await engine.dispose() @pytest.mark.asyncio async def test_sweep_updates_preexisting_revoked_grant_no_collision( db_session: AsyncSession, monkeypatch, ): """Regression: a prior REVOKED grant for the SAME (scope, person, role) must be UPDATED in place, not INSERTed — a blind insert collides with ``uq_cmdb_cloud_grants_scope_person_role`` → IntegrityError → false CRITICAL. This is the PR's own core "re-added via console after a prior Helix grant" case, and the module's documented re-run idempotency.""" engine = _bind_test_session_factory(monkeypatch) try: person = await _mk_person(db_session) integ = await _mk_integration(db_session) scope = await _mk_scope( db_session, integration_id=str(integ.id), external_id="448141240262", env="dev", ) # A previously-revoked Helix grant already occupies the # (scope, person, role) unique key. db_session.add(CmdbCloudAccessGrant( id=uuid.uuid4(), scope_id=str(scope.id), person_id=str(person.id), role_key=_ROLE, status=GRANT_STATUS_REVOKED, revoke_reason="prior_manual_revoke", )) await db_session.commit() delete_mock = AsyncMock(return_value=CloudGrantResult(ok=True)) driver = _fake_driver( [PrincipalAssignment( account_id="448141240262", principal_id=_PRINCIPAL, role_key=_ROLE, role_display_name=_ROLE_NAME, )], delete_mock, ) monkeypatch.setattr( _ls, "_load_cloud_driver", AsyncMock(return_value=driver), ) result = await sweep_leaver_sso_assignments( person.id, actor_user_id=None, trace_id="test-sweep", ) # Cloud delete happened and the history write succeeded (no error). assert result.results[0].outcome == OUTCOME_DELETED assert result.error_count == 0 assert delete_mock.await_count == 1 # Still exactly ONE row for the (scope, person, role) key — updated # in place, not duplicated — now stamped with the sweep reason. rows = (await db_session.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == str(scope.id), CmdbCloudAccessGrant.person_id == str(person.id), CmdbCloudAccessGrant.role_key == _ROLE, ) )).scalars().all() assert len(rows) == 1 assert rows[0].revoke_reason == GRANT_REVOKE_REASON_LEAVER_PRINCIPAL_SWEPT assert rows[0].external_principal_id == _PRINCIPAL finally: await engine.dispose() @pytest.mark.asyncio async def test_sweep_upgrades_orphan_cleanup_row_by_principal_id( db_session: AsyncSession, monkeypatch, ): """Regression: an ``orphan_cleanup`` history row (person_id=NULL, same principal_id) collides with ``uq_cmdb_cloud_grants_scope_role_extprincipal`` on a blind insert. The sweep must UPDATE it in place, upgrading the NULL person_id to the real, attributable one.""" engine = _bind_test_session_factory(monkeypatch) try: person = await _mk_person(db_session) integ = await _mk_integration(db_session) scope = await _mk_scope( db_session, integration_id=str(integ.id), external_id="448141240262", env="dev", ) # An orphan_cleanup row: NULL person_id, keyed by principal_id. db_session.add(CmdbCloudAccessGrant( id=uuid.uuid4(), scope_id=str(scope.id), person_id=None, role_key=_ROLE, status=GRANT_STATUS_REVOKED, external_principal_id=_PRINCIPAL, source="discovered", )) await db_session.commit() delete_mock = AsyncMock(return_value=CloudGrantResult(ok=True)) driver = _fake_driver( [PrincipalAssignment( account_id="448141240262", principal_id=_PRINCIPAL, role_key=_ROLE, role_display_name=_ROLE_NAME, )], delete_mock, ) monkeypatch.setattr( _ls, "_load_cloud_driver", AsyncMock(return_value=driver), ) result = await sweep_leaver_sso_assignments( person.id, actor_user_id=None, trace_id="test-sweep", ) assert result.results[0].outcome == OUTCOME_DELETED assert result.error_count == 0 rows = (await db_session.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == str(scope.id), CmdbCloudAccessGrant.role_key == _ROLE, ) )).scalars().all() # One row, its NULL person_id upgraded to the real leaver. assert len(rows) == 1 assert rows[0].person_id == str(person.id) assert rows[0].revoke_reason == GRANT_REVOKE_REASON_LEAVER_PRINCIPAL_SWEPT finally: await engine.dispose() @pytest.mark.asyncio async def test_sweep_already_absent_not_counted_as_deleted( db_session: AsyncSession, monkeypatch, ): """Regression (F6): a driver reporting ``already_absent`` deleted nothing this run, so it must NOT inflate ``deleted_count`` — it belongs to ``already_absent_count`` / ``reconciled_count``.""" engine = _bind_test_session_factory(monkeypatch) try: person = await _mk_person(db_session) integ = await _mk_integration(db_session) await _mk_scope( db_session, integration_id=str(integ.id), external_id="448141240262", env="dev", ) delete_mock = AsyncMock( return_value=CloudGrantResult(ok=True, already_absent=True), ) driver = _fake_driver( [PrincipalAssignment( account_id="448141240262", principal_id=_PRINCIPAL, role_key=_ROLE, role_display_name=_ROLE_NAME, )], delete_mock, ) monkeypatch.setattr( _ls, "_load_cloud_driver", AsyncMock(return_value=driver), ) result = await sweep_leaver_sso_assignments( person.id, actor_user_id=None, trace_id="test-sweep", ) assert result.results[0].outcome == OUTCOME_ALREADY_ABSENT assert result.deleted_count == 0 assert result.already_absent_count == 1 assert result.reconciled_count == 1 finally: await engine.dispose()