"""PR3 — Bucket B: elevated/restricted roles require a cloud-admin approver. A scope owner who is a regular employee must not be able to approve a privilege-escalation grant (Owner / editor / Contributor / PowerUser / IAM-write). ``approve_grant`` classifies every pending sibling and refuses unless the approver holds ``can_manage_cloud``. Normal roles stay owner-approvable. """ from __future__ import annotations import uuid import pytest from sqlalchemy.ext.asyncio import AsyncSession # ensure the gcp driver registers import app.integrations.drivers.gcp_cloud # noqa: F401 from app.core.exceptions import AppException from app.domains.cmdb.lifecycle_service import ( CloudLifecycleService, LifecycleGrantOutcome, ) from app.domains.cmdb.models import SCOPE_STATUS_ACTIVE, CmdbCloudScope from app.identity.person_models import IdentityPerson from app.integrations.models import Integration async def _person(db: AsyncSession, email: str) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=email.split("@")[0], 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 _gcp_scope(db: AsyncSession, owner: IdentityPerson) -> CmdbCloudScope: integ = Integration( id=uuid.uuid4(), kind="cloud", provider="gcp", name="gcp-appr-test", slug=f"gcp-appr-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["servers"], ) db.add(integ) await db.commit() scope = CmdbCloudScope( integration_id=str(integ.id), scope_type="gcp_project", external_id=f"proj-{uuid.uuid4().hex[:6]}", display_name="appr-test", owner_person_id=str(owner.id), status=SCOPE_STATUS_ACTIVE, ) db.add(scope) await db.commit() return scope async def _pending(svc, scope, requester, role_key, actor_roles): return await svc.submit_access_request( scope_id=str(scope.id), person_id=str(requester.id), role_key=role_key, role_display_name=role_key, reason="t", ttl_days=30, requester_id=requester.id, trace_id="t", actor_role_names=actor_roles, ) def _stub_dispatch(svc): """Patch _dispatch_grant so the positive path doesn't hit a real cloud API; records that it ran.""" calls = [] async def _fake(*, grant, scope, ttl_days, actor_user_id, trace_id, action): # noqa: ARG001 calls.append(grant.role_key) return LifecycleGrantOutcome(grant_id=str(grant.id), status="active") svc._dispatch_grant = _fake # type: ignore[method-assign] return calls @pytest.mark.asyncio async def test_approve_elevated_blocked_for_non_admin_owner(db_session): owner = await _person(db_session, "ao1@pingcap.com") emp = await _person(db_session, "ae1@pingcap.com") scope = await _gcp_scope(db_session, owner) svc = CloudLifecycleService(db_session) grant = await _pending(svc, scope, emp, "roles/iam.serviceAccountUser", []) calls = _stub_dispatch(svc) with pytest.raises(AppException) as ei: await svc.approve_grant( str(grant.id), approver_id=owner.id, trace_id="t", approver_role_names=[], # owner, non-admin ) assert ei.value.status_code == 403 assert ei.value.code == "CLOUD_APPROVAL_NEEDS_ADMIN" assert calls == [] # gate fired before any dispatch @pytest.mark.asyncio async def test_approve_restricted_blocked_for_non_admin_owner(db_session): owner = await _person(db_session, "ao2@pingcap.com") admin = await _person(db_session, "aa2@pingcap.com") scope = await _gcp_scope(db_session, owner) svc = CloudLifecycleService(db_session) # restricted can only be SUBMITTED by an admin; submit as admin grant = await _pending(svc, scope, admin, "roles/owner", ["cloud_admin"]) calls = _stub_dispatch(svc) with pytest.raises(AppException) as ei: await svc.approve_grant( str(grant.id), approver_id=owner.id, trace_id="t", approver_role_names=[], ) assert ei.value.status_code == 403 assert calls == [] @pytest.mark.asyncio async def test_approve_elevated_allowed_for_cloud_admin(db_session): owner = await _person(db_session, "ao3@pingcap.com") emp = await _person(db_session, "ae3@pingcap.com") approver = await _person(db_session, "ad3@pingcap.com") scope = await _gcp_scope(db_session, owner) svc = CloudLifecycleService(db_session) grant = await _pending(svc, scope, emp, "roles/iam.serviceAccountUser", []) calls = _stub_dispatch(svc) out = await svc.approve_grant( str(grant.id), approver_id=approver.id, trace_id="t", approver_role_names=["cloud_admin"], ) assert out is not None assert "roles/iam.serviceAccountUser" in calls # passed gate → dispatched @pytest.mark.asyncio async def test_approve_normal_allowed_for_non_admin_owner(db_session): owner = await _person(db_session, "ao4@pingcap.com") emp = await _person(db_session, "ae4@pingcap.com") scope = await _gcp_scope(db_session, owner) svc = CloudLifecycleService(db_session) grant = await _pending(svc, scope, emp, "roles/viewer", []) calls = _stub_dispatch(svc) out = await svc.approve_grant( str(grant.id), approver_id=owner.id, trace_id="t", approver_role_names=[], # owner can approve a normal role ) assert out is not None assert "roles/viewer" in calls