"""Admin re-binds an API-key assignment to a different IAM/RAM user. Verifies: * service-layer rebind clears the binding when iam_user_id=None. * service-layer rebind copies arn + integration_id from the picked cmdb_iam_users row. * audit log captures previous + new binding for compliance review. """ from __future__ import annotations import uuid from datetime import UTC, datetime from decimal import Decimal import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.core.exceptions import NotFoundError from app.domains.apikey.models import ApiKeyAssignment, ApiKeyProvider from app.domains.apikey.schemas import ApiKeyIamRebindRequest from app.domains.apikey.service import ApiKeyService from app.domains.cmdb.models import CmdbIamUser from app.identity.person_models import IdentityPerson from app.integrations.models import Integration async def _seed(db_session: AsyncSession) -> tuple[ApiKeyAssignment, IdentityPerson, CmdbIamUser]: integ = Integration( id=uuid.uuid4(), kind="cloud", provider="aws", name="AWS SG", slug="aws-sg", enabled=True, is_primary=False, capabilities=["servers"], config_public={}, ) db_session.add(integ) person = IdentityPerson( id=uuid.uuid4(), source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:6]}", display_name="Tester", work_email="tester@example.com", employment_status="active", ) db_session.add(person) provider = ApiKeyProvider( id=uuid.uuid4(), name="AWS Bedrock", description="Bedrock", is_active=True, ) db_session.add(provider) await db_session.flush() iam = CmdbIamUser( id=uuid.uuid4(), integration_id=str(integ.id), user_name="bedrock-tester-20260427-AB12", arn="arn:aws:iam::000:user/bedrock-tester-20260427-AB12", ) db_session.add(iam) assignment = ApiKeyAssignment( id=uuid.uuid4(), provider_id=provider.id, assigned_to=person.id, status="active", request_number=f"AK-{datetime.now(UTC).strftime('%Y%m%d')}-XYZ1", project_name="proj", reason="testing rebind", monthly_budget_usd=Decimal("20.00"), key_value="ACCESS_KEY_ID=AKIA-old\nSECRET_ACCESS_KEY=secret", iam_user_id=None, iam_user_arn=None, iam_integration_id=None, ) db_session.add(assignment) await db_session.flush() actor = IdentityPerson( id=uuid.uuid4(), work_email="admin@example.com", display_name="Admin", employment_status="active", source_system="successfactors", hris_person_id="sf-admin-rebind", ) db_session.add(actor) await db_session.commit() # ``rebind_iam_user`` only reads ``current_user.id`` for audit-logging, # so we don't need to wire up the role rows here. Router-level RBAC # is covered by the dependency injection in production; service- # layer behaviour is what this test exercises. return assignment, actor, iam @pytest.mark.asyncio async def test_rebind_sets_arn_and_integration(db_session: AsyncSession): assignment, actor, iam = await _seed(db_session) svc = ApiKeyService(db_session) out = await svc.rebind_iam_user( assignment.id, ApiKeyIamRebindRequest(iam_user_id=iam.id, reason="picking the right user"), current_user=actor, trace_id="trace-1", ip_address=None, user_agent=None, ) assert out.iam_user_id == iam.id assert out.iam_user_arn == iam.arn assert out.iam_integration_id == str(iam.integration_id) assert out.iam_user_name == iam.user_name @pytest.mark.asyncio async def test_rebind_clear_unsets_columns(db_session: AsyncSession): assignment, actor, iam = await _seed(db_session) svc = ApiKeyService(db_session) # First bind, then unbind. await svc.rebind_iam_user( assignment.id, ApiKeyIamRebindRequest(iam_user_id=iam.id, reason="bind for the test"), current_user=actor, trace_id="t", ip_address=None, user_agent=None, ) out = await svc.rebind_iam_user( assignment.id, ApiKeyIamRebindRequest(iam_user_id=None, reason="orphaning the binding"), current_user=actor, trace_id="t", ip_address=None, user_agent=None, ) assert out.iam_user_id is None assert out.iam_user_arn is None assert out.iam_integration_id is None @pytest.mark.asyncio async def test_rebind_writes_audit(db_session: AsyncSession): assignment, actor, iam = await _seed(db_session) svc = ApiKeyService(db_session) await svc.rebind_iam_user( assignment.id, ApiKeyIamRebindRequest(iam_user_id=iam.id, reason="auditable change"), current_user=actor, trace_id="trace-audit", ip_address="127.0.0.1", user_agent="pytest", ) rows = (await db_session.execute( select(AuditLog).where(AuditLog.action == "apikey.iam_binding.rebind") )).scalars().all() assert len(rows) == 1 detail = rows[0].detail or {} assert detail["new_iam_user_arn"] == iam.arn assert detail["reason"] == "auditable change" @pytest.mark.asyncio async def test_rebind_to_unknown_iam_user_404(db_session: AsyncSession): assignment, actor, _ = await _seed(db_session) svc = ApiKeyService(db_session) with pytest.raises(NotFoundError): await svc.rebind_iam_user( assignment.id, ApiKeyIamRebindRequest( iam_user_id=uuid.uuid4(), reason="targeting a ghost", ), current_user=actor, trace_id="t", ip_address=None, user_agent=None, )