"""Classifying a cloud IAM account as human or service. Every compliance surface treats an IAM user with no ``owner_person_id`` as an orphan, and two different problems land in that bucket: * a colleague Helix failed to match — act on it; * a robot that will never match — stop asking. Prod Tencent (2026-08-12) has both: ``chao.wu@pingcap.cn`` and ``huangbaoling@pingcap.cn`` in the first group, ``tiup-server`` and ``operator-v1-cos-upload`` in the second. With nowhere to record the difference the orphan list can never reach zero — and a list that can never reach zero stops being read, which is how two departed employees' live AdministratorAccess sat there for months. Classification is a human statement, never inferred: ``ConsoleLogin=0`` is surfaced as a hint in the UI and is deliberately not written on the admin's behalf. """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.leaver_iam_residue import detect_leaver_iam_residue from app.domains.cmdb.models import CmdbIamUser from app.identity.person_models import IdentityPerson from app.integrations.models import Integration async def _integration(db: AsyncSession) -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider="tencent", name="腾讯云", slug=f"tencent-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["iam"], ) db.add(row) await db.commit() return row async def _iam_user( db: AsyncSession, integration: Integration, *, user_name: str, owner_person_id: str | None = None, policies: list[dict] | None = None, console_login: int | None = None, account_kind: str | None = None, ) -> CmdbIamUser: now = datetime.now(UTC).replace(tzinfo=None) row = CmdbIamUser( id=uuid.uuid4(), integration_id=str(integration.id), user_name=user_name, sub_account_id="", attached_policies=policies or [], groups=[], mfa_enabled=False, tags={}, owner_person_id=owner_person_id, account_kind=account_kind, raw_data={} if console_login is None else {"ConsoleLogin": console_login}, last_synced_at=now - timedelta(hours=1), ) db.add(row) await db.commit() return row @pytest.mark.asyncio async def test_classify_marks_a_robot_and_records_who_said_so( db_session: AsyncSession, client, admin_headers: dict[str, str], ) -> None: integration = await _integration(db_session) row = await _iam_user( db_session, integration, user_name="tiup-server", console_login=0, ) resp = await client.post( f"/api/v1/cmdb/iam-users/{row.id}/classify", headers=admin_headers, json={"kind": "service", "note": "TiUP 部署用,无人对应"}, ) assert resp.status_code == 200 assert resp.json()["data"]["account_kind"] == "service" await db_session.refresh(row) assert row.account_kind == "service" assert row.account_kind_note == "TiUP 部署用,无人对应" # Who decided, and when — an access review will ask. assert row.account_kind_set_by_person_id is not None assert row.account_kind_set_at is not None @pytest.mark.asyncio async def test_unset_clears_the_classification_and_its_provenance( db_session: AsyncSession, client, admin_headers: dict[str, str], ) -> None: integration = await _integration(db_session) row = await _iam_user( db_session, integration, user_name="maybe-robot", account_kind="service", ) resp = await client.post( f"/api/v1/cmdb/iam-users/{row.id}/classify", headers=admin_headers, json={"kind": "unset"}, ) assert resp.status_code == 200 await db_session.refresh(row) assert row.account_kind is None assert row.account_kind_set_by_person_id is None assert row.account_kind_set_at is None @pytest.mark.asyncio async def test_service_accounts_leave_the_orphan_list( db_session: AsyncSession, client, admin_headers: dict[str, str], ) -> None: """``has_owner=false`` is the compliance review surface. A declared robot is not an orphan — otherwise the list can never reach zero.""" integration = await _integration(db_session) await _iam_user( db_session, integration, user_name="tiup-server", account_kind="service", ) await _iam_user(db_session, integration, user_name="chao.wu@pingcap.cn") resp = await client.get( f"/api/v1/cmdb/iam-users?integration_id={integration.id}" "&has_owner=false", headers=admin_headers, ) names = [r["user_name"] for r in resp.json()["data"]] assert "chao.wu@pingcap.cn" in names assert "tiup-server" not in names @pytest.mark.asyncio async def test_service_accounts_leave_the_leaver_residue_queue( db_session: AsyncSession, ) -> None: """A robot named after a departed colleague is still a robot. Service accounts outlive their creator by design.""" person = IdentityPerson( id=uuid.uuid4(), work_email="chujie@pingcap.com", display_name="褚杰", employment_status="left", termination_date=(datetime.now(UTC) - timedelta(days=70)).date(), source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", ) db_session.add(person) await db_session.commit() integration = await _integration(db_session) await _iam_user( db_session, integration, user_name="chujie@pingcap.com", owner_person_id=str(person.id), policies=[{"name": "AdministratorAccess", "arn": ""}], account_kind="service", ) assert await detect_leaver_iam_residue(db_session) == [] @pytest.mark.asyncio async def test_human_classification_keeps_the_row_in_the_queue( db_session: AsyncSession, client, admin_headers: dict[str, str], ) -> None: """"This is a person we have not matched yet" is work, not filing. Only ``service`` takes a row out of the compliance list.""" integration = await _integration(db_session) row = await _iam_user( db_session, integration, user_name="chao.wu@pingcap.cn", ) await client.post( f"/api/v1/cmdb/iam-users/{row.id}/classify", headers=admin_headers, json={"kind": "human", "note": "应为吴超,待确认工号"}, ) resp = await client.get( f"/api/v1/cmdb/iam-users?integration_id={integration.id}" "&has_owner=false", headers=admin_headers, ) names = [r["user_name"] for r in resp.json()["data"]] assert "chao.wu@pingcap.cn" in names @pytest.mark.asyncio async def test_console_login_is_exposed_as_a_hint_not_a_classification( db_session: AsyncSession, client, admin_headers: dict[str, str], ) -> None: """The signal is offered to the admin; it must never classify on its own. A guess rendered as a decision is how this data goes wrong.""" integration = await _integration(db_session) await _iam_user( db_session, integration, user_name="tiup-server", console_login=0, ) resp = await client.get( f"/api/v1/cmdb/iam-users?integration_id={integration.id}", headers=admin_headers, ) row = next( r for r in resp.json()["data"] if r["user_name"] == "tiup-server" ) assert row["console_login"] is False assert row["account_kind"] is None @pytest.mark.asyncio async def test_classify_rejects_an_unknown_kind( db_session: AsyncSession, client, admin_headers: dict[str, str], ) -> None: integration = await _integration(db_session) row = await _iam_user(db_session, integration, user_name="x") resp = await client.post( f"/api/v1/cmdb/iam-users/{row.id}/classify", headers=admin_headers, json={"kind": "robot"}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_overview_kpi_agrees_with_the_orphan_list( db_session: AsyncSession, client, admin_headers: dict[str, str], ) -> None: """The Cloud Overview 未匹配 tile and ``has_owner=false`` answer the same compliance question and must not disagree. Before this, classifying the two Tencent robots emptied the list while the tile kept showing 未匹配 = 2 in warning tone — which defeats the point of being able to classify them at all. """ from app.domains.cmdb.admin_overview_service import _iam_users_section integration = await _integration(db_session) await _iam_user( db_session, integration, user_name="tiup-server", account_kind="service", ) await _iam_user(db_session, integration, user_name="chao.wu@pingcap.cn") listed = await client.get( f"/api/v1/cmdb/iam-users?integration_id={integration.id}" "&has_owner=false", headers=admin_headers, ) from app.domains.cmdb.models import CmdbIamUser section = await _iam_users_section( db_session, [CmdbIamUser.integration_id == str(integration.id)], ) assert section["unowned"] == len(listed.json()["data"]) == 1