"""``require_browser_session`` regression tests. Sensitive admin actions (PII reveal of *another* user, role mutation, api-key plaintext reveal of *another* user's assignment, PAT mint / revoke) must NOT be reachable with a PAT bearer — even an IT-Admin PAT. The gate prevents a stolen IT-Admin PAT from dumping the entire roster, granting itself permanent admin via role-add, or revealing every employee's cloud IAM key. These tests exercise the gate end-to-end: build an IT-Admin user, mint a PAT, hit each gated endpoint with the PAT, and assert 403. The same calls with a JWT bearer succeed (or 404, depending on the target row) — confirming the gate fires on the auth-via dimension and not on the underlying business logic. """ from __future__ import annotations import uuid import pytest from sqlalchemy import select from app.auth.models import AuthRole, AuthUserRole from app.auth.tokens.service import PersonalAccessTokenService from app.core.security import create_access_token from app.identity.person_models import IdentityPerson async def _make_admin(db_session, email: str = "admin@example.com"): user_id = uuid.uuid4() user = IdentityPerson( id=user_id, work_email=email, display_name="Admin", employment_status="active", source_system="manual", hris_person_id=f"manual-{user_id}", ) db_session.add(user) await db_session.flush() role = ( await db_session.execute( select(AuthRole).where(AuthRole.name == "it_admin"), ) ).scalar_one_or_none() if role is None: role = AuthRole(id=uuid.uuid4(), name="it_admin", is_system=True) db_session.add(role) await db_session.flush() db_session.add(AuthUserRole(user_id=user_id, role_id=role.id)) await db_session.flush() return user def _jwt(user_id: uuid.UUID, roles: list[str]) -> dict[str, str]: return { "Authorization": f"Bearer {create_access_token(str(user_id), roles)}", } async def _mint_pat(db_session, user_id: uuid.UUID) -> str: svc = PersonalAccessTokenService(db_session) _, plaintext = await svc.create( user_id=user_id, name="for-test", expires_in_days=30, ) await db_session.commit() return plaintext @pytest.mark.asyncio async def test_pat_blocked_on_reveal_other_user_contact(client, db_session): """An IT-Admin PAT must NOT be allowed to unmask another user's PII. Without ``require_browser_session`` on this endpoint, a stolen IT-Admin PAT could enumerate the entire org's mobile numbers / personal emails. Same posture GitHub uses for sudo-mode on sensitive admin pages. """ admin = await _make_admin(db_session) target = await _make_admin(db_session, email="target@example.com") pat = await _mint_pat(db_session, admin.id) r = await client.post( f"/api/v1/users/{target.id}/reveal-contact", headers={"Authorization": f"Bearer {pat}"}, ) assert r.status_code == 403, r.text body = r.json() assert body["errors"][0]["code"] == "FORBIDDEN" # Sanity: same call with JWT succeeds. r2 = await client.post( f"/api/v1/users/{target.id}/reveal-contact", headers=_jwt(admin.id, ["it_admin"]), ) assert r2.status_code == 200, r2.text @pytest.mark.asyncio async def test_pat_blocked_on_role_mutation(client, db_session): """A stolen IT-Admin PAT must NOT be able to grant itself or others additional roles. Role mutations are browser-only. """ admin = await _make_admin(db_session) target = await _make_admin(db_session, email="role-target@example.com") pat = await _mint_pat(db_session, admin.id) # Try to assign 'security_auditor' via the PAT. r = await client.post( f"/api/v1/users/{target.id}/roles", json={"role_name": "security_auditor"}, headers={"Authorization": f"Bearer {pat}"}, ) assert r.status_code == 403, r.text assert r.json()["errors"][0]["code"] == "FORBIDDEN" @pytest.mark.asyncio async def test_pat_blocked_on_role_removal(client, db_session): admin = await _make_admin(db_session) target = await _make_admin(db_session, email="rem-target@example.com") pat = await _mint_pat(db_session, admin.id) r = await client.delete( f"/api/v1/users/{target.id}/roles/by-name/it_admin", headers={"Authorization": f"Bearer {pat}"}, ) assert r.status_code == 403, r.text @pytest.mark.asyncio async def test_pat_blocked_on_user_update(client, db_session): admin = await _make_admin(db_session) target = await _make_admin(db_session, email="upd-target@example.com") pat = await _mint_pat(db_session, admin.id) r = await client.patch( f"/api/v1/users/{target.id}", json={"display_name": "Hijacked"}, headers={"Authorization": f"Bearer {pat}"}, ) assert r.status_code == 403, r.text @pytest.mark.asyncio @pytest.mark.parametrize( "action", ["power-on", "power-off", "restart", "destroy"], ) async def test_pat_blocked_on_cmdb_server_lifecycle( client, db_session, action, ): """PR #559 — cloud-server lifecycle (开机/关机/重启/删除) must NOT be reachable with a PAT bearer. A leaked cloud_admin PAT could otherwise issue a single ``curl POST .../servers/{id}/destroy`` and nuke production infrastructure. Browser-session step-up is the same posture the force-sweep + PII-reveal endpoints use. """ admin = await _make_admin(db_session, email=f"pat-life-{action}@x.com") pat = await _mint_pat(db_session, admin.id) # Even an unknown server id should 403 (gate fires before lookup). fake_id = uuid.uuid4() r = await client.post( f"/api/v1/cmdb/servers/{fake_id}/{action}", headers={"Authorization": f"Bearer {pat}"}, ) assert r.status_code == 403, r.text body = r.json() assert body["errors"][0]["code"] == "FORBIDDEN", body