"""Batch RBAC role endpoints. POST /api/v1/auth/roles/batch-assign POST /api/v1/auth/roles/batch-revoke Replaces the RbacPage Promise.all-of-single-calls pattern with one server-side batch: per-row SAVEPOINT isolation, idempotent skips, an aggregated audit row, and the self-lockout guard (an admin cannot batch-remove their own ``global_admin``). """ from __future__ import annotations import uuid import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.auth.models import AuthRole, AuthUserRole from app.identity.person_models import IdentityPerson async def _role_id(db: AsyncSession, role_name: str) -> uuid.UUID: role = ( await db.execute(select(AuthRole).where(AuthRole.name == role_name)) ).scalar_one() return role.id async def _make_user( db: AsyncSession, email: str, *, roles: tuple[str, ...] = ("employee",), ) -> IdentityPerson: person = 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[:10]}", ) db.add(person) await db.flush() for role_name in roles: db.add(AuthUserRole(user_id=person.id, role_id=await _role_id(db, role_name))) await db.flush() return person async def _user_role_names(db: AsyncSession, user_id: uuid.UUID) -> set[str]: rows = ( await db.execute( select(AuthRole.name) .join(AuthUserRole, AuthUserRole.role_id == AuthRole.id) .where(AuthUserRole.user_id == user_id) ) ).scalars().all() return set(rows) @pytest.mark.asyncio async def test_batch_assign_then_idempotent_skip(client, admin_user, db_session): _, token = admin_user user_a = await _make_user(db_session, f"ba-{uuid.uuid4().hex[:6]}@test.com") user_b = await _make_user(db_session, f"bb-{uuid.uuid4().hex[:6]}@test.com") await db_session.commit() resp = await client.post( "/api/v1/auth/roles/batch-assign", json={"user_ids": [str(user_a.id), str(user_b.id)], "role_name": "hr"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["succeeded"] == 2 assert data["skipped"] == 0 assert data["failed"] == 0 assert await _user_role_names(db_session, user_a.id) >= {"employee", "hr"} assert await _user_role_names(db_session, user_b.id) >= {"employee", "hr"} # Second run is a no-op — both rows skipped, nothing fails. resp = await client.post( "/api/v1/auth/roles/batch-assign", json={"user_ids": [str(user_a.id), str(user_b.id)], "role_name": "hr"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["succeeded"] == 0 assert data["skipped"] == 2 assert data["failed"] == 0 audit_row = ( await db_session.execute( select(AuditLog) .where(AuditLog.action == "auth.roles.batch_assign") .order_by(AuditLog.created_at.desc()) ) ).scalars().first() assert audit_row is not None assert audit_row.detail["role_name"] == "hr" assert audit_row.detail["total"] == 2 assert str(user_a.id) in audit_row.detail["ids"] @pytest.mark.asyncio async def test_batch_revoke_blocks_self_global_admin(client, admin_user, db_session): """The caller's own global_admin row fails (cannot_remove_own_admin); other rows in the same batch still go through.""" admin, token = admin_user other_admin = await _make_user( db_session, f"oa-{uuid.uuid4().hex[:6]}@test.com", roles=("employee", "global_admin"), ) await db_session.commit() resp = await client.post( "/api/v1/auth/roles/batch-revoke", json={ "user_ids": [str(admin.id), str(other_admin.id)], "role_name": "global_admin", }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["succeeded"] == 1 assert data["failed"] == 1 by_id = {r["id"]: r for r in data["results"]} assert by_id[str(admin.id)]["status"] == "failed" assert by_id[str(admin.id)]["error"] == "cannot_remove_own_admin" assert by_id[str(other_admin.id)]["status"] == "succeeded" # The caller keeps global_admin; the other admin lost it. assert "global_admin" in await _user_role_names(db_session, admin.id) assert "global_admin" not in await _user_role_names(db_session, other_admin.id) audit_row = ( await db_session.execute( select(AuditLog) .where(AuditLog.action == "auth.roles.batch_revoke") .order_by(AuditLog.created_at.desc()) ) ).scalars().first() assert audit_row is not None assert audit_row.detail["role_name"] == "global_admin" assert audit_row.detail["failed"] == 1 @pytest.mark.asyncio async def test_batch_revoke_not_holding_role_skipped(client, admin_user, db_session): _, token = admin_user user_a = await _make_user(db_session, f"nr-{uuid.uuid4().hex[:6]}@test.com") await db_session.commit() resp = await client.post( "/api/v1/auth/roles/batch-revoke", json={"user_ids": [str(user_a.id)], "role_name": "hr"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["succeeded"] == 0 assert data["skipped"] == 1 assert data["failed"] == 0 @pytest.mark.asyncio async def test_batch_assign_asset_admin_rejected(client, admin_user, employee_user): """asset_admin is auto-derived — batch assignment is rejected up front, mirroring the single-row endpoint.""" _, token = admin_user employee, _ = employee_user resp = await client.post( "/api/v1/auth/roles/batch-assign", json={"user_ids": [str(employee.id)], "role_name": "asset_admin"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 400 assert resp.json()["errors"][0]["code"] == "ROLE_AUTO_DERIVED" @pytest.mark.asyncio async def test_batch_role_endpoints_employee_403(client, employee_user): employee, token = employee_user for path in ("/api/v1/auth/roles/batch-assign", "/api/v1/auth/roles/batch-revoke"): resp = await client.post( path, json={"user_ids": [str(employee.id)], "role_name": "hr"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio @pytest.mark.parametrize( ("path", "service_method", "initial_roles"), [ ("/api/v1/auth/roles/batch-assign", "assign_role", ("employee",)), ("/api/v1/auth/roles/batch-revoke", "remove_role_by_name", ("employee", "hr")), ], ) async def test_batch_role_unexpected_error_returns_stable_code( client, admin_user, db_session, monkeypatch, caplog, path, service_method, initial_roles, ): from app.auth.service import AuthService secret = "password=batch-role-secret at /srv/private/auth.py:77" async def _boom(*args, **kwargs): raise RuntimeError(secret) monkeypatch.setattr(AuthService, service_method, _boom) person = await _make_user( db_session, f"failure-{uuid.uuid4().hex[:6]}@test.com", roles=initial_roles, ) await db_session.commit() _, token = admin_user response = await client.post( path, json={"user_ids": [str(person.id)], "role_name": "hr"}, headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 assert response.json()["data"]["results"][0] == { "id": str(person.id), "status": "failed", "error": "INTERNAL_ERROR", } assert secret not in response.text assert secret not in caplog.text