"""Regression: scope owner can self-transfer to a successor without needing IT to play middleman. Policy decision (2026-05-06): owner self-transfer is allowed because the current owner is the right party to hand off the cloud account. The outer ``_require_scope_owner_or_admin`` gate already proves the caller is owner or cloud-admin, so transfer can only come from someone with legitimate management authority. Privilege escalation is bounded: once owner A transfers to B, only B (or an admin) can transfer again — A cannot reclaim self-service. Service-layer guard rails carry over from the prior admin-only path: * Leaver / pending-leaver targets rejected with HTTP 409 ``SCOPE_OWNER_LEAVER_FORBIDDEN`` (offboarding invariant — no active leaver owns active resources). * Every rebind audited via ``cloud.scope.assign_owner``. The corresponding UI surface lives in ``MyCloudScopeDetailPage`` Settings tab → "转让 Owner" Popconfirm → Modal with the ``listPickablePeopleForScope`` roster. """ from __future__ import annotations import uuid from datetime import UTC, datetime import pytest from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog 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 _make_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.flush() return p async def _make_integration(db: AsyncSession) -> Integration: i = Integration( id=uuid.uuid4(), kind="cloud", provider="azure", name="azure-test", slug=f"azure-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["servers"], ) db.add(i) await db.commit() return i async def _make_scope( db: AsyncSession, integration: Integration, owner_id: uuid.UUID, ) -> CmdbCloudScope: s = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integration.id), scope_type="azure_subscription", external_id=uuid.uuid4().hex[:16], display_name=f"scope-{uuid.uuid4().hex[:6]}", status=SCOPE_STATUS_ACTIVE, owner_person_id=str(owner_id), first_seen_at=datetime.now(UTC), last_synced_at=datetime.now(UTC), environment="dev", ) db.add(s) await db.commit() return s @pytest.mark.asyncio async def test_owner_can_self_transfer( client: AsyncClient, db_session: AsyncSession, ): """Current owner PATCHes ``owner_person_id`` to a teammate → 200, audit row written, scope row reflects new owner.""" from app.core.security import create_access_token owner = await _make_person(db_session, email="owner@test.com") successor = await _make_person(db_session, email="successor@test.com") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, owner.id) owner_token = create_access_token(str(owner.id), ["employee"]) resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"owner_person_id": str(successor.id)}, headers={"Authorization": f"Bearer {owner_token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["data"]["owner_person_id"] == str(successor.id) # Audit row written. rows = (await db_session.execute( select(AuditLog).where( AuditLog.resource_type == "cmdb_cloud_scope", AuditLog.resource_id == str(scope.id), AuditLog.action == "cloud.scope.assign_owner", ) )).scalars().all() assert len(rows) == 1 assert rows[0].detail["previous_owner_person_id"] == str(owner.id) assert rows[0].detail["new_owner_person_id"] == str(successor.id) @pytest.mark.asyncio async def test_non_owner_employee_cannot_transfer( client: AsyncClient, db_session: AsyncSession, ): """A random employee (not owner, not admin, no grant) cannot rebind the scope's owner. Outer ``_require_scope_owner_or_admin`` gate blocks them.""" from app.core.security import create_access_token owner = await _make_person(db_session, email="owner2@test.com") bystander = await _make_person(db_session, email="bystander@test.com") successor = await _make_person(db_session, email="successor2@test.com") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, owner.id) bystander_token = create_access_token(str(bystander.id), ["employee"]) resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"owner_person_id": str(successor.id)}, headers={"Authorization": f"Bearer {bystander_token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_owner_cannot_transfer_to_leaver( client: AsyncClient, db_session: AsyncSession, ): """Service-layer leaver guard still applies on the owner-self path — owner cannot dump the scope on someone whose accounts are about to be wiped on T+0. 409 with SCOPE_OWNER_LEAVER_FORBIDDEN.""" from app.core.security import create_access_token owner = await _make_person(db_session, email="owner3@test.com") leaver = IdentityPerson( id=uuid.uuid4(), work_email="leaver@test.com", display_name="leaver", employment_status="left", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", ) db_session.add(leaver) await db_session.commit() integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, owner.id) owner_token = create_access_token(str(owner.id), ["employee"]) resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"owner_person_id": str(leaver.id)}, headers={"Authorization": f"Bearer {owner_token}"}, ) assert resp.status_code == 409 body = resp.json() assert body["errors"][0]["code"] == "SCOPE_OWNER_LEAVER_FORBIDDEN" @pytest.mark.asyncio async def test_owner_still_cannot_change_environment_or_status( client: AsyncClient, db_session: AsyncSession, ): """Owner-self-transfer is the only loosened axis. ``environment`` and ``status`` stay admin-gated because they bypass the T+0 auto-revoke / decommission flow.""" from app.core.security import create_access_token owner = await _make_person(db_session, email="owner4@test.com") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, owner.id) owner_token = create_access_token(str(owner.id), ["employee"]) # environment resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"environment": "prod"}, headers={"Authorization": f"Bearer {owner_token}"}, ) assert resp.status_code == 403 # status (schema only allows active/suspended; closed goes through # the dedicated decommission endpoint). resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"status": "suspended"}, headers={"Authorization": f"Bearer {owner_token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_self_rebind_is_silent_noop( client: AsyncClient, db_session: AsyncSession, ): """``owner_person_id == current owner`` is a tolerated no-op: the HTTP call returns 200 + the unchanged scope, but no audit row is written. Prevents an attacker from spamming the audit table with self-rebinds to bury a real attack underneath.""" from app.core.security import create_access_token owner = await _make_person(db_session, email="self_owner@test.com") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, owner.id) owner_token = create_access_token(str(owner.id), ["employee"]) resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"owner_person_id": str(owner.id)}, # same as current headers={"Authorization": f"Bearer {owner_token}"}, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["owner_person_id"] == str(owner.id) rows = (await db_session.execute( select(AuditLog).where( AuditLog.resource_type == "cmdb_cloud_scope", AuditLog.resource_id == str(scope.id), AuditLog.action == "cloud.scope.assign_owner", ) )).scalars().all() assert rows == [], ( "Self-rebind must not write an audit row " "(otherwise a malicious caller can flood the audit log)." ) @pytest.mark.asyncio async def test_closed_scope_rebind_rejected( client: AsyncClient, db_session: AsyncSession, ): """Closed scopes are decommissioned — the cloud account is closed or closing on the provider side. Rebinding a dead account adds a misleading audit row and confuses leaver-blocked sweeps that key on ``status == active``. Reject with 409 SCOPE_OWNER_REBIND_CLOSED_FORBIDDEN.""" from app.core.security import create_access_token from app.domains.cmdb.models import SCOPE_STATUS_CLOSED owner = await _make_person(db_session, email="owner_closed@test.com") successor = await _make_person(db_session, email="next_owner_closed@test.com") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, owner.id) scope.status = SCOPE_STATUS_CLOSED await db_session.commit() owner_token = create_access_token(str(owner.id), ["employee"]) resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"owner_person_id": str(successor.id)}, headers={"Authorization": f"Bearer {owner_token}"}, ) # Note: the outer ``_require_scope_owner_or_admin`` gate already # rejects employees on closed scopes (the page is read-only at # the UI level too — see MyCloudScopeDetailPage's ``isOwner`` # check on ``scope.status !== 'closed'``). For an admin caller # the service-layer guard is what kicks in. Either way the rebind # cannot land — assert one of the two refusal codes. assert resp.status_code in (403, 409), resp.text @pytest.mark.asyncio async def test_admin_rebinding_closed_scope_rejected_at_service_layer( client: AsyncClient, db_session: AsyncSession, admin_user, ): """Admin path-only — the outer gate doesn't reject admins on closed scopes (admins can still see / decommission closed rows), but the service-layer ``SCOPE_OWNER_REBIND_CLOSED_FORBIDDEN`` guard does. Pin that boundary.""" from app.domains.cmdb.models import SCOPE_STATUS_CLOSED owner = await _make_person(db_session, email="dead_scope_owner@test.com") successor = await _make_person(db_session, email="dead_scope_succ@test.com") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, owner.id) scope.status = SCOPE_STATUS_CLOSED await db_session.commit() _, admin_token = admin_user resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"owner_person_id": str(successor.id)}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 409, resp.text body = resp.json() assert body["errors"][0]["code"] == "SCOPE_OWNER_REBIND_CLOSED_FORBIDDEN" @pytest.mark.asyncio async def test_transfer_to_unknown_person_id_returns_404( client: AsyncClient, db_session: AsyncSession, ): """``new_owner_person_id`` resolves via ``db.get(IdentityPerson)`` which returns None for a never-existed UUID. Service raises ``NotFoundError("IdentityPerson", id)`` — pin that the route surfaces as a clean 404 envelope (not a 500 / generic INTERNAL_ERROR).""" from app.core.security import create_access_token owner = await _make_person(db_session, email="owner_404@test.com") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, owner.id) owner_token = create_access_token(str(owner.id), ["employee"]) resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"owner_person_id": str(uuid.uuid4())}, # never existed headers={"Authorization": f"Bearer {owner_token}"}, ) assert resp.status_code == 404, resp.text body = resp.json() assert body["success"] is False assert body["errors"][0]["code"] in ("NOT_FOUND", "RESOURCE_NOT_FOUND") @pytest.mark.asyncio async def test_post_transfer_original_owner_loses_management( client: AsyncClient, db_session: AsyncSession, ): """Bounded escalation: once A transfers to B, A can no longer transfer the scope back. The next PATCH from A is gated at the outer ``_require_scope_owner_or_admin`` check (A is no longer the owner, A is not an admin).""" from app.core.security import create_access_token a = await _make_person(db_session, email="a@test.com") b = await _make_person(db_session, email="b@test.com") c = await _make_person(db_session, email="c@test.com") integration = await _make_integration(db_session) scope = await _make_scope(db_session, integration, a.id) a_token = create_access_token(str(a.id), ["employee"]) # Step 1 — A transfers to B. resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"owner_person_id": str(b.id)}, headers={"Authorization": f"Bearer {a_token}"}, ) assert resp.status_code == 200 # Step 2 — A tries to reclaim by transferring to C — must 403. resp = await client.patch( f"/api/v1/cmdb/scopes/{scope.id}", json={"owner_person_id": str(c.id)}, headers={"Authorization": f"Bearer {a_token}"}, ) assert resp.status_code == 403