"""Tests for reverse grants discovery + owner auto-assignment. End-to-end: patch the cloud driver's ``list_scope_grants`` with canned ``DiscoveredGrant`` rows, run ``sync_grants_for_integration``, assert the expected ``CmdbCloudAccessGrant`` + ``CmdbCloudScope.owner_person_id`` state. We intentionally test at the service layer (not per-driver) because the per-driver implementations are exercised by their own respx/mock suites — here we lock in the shared upsert + auto-owner semantics every provider relies on. """ from __future__ import annotations import uuid from contextlib import asynccontextmanager from datetime import UTC as _UTC from datetime import datetime as _datetime from unittest.mock import patch import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.lifecycle_service import DISCOVERY_TTL_JITTER_DAYS from app.domains.cmdb.models import ( GRANT_STATUS_ACTIVE, SCOPE_STATUS_ACTIVE, CmdbCloudAccessGrant, CmdbCloudRole, CmdbCloudScope, ) from app.identity.person_models import IdentityPerson from app.integrations.driver_base import ( CloudScopeRef, DiscoveredGrant, ) from app.integrations.models import Integration async def _make_person(db: AsyncSession, *, email: str, name: str) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=name, employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", ) db.add(p) await db.commit() return p async def _make_integration(db: AsyncSession, *, provider: str = "azure") -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider=provider, name=f"{provider}-grants-test", slug=f"{provider}-grants-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["servers"], ) db.add(row) await db.commit() return row async def _make_scope( db: AsyncSession, integration: Integration, *, external_id: str, scope_type: str = "azure_subscription", owner_person_id: str | None = None, ) -> CmdbCloudScope: s = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integration.id), scope_type=scope_type, external_id=external_id, display_name=external_id, status=SCOPE_STATUS_ACTIVE, owner_person_id=owner_person_id, ) db.add(s) await db.commit() return s def _patch_session_factory(db_session: AsyncSession): """Pipe every ``async with async_session_factory()`` inside the service through the test's session. The service imported the name at module load, so the patch target is the service's namespace.""" @asynccontextmanager async def _factory(): yield db_session return patch( "app.domains.cmdb.lifecycle_service.async_session_factory", new=_factory, ) class _FakeDriver: """Stands in for any CloudDriver; only exposes what sync_grants_for_integration actually calls. """ provider = "azure" grants_scope_wide = True def __init__(self, grants_by_scope: dict[str, list[DiscoveredGrant]]) -> None: self._grants_by_scope = grants_by_scope self.owner_role_keys = frozenset({"owner-role"}) async def list_scope_grants(self, scope: CloudScopeRef): return list(self._grants_by_scope.get(scope.external_id, [])) async def close(self) -> None: pass def _patch_driver(driver: _FakeDriver): async def _loader(session, integration): return driver return patch( "app.domains.cmdb.lifecycle_service._load_cloud_driver", side_effect=_loader, ) # ── Discovery + upsert ───────────────────────────────────────── @pytest.mark.asyncio async def test_sync_inserts_new_discovered_grant(db_session: AsyncSession) -> None: from app.domains.cmdb.lifecycle_service import sync_grants_for_integration person = await _make_person(db_session, email="alice@example.com", name="Alice") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, external_id="sub-1") # Snapshot ids before crossing the service boundary (session resets # on commit inside the service). scope_id = str(scope.id) person_id = str(person.id) integration_id = str(integration.id) driver = _FakeDriver({ "sub-1": [DiscoveredGrant( principal_email="alice@example.com", principal_type="user", role_key="Reader", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-1"), external_ref="sub-1::ra-abc", )], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) assert result["status"] == "success" assert result["upserted"] == 1 assert result["touched"] == 0 rows = (await db_session.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == scope_id, ) )).scalars().all() assert len(rows) == 1 r = rows[0] assert r.person_id == person_id assert r.role_key == "Reader" assert r.source == "discovered" assert r.status == GRANT_STATUS_ACTIVE assert r.external_ref == "sub-1::ra-abc" assert r.last_seen_grants_sync_job_id is not None # New: discovered grants get expires_at = now + scope's default TTL # so the expiry sweep actually catches them (previously NULL → # auto-reclaim silently skipped every cloud-side role binding). assert r.expires_at is not None # MySQL returns offset-naive datetimes even for TIMESTAMP cols; # compare in UTC space regardless of how the driver rendered it. ea = r.expires_at if r.expires_at.tzinfo else r.expires_at.replace(tzinfo=_UTC) delta_days = (ea - _datetime.now(_UTC)).days # Default scope TTL is 90, but since 2026-07-29 the TTL stamped at # discovery is a CEILING with a deterministic backwards jitter of up to # DISCOVERY_TTL_JITTER_DAYS (14), floored at half the TTL. Every row # inserted by one sync run used to share ``now`` exactly, so onboarding # an account scheduled its whole membership to expire in the same # second — 164 grants on one instant for aws-account 385595570414, # which produced an 861-card reminder storm at T-14 and a revoke cliff # at T+0. See ``_discovery_ttl_expires_at``; the spread itself is # asserted in test_grant_renewal_approval.py. # Jitter never ADDS, so 91 stays the upper bound (+1 for clock slop). assert 90 - DISCOVERY_TTL_JITTER_DAYS <= delta_days <= 91 @pytest.mark.asyncio async def test_sync_uses_role_catalog_name_for_new_and_existing_grants( db_session: AsyncSession, ) -> None: """Catalog names repair raw provider role identifiers for every cloud. AWS Identity Center emits permission-set ARNs in assignment listings, but this is deliberately provider-neutral: any cloud whose grant discovery omits a name must render the curated role-catalog label instead. """ from app.domains.cmdb.lifecycle_service import sync_grants_for_integration person = await _make_person(db_session, email="sre@example.com", name="SRE") new_person = await _make_person( db_session, email="new-sre@example.com", name="New SRE", ) integration = await _make_integration(db_session, provider="aws") scope = await _make_scope( db_session, integration, external_id="123456789012", scope_type="aws_account", ) role_key = "arn:aws:sso:::permissionSet/ssoins-test/ps-sre-admin" db_session.add(CmdbCloudRole( id=uuid.uuid4(), integration_id=str(integration.id), scope_id=None, role_key=role_key, display_name="SRE-Admin", )) existing = CmdbCloudAccessGrant( id=uuid.uuid4(), scope_id=str(scope.id), person_id=str(person.id), role_key=role_key, role_display_name=None, status=GRANT_STATUS_ACTIVE, source="discovered", ) db_session.add(existing) await db_session.commit() driver = _FakeDriver({ "123456789012": [DiscoveredGrant( principal_email="sre@example.com", principal_type="user", role_key=role_key, scope=CloudScopeRef(scope_type="aws_account", external_id="123456789012"), ), DiscoveredGrant( principal_email="new-sre@example.com", principal_type="user", role_key=role_key, scope=CloudScopeRef(scope_type="aws_account", external_id="123456789012"), )], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(str(integration.id)) assert result["status"] == "success" await db_session.refresh(existing) assert existing.role_display_name == "SRE-Admin" new_grant = await db_session.scalar( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == str(scope.id), CmdbCloudAccessGrant.person_id == str(new_person.id), CmdbCloudAccessGrant.role_key == role_key, ) ) assert new_grant is not None assert new_grant.role_display_name == "SRE-Admin" @pytest.mark.asyncio async def test_sync_touches_existing_helix_row_but_does_not_change_source( db_session: AsyncSession, ) -> None: """When grants_sync rediscovers a row originally issued by Helix, it should refresh the ``last_seen_grants_sync_*`` markers but keep ``source='helix'`` and leave ``external_ref`` / ``detail`` intact so the Helix-approved grant's canonical state wins.""" from app.domains.cmdb.lifecycle_service import sync_grants_for_integration person = await _make_person(db_session, email="bob@example.com", name="Bob") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, external_id="sub-2") existing = CmdbCloudAccessGrant( id=uuid.uuid4(), scope_id=str(scope.id), person_id=str(person.id), role_key="Reader", status=GRANT_STATUS_ACTIVE, source="helix", external_ref="helix-issued-ref", detail={"created_by": "helix"}, ) db_session.add(existing) await db_session.commit() existing_id = existing.id scope_id = str(scope.id) person_id = str(person.id) integration_id = str(integration.id) driver = _FakeDriver({ "sub-2": [DiscoveredGrant( principal_email="bob@example.com", principal_type="user", role_key="Reader", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-2"), external_ref="cloud-side-ref", # different! raw={"cloud": "info"}, )], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) assert result["touched"] == 1 assert result["upserted"] == 0 db_session.expire_all() refreshed = (await db_session.execute( select(CmdbCloudAccessGrant).where(CmdbCloudAccessGrant.id == existing_id) )).scalars().one() # Source kept, external_ref kept (helix-owned); sync marker touched. assert refreshed.source == "helix" assert refreshed.external_ref == "helix-issued-ref" assert refreshed.detail == {"created_by": "helix"} assert refreshed.last_seen_grants_sync_job_id is not None _ = (scope_id, person_id) @pytest.mark.asyncio async def test_sync_skips_unresolved_email(db_session: AsyncSession) -> None: """Grants whose principal_email doesn't match an active IdentityPerson must NOT create orphan grant rows.""" from app.domains.cmdb.lifecycle_service import sync_grants_for_integration integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, external_id="sub-3") integration_id = str(integration.id) scope_id = str(scope.id) driver = _FakeDriver({ "sub-3": [DiscoveredGrant( principal_email="ghost@example.com", # no IdentityPerson principal_type="user", role_key="Reader", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-3"), )], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) assert result["upserted"] == 0 assert result["skipped_unresolved"] == 1 rows = (await db_session.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == scope_id, ) )).scalars().all() assert rows == [] @pytest.mark.asyncio async def test_sync_ignores_non_user_principals(db_session: AsyncSession) -> None: """Service principals / groups flow through the driver result but never become grant rows.""" from app.domains.cmdb.lifecycle_service import sync_grants_for_integration integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, external_id="sub-4") integration_id = str(integration.id) scope_id = str(scope.id) driver = _FakeDriver({ "sub-4": [ DiscoveredGrant( principal_email="terraform-sp@tenant.onmicrosoft.com", principal_type="service_principal", role_key="Reader", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-4"), ), DiscoveredGrant( principal_email="engineers@example.com", principal_type="group", role_key="Reader", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-4"), ), ], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) assert result["upserted"] == 0 assert result["skipped_unresolved"] == 0 # filtered before email-resolution rows = (await db_session.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == scope_id, ) )).scalars().all() assert rows == [] # ── Owner auto-assignment ────────────────────────────────────── @pytest.mark.asyncio async def test_owner_auto_assigned_when_exactly_one_owner_role_holder( db_session: AsyncSession, ) -> None: from app.domains.cmdb.lifecycle_service import sync_grants_for_integration person = await _make_person(db_session, email="carol@example.com", name="Carol") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, external_id="sub-5") scope_id = str(scope.id) person_id = str(person.id) integration_id = str(integration.id) driver = _FakeDriver({ "sub-5": [DiscoveredGrant( principal_email="carol@example.com", principal_type="user", role_key="owner-role", # matches driver.owner_role_keys scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-5"), )], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) assert result["auto_owners_assigned"] == 1 db_session.expire_all() refreshed_scope = (await db_session.execute( select(CmdbCloudScope).where(CmdbCloudScope.id == uuid.UUID(scope_id)) )).scalars().one() assert refreshed_scope.owner_person_id == person_id @pytest.mark.asyncio async def test_owner_auto_assigned_when_multiple_owners_picks_earliest_granted_at( db_session: AsyncSession, ) -> None: """Two people hold Owner — auto-owner picks the one whose ``granted_at`` came first. Admin can override later via the transfer-owner flow; the user would rather have a sensible default than an unowned scope (see /sync-now ticket 2026-04-24).""" from datetime import datetime from app.domains.cmdb.lifecycle_service import sync_grants_for_integration alice = await _make_person(db_session, email="alice2@example.com", name="Alice") bob = await _make_person(db_session, email="bob2@example.com", name="Bob") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, external_id="sub-6") scope_id = str(scope.id) bob_id = str(bob.id) integration_id = str(integration.id) driver = _FakeDriver({ "sub-6": [ DiscoveredGrant( principal_email="alice2@example.com", principal_type="user", role_key="owner-role", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-6"), granted_at=datetime(2025, 3, 1, tzinfo=_UTC), ), DiscoveredGrant( principal_email="bob2@example.com", principal_type="user", role_key="owner-role", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-6"), granted_at=datetime(2024, 1, 1, tzinfo=_UTC), # earlier → wins ), ], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) assert result["auto_owners_assigned"] == 1 db_session.expire_all() refreshed_scope = (await db_session.execute( select(CmdbCloudScope).where(CmdbCloudScope.id == uuid.UUID(scope_id)) )).scalars().one() assert refreshed_scope.owner_person_id == bob_id _ = alice @pytest.mark.asyncio async def test_owner_auto_assigned_falls_back_to_active_grant_when_no_owner_role( db_session: AsyncSession, ) -> None: """When nobody holds the owner-equivalent role but there is at least one active grant, the earliest active-grant holder becomes the default owner. The admin can hand it over later; 'unowned forever' is worse than 'owned by a plausible person'.""" from datetime import datetime from app.domains.cmdb.lifecycle_service import sync_grants_for_integration alice = await _make_person(db_session, email="alice3@example.com", name="Alice") bob = await _make_person(db_session, email="bob3@example.com", name="Bob") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, external_id="sub-8") scope_id = str(scope.id) alice_id = str(alice.id) integration_id = str(integration.id) driver = _FakeDriver({ "sub-8": [ DiscoveredGrant( principal_email="alice3@example.com", principal_type="user", role_key="Reader", # NOT in owner_role_keys scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-8"), granted_at=datetime(2024, 5, 1, tzinfo=_UTC), # earlier → wins ), DiscoveredGrant( principal_email="bob3@example.com", principal_type="user", role_key="Contributor", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-8"), granted_at=datetime(2025, 2, 1, tzinfo=_UTC), ), ], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) assert result["auto_owners_assigned"] == 1 db_session.expire_all() refreshed_scope = (await db_session.execute( select(CmdbCloudScope).where(CmdbCloudScope.id == uuid.UUID(scope_id)) )).scalars().one() assert refreshed_scope.owner_person_id == alice_id _ = bob @pytest.mark.asyncio async def test_owner_picks_deterministically_when_cloud_has_no_timestamps( db_session: AsyncSession, ) -> None: """Cloud providers that don't surface ``granted_at`` (we fall to lexicographic ``person_id`` tiebreak) must still produce a single stable pick, not an unowned scope.""" from app.domains.cmdb.lifecycle_service import sync_grants_for_integration alice = await _make_person(db_session, email="alice4@example.com", name="Alice") bob = await _make_person(db_session, email="bob4@example.com", name="Bob") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, external_id="sub-9") # Snapshot ids before the service commits + our expire_all() so # we don't hit MissingGreenlet on lazy reload. scope_id = str(scope.id) alice_id = str(alice.id) bob_id = str(bob.id) integration_id = str(integration.id) driver = _FakeDriver({ "sub-9": [ DiscoveredGrant( principal_email="alice4@example.com", principal_type="user", role_key="owner-role", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-9"), granted_at=None, ), DiscoveredGrant( principal_email="bob4@example.com", principal_type="user", role_key="owner-role", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-9"), granted_at=None, ), ], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) assert result["auto_owners_assigned"] == 1 db_session.expire_all() refreshed_scope = (await db_session.execute( select(CmdbCloudScope).where(CmdbCloudScope.id == uuid.UUID(scope_id)) )).scalars().one() # Deterministic pick — whichever of alice/bob has the smaller UUID # string wins. Stable across runs. candidate_ids = {alice_id, bob_id} assert refreshed_scope.owner_person_id in candidate_ids expected = min(candidate_ids) assert refreshed_scope.owner_person_id == expected @pytest.mark.asyncio async def test_owner_not_overwritten_when_scope_already_owned( db_session: AsyncSession, ) -> None: """If a scope has an owner already, grants_sync must NOT overwrite it even when exactly one person holds the Owner role (admins may have intentionally assigned a different owner).""" from app.domains.cmdb.lifecycle_service import sync_grants_for_integration pre_owner = await _make_person(db_session, email="preowner@example.com", name="Pre") cloud_owner = await _make_person(db_session, email="cloudowner@example.com", name="Cloud") integration = await _make_integration(db_session) scope = await _make_scope( db_session, integration, external_id="sub-7", owner_person_id=str(pre_owner.id), ) scope_id = str(scope.id) pre_owner_id = str(pre_owner.id) integration_id = str(integration.id) driver = _FakeDriver({ "sub-7": [DiscoveredGrant( principal_email="cloudowner@example.com", principal_type="user", role_key="owner-role", scope=CloudScopeRef(scope_type="azure_subscription", external_id="sub-7"), )], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) assert result["auto_owners_assigned"] == 0 db_session.expire_all() refreshed_scope = (await db_session.execute( select(CmdbCloudScope).where(CmdbCloudScope.id == uuid.UUID(scope_id)) )).scalars().one() assert refreshed_scope.owner_person_id == pre_owner_id _ = cloud_owner # ── Tenant-wide driver semantics ─────────────────────────────── @pytest.mark.asyncio async def test_tenant_wide_driver_emits_grants_once_for_all_scopes( db_session: AsyncSession, ) -> None: """Drivers with ``grants_scope_wide=False`` (Aliyun/Tencent) are called once, and the returned grants fan out onto every scope.""" from app.domains.cmdb.lifecycle_service import sync_grants_for_integration person = await _make_person(db_session, email="dave@example.com", name="Dave") integration = await _make_integration(db_session, provider="aliyun") scope_a = await _make_scope( db_session, integration, external_id="acct-a", scope_type="aliyun_account", ) scope_b = await _make_scope( db_session, integration, external_id="acct-b", scope_type="aliyun_account", ) integration_id = str(integration.id) scope_a_id = str(scope_a.id) scope_b_id = str(scope_b.id) class _TenantWideDriver(_FakeDriver): grants_scope_wide = False driver = _TenantWideDriver({ # Tenant-wide driver emits on whichever scope it's asked about; # the service iterates with only the first scope. "acct-a": [DiscoveredGrant( principal_email="dave@example.com", principal_type="user", role_key="Reader", scope=CloudScopeRef(scope_type="aliyun_account", external_id="acct-a"), )], }) with _patch_session_factory(db_session), _patch_driver(driver): result = await sync_grants_for_integration(integration_id) # Once from the first scope + once fanned out to scope_b. assert result["upserted"] == 2 a_rows = (await db_session.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == scope_a_id, ) )).scalars().all() b_rows = (await db_session.execute( select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.scope_id == scope_b_id, ) )).scalars().all() assert len(a_rows) == 1 assert len(b_rows) == 1 _ = person