"""Tests for ``ApiKeyService.revoke_key`` — the allocation-rule disable contract added in the 2026-05-12 cost-attribution audit. When an autograny AK is revoked (Bedrock / AOAI / Bailian), the cost-allocation rule the autogrant minted on its behalf must be disabled (``enabled=False``) so the cost-ingest cron stops attributing new facts to the departed employee's email tag. Disable, don't delete: forensics + the unattributed-KPI rely on the rule row remaining inspectable. Background: production audit found that ``_revoke_cloud_resources`` tore down the cloud IAM user / Foundry account / Bailian RAM user but NEVER touched the ``CmdbCostAllocationRule``. Rules accumulated indefinitely; if any AWS principal carrying the same ``Owner=`` tag emitted cost after the AK revoke (e.g. employee re-grants from a parallel project, or AWS Cost Explorer back-fills late line items), the stale rule kept attributing the cost to that AK's owner. Production-grade per CLAUDE.md: no DB mocks, real assignment rows, real CmdbCostAllocationRule rows, real audit-log writes. The cloud-side revoke is mocked (no AWS / Azure SDK calls in unit tests) but the rule disable + audit log paths use the live SQL session. """ from __future__ import annotations import uuid from decimal import Decimal from types import SimpleNamespace import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.domains.apikey.models import ApiKeyAssignment, ApiKeyProvider from app.domains.apikey.service import ApiKeyService from app.domains.cmdb.models import CmdbCostAllocationRule from app.identity.person_models import IdentityPerson from app.integrations.models import Integration def _admin_auth(person: IdentityPerson) -> SimpleNamespace: return SimpleNamespace( id=person.id, role_names=["it_admin"], ) async def _make_person( db: AsyncSession, *, email: str = "revoker@pingcap.com", ) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", display_name=email.split("@")[0], work_email=email, employment_status="active", ) db.add(p) await db.commit() return p async def _make_provider( db: AsyncSession, *, name: str, ) -> ApiKeyProvider: p = ApiKeyProvider( id=uuid.uuid4(), name=name, description="", is_active=True, sort_order=0, ) db.add(p) await db.commit() return p async def _make_integration( db: AsyncSession, *, provider: str = "aws", ) -> Integration: integ = Integration( id=uuid.uuid4(), kind="cloud", provider=provider, name=f"{provider.upper()} test", slug=f"{provider}-revoke-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=[], config_public={}, ) db.add(integ) await db.commit() return integ async def _make_assignment_with_rule( db: AsyncSession, *, bucket_key: str, provider_name: str, applicant: IdentityPerson, integration: Integration, rule_match: dict, rule_id: str | None = None, ) -> tuple[ApiKeyAssignment, CmdbCostAllocationRule]: """Build an AK + an allocation rule + cross-reference them via ``detail..allocation_rule_id``. ``rule_match`` carries the bucket-specific match keys (Owner tag for Bedrock, RG for AOAI). """ provider = await _make_provider(db, name=provider_name) rule = CmdbCostAllocationRule( id=rule_id or str(uuid.uuid4()), name=f"{provider_name} · test-{uuid.uuid4().hex[:8]} · {applicant.work_email}", priority=10, match_integration_ids=[str(integration.id)], match_provider=integration.provider, match_resource_group=rule_match.get("match_resource_group"), match_service=None, match_tag_key=rule_match.get("match_tag_key"), match_tag_value=rule_match.get("match_tag_value"), assign_person_id=str(applicant.id), assign_department=None, assign_cost_center=None, enabled=True, description=( f"Auto-generated by helix_autogrant.{bucket_key} for the " f"test assignment. Charges tagged line items to applicant." ), ) db.add(rule) await db.commit() assignment = ApiKeyAssignment( id=uuid.uuid4(), provider_id=provider.id, assigned_to=applicant.id, status="active", request_number=f"AK-{uuid.uuid4().hex[:8].upper()}", project_name="LLM eval", reason="revoke test", monthly_budget_usd=Decimal("100.00"), iam_integration_id=str(integration.id), iam_user_arn=( f"arn:aws:iam::000000000000:user/test-{uuid.uuid4().hex[:6]}" if bucket_key == "bedrock" else None ), detail={ bucket_key: { "granted": True, "iam_user_name": ( f"test-user-{uuid.uuid4().hex[:6]}" if bucket_key in {"bedrock", "bailian"} else None ), "allocation_rule_id": rule.id, }, }, ) db.add(assignment) await db.commit() return assignment, rule @pytest.mark.asyncio async def test_revoke_bedrock_disables_allocation_rule( db_session: AsyncSession, monkeypatch, ): """Revoking a Bedrock AK must set ``enabled=False`` on the rule the autogrant minted, append a ``[disabled by revoke...]`` suffix to the description, and surface the result in the revoke audit row's ``allocation_rules_disabled`` field so SREs can audit the chain. """ person = await _make_person(db_session, email="dan-revoke@pingcap.com") integ = await _make_integration(db_session, provider="aws") assignment, rule = await _make_assignment_with_rule( db_session, bucket_key="bedrock", provider_name="AWS BedRock", applicant=person, integration=integ, rule_match={ "match_tag_key": "Owner", "match_tag_value": person.work_email, }, ) # Stub the cloud-side revoke — we don't want to hit AWS in tests. # The actual cloud teardown is covered separately in # test_integrations/test_aws_cloud_driver.py. async def fake_revoke_cloud_resources(self, assignment, *, trace_id): return {"attempted": [{"kind": "bedrock", "ok": True}], "errors": []} monkeypatch.setattr( ApiKeyService, "_revoke_cloud_resources", fake_revoke_cloud_resources, ) response = await ApiKeyService(db_session).revoke_key( assignment.id, reason="test revoke", current_user=_admin_auth(person), trace_id="t-test-revoke", ip_address="127.0.0.1", user_agent="pytest", ) assert response.status == "revoked" # Rule disabled in-place. await db_session.refresh(rule) assert rule.enabled is False assert "[disabled by revoke of " in (rule.description or "") assert assignment.request_number in (rule.description or "") # Audit row carries the disable summary so forensics can reconstruct # the chain even after the rule row itself is admin-edited. audit_rows = (await db_session.execute( select(AuditLog).where( AuditLog.action == "apikey.key.revoke", AuditLog.resource_id == str(assignment.id), ) )).scalars().all() assert len(audit_rows) == 1 detail = audit_rows[0].detail or {} disabled = detail.get("allocation_rules_disabled") or [] assert len(disabled) == 1 assert disabled[0]["bucket"] == "bedrock" assert disabled[0]["status"] == "disabled" assert disabled[0]["rule_id"] == rule.id @pytest.mark.asyncio async def test_revoke_aoai_disables_allocation_rule( db_session: AsyncSession, monkeypatch, ): """Same contract for Azure OpenAI / Foundry: revoking the AK disables the RG-targeted allocation rule the AOAI autogrant minted so future Azure line items in the (now-deleted) RG don't keep landing on the applicant's chargeback row.""" person = await _make_person(db_session, email="carol-revoke@pingcap.com") integ = await _make_integration(db_session, provider="azure") assignment, rule = await _make_assignment_with_rule( db_session, bucket_key="azure_openai", provider_name="Azure OpenAI", applicant=person, integration=integ, rule_match={"match_resource_group": "helix-aoai-FOOBAR"}, ) async def fake_revoke_cloud_resources(self, assignment, *, trace_id): return {"attempted": [{"kind": "azure_openai"}], "errors": []} monkeypatch.setattr( ApiKeyService, "_revoke_cloud_resources", fake_revoke_cloud_resources, ) await ApiKeyService(db_session).revoke_key( assignment.id, reason="test revoke aoai", current_user=_admin_auth(person), trace_id="t-aoai-revoke", ip_address="127.0.0.1", user_agent="pytest", ) await db_session.refresh(rule) assert rule.enabled is False @pytest.mark.asyncio async def test_revoke_handles_missing_rule_gracefully( db_session: AsyncSession, monkeypatch, ): """``detail..allocation_rule_id`` references a rule id that no longer exists in the DB (admin manually deleted, or rule never committed because of a partial autogrant). The revoke must NOT fail — the cloud teardown already succeeded and book-keeping failure shouldn't block the lifecycle endpoint. Summary entry surfaces ``status='not_found'`` for forensics. """ person = await _make_person(db_session, email="ghost@pingcap.com") integ = await _make_integration(db_session, provider="aws") provider = await _make_provider(db_session, name="AWS BedRock") assignment = ApiKeyAssignment( id=uuid.uuid4(), provider_id=provider.id, assigned_to=person.id, status="active", request_number="AK-GHOST-0001", project_name="x", reason="x", monthly_budget_usd=Decimal("10.00"), iam_integration_id=str(integ.id), iam_user_arn="arn:aws:iam::0:user/ghost", detail={ "bedrock": { "granted": True, "iam_user_name": "ghost", # Rule id that does not exist in the DB. "allocation_rule_id": str(uuid.uuid4()), }, }, ) db_session.add(assignment) await db_session.commit() async def fake_revoke_cloud_resources(self, assignment, *, trace_id): return {"attempted": [], "errors": []} monkeypatch.setattr( ApiKeyService, "_revoke_cloud_resources", fake_revoke_cloud_resources, ) response = await ApiKeyService(db_session).revoke_key( assignment.id, reason="test ghost revoke", current_user=_admin_auth(person), trace_id="t-ghost", ip_address="127.0.0.1", user_agent="pytest", ) assert response.status == "revoked" audit_rows = (await db_session.execute( select(AuditLog).where( AuditLog.action == "apikey.key.revoke", AuditLog.resource_id == str(assignment.id), ) )).scalars().all() disabled = (audit_rows[0].detail or {}).get( "allocation_rules_disabled", ) or [] assert len(disabled) == 1 assert disabled[0]["status"] == "not_found" @pytest.mark.asyncio async def test_revoke_already_disabled_rule_is_idempotent( db_session: AsyncSession, monkeypatch, ): """Re-running revoke on an assignment whose rule is already ``enabled=False`` (e.g. admin disabled manually first, or this is a retried revoke after a previous attempt half-committed) must NOT re-stamp the disabled-by suffix or mutate the rule — that would grow the description on every retry. The summary records ``status='already_disabled'`` for the audit row. """ person = await _make_person(db_session, email="repeat@pingcap.com") integ = await _make_integration(db_session, provider="aws") assignment, rule = await _make_assignment_with_rule( db_session, bucket_key="bedrock", provider_name="AWS BedRock", applicant=person, integration=integ, rule_match={ "match_tag_key": "Owner", "match_tag_value": person.work_email, }, ) # Pre-disable the rule manually. rule.enabled = False rule.description = "manually disabled earlier — original text" await db_session.commit() original_desc = rule.description async def fake_revoke_cloud_resources(self, assignment, *, trace_id): return {"attempted": [], "errors": []} monkeypatch.setattr( ApiKeyService, "_revoke_cloud_resources", fake_revoke_cloud_resources, ) await ApiKeyService(db_session).revoke_key( assignment.id, reason="retry", current_user=_admin_auth(person), trace_id="t-retry", ip_address="127.0.0.1", user_agent="pytest", ) await db_session.refresh(rule) # Description must NOT have been re-stamped. assert rule.description == original_desc assert rule.enabled is False audit_rows = (await db_session.execute( select(AuditLog).where( AuditLog.action == "apikey.key.revoke", AuditLog.resource_id == str(assignment.id), ) )).scalars().all() disabled = (audit_rows[0].detail or {}).get( "allocation_rules_disabled", ) or [] assert disabled[0]["status"] == "already_disabled" @pytest.mark.asyncio async def test_revoke_assignment_without_rule_reference_is_noop( db_session: AsyncSession, monkeypatch, ): """Assignment whose ``detail.`` lacks an ``allocation_rule_id`` (pre-autogrant manual-fill row, or a future bucket that doesn't mint a rule) must revoke cleanly with an empty ``allocation_rules_disabled`` summary — no spurious not_found entries.""" person = await _make_person(db_session, email="manual@pingcap.com") integ = await _make_integration(db_session, provider="aws") provider = await _make_provider(db_session, name="AWS BedRock") assignment = ApiKeyAssignment( id=uuid.uuid4(), provider_id=provider.id, assigned_to=person.id, status="active", request_number="AK-MANUAL-0001", project_name="x", reason="x", monthly_budget_usd=Decimal("10.00"), iam_integration_id=str(integ.id), iam_user_arn="arn:aws:iam::0:user/manual", detail={"bedrock": {"granted": True, "iam_user_name": "manual"}}, ) db_session.add(assignment) await db_session.commit() async def fake_revoke_cloud_resources(self, assignment, *, trace_id): return {"attempted": [], "errors": []} monkeypatch.setattr( ApiKeyService, "_revoke_cloud_resources", fake_revoke_cloud_resources, ) await ApiKeyService(db_session).revoke_key( assignment.id, reason="manual revoke", current_user=_admin_auth(person), trace_id="t-manual", ip_address="127.0.0.1", user_agent="pytest", ) audit_rows = (await db_session.execute( select(AuditLog).where( AuditLog.action == "apikey.key.revoke", AuditLog.resource_id == str(assignment.id), ) )).scalars().all() disabled = (audit_rows[0].detail or {}).get( "allocation_rules_disabled", ) or [] assert disabled == []