"""``security_officer`` (安全员) — the security operations desk. Three properties, each a real failure mode if it breaks: 1. The role passes both security gates. Without ``can_manage_security`` the desk can read reports but not triage them, which is the whole job. 2. It grants NOTHING outside the security domain. A role that quietly became a second global_admin would be worse than not having it. 3. It is a canonical, leaver-revocable role, so a departing security officer does not keep the seat. """ from __future__ import annotations import uuid import pytest from sqlalchemy import select from app.auth.models import AuthRole, AuthUserRole from app.core import rbac from app.core.security import create_access_token from app.identity.person_models import IdentityPerson BASE = "/api/v1/bounty" ROLE = "security_officer" async def _officer(db_session): person_id = uuid.uuid4() db_session.add(IdentityPerson( id=person_id, work_email="sec.officer@test.com", display_name="Sec Officer", employment_status="active", source_system="successfactors", hris_person_id="sf-secofficer", )) await db_session.flush() for name in ("employee", ROLE): role_id = ( await db_session.execute( select(AuthRole.id).where(AuthRole.name == name), ) ).scalar_one_or_none() if role_id is None: role_id = uuid.uuid4() db_session.add(AuthRole(id=role_id, name=name, is_system=True)) await db_session.flush() db_session.add(AuthUserRole(user_id=person_id, role_id=role_id)) await db_session.commit() token = create_access_token(str(person_id), ["employee", ROLE]) return person_id, {"Authorization": f"Bearer {token}"} def test_role_is_canonical_and_revoked_on_departure(): assert rbac.SECURITY_OFFICER_ROLE == ROLE assert ROLE in rbac.CANONICAL_ROLE_NAMES # A departing security officer must not keep the seat. assert ROLE in rbac.LEAVER_REVOCABLE_ROLES # No legacy alias collapses onto it, and it collapses onto nothing. assert rbac.canonicalize_role_name(ROLE) == ROLE assert rbac.ROLE_EQUIVALENTS[ROLE] == {ROLE} def test_role_passes_both_security_gates_and_nothing_else(): roles = [ROLE] assert rbac.can_view_security(roles) is True # Read without write would leave the desk unable to do its job. assert rbac.can_manage_security(roles) is True # …and nothing outside the security domain. Each of these is a # separate authority the desk has no business holding. assert rbac.can_manage_cloud(roles) is False assert rbac.can_manage_leaver_blocked(roles) is False assert rbac.can_view_cloud_resources(roles) is False assert rbac.has_global_asset_scope(roles) is False assert rbac.can_view_directory(roles) is False def test_role_has_the_same_authority_as_auditor_inside_the_domain(): """Inside 安全合规 the officer and the auditor are equal. Access-certification item decisions are gated on ``can_manage_security`` (``compliance_router._require_manage``), so the officer decides them too. Pinned explicitly because the tempting story — "an auditor audits, an officer operates, therefore the officer has less authority" — is NOT what the code does, and a test asserting the comfortable version would make the comment a lie. """ for helper in (rbac.can_view_security, rbac.can_manage_security): assert helper([ROLE]) is helper([rbac.AUDITOR_ROLE]) is True # ``management`` remains the read-only tier on the same surface. assert rbac.can_view_security([rbac.MANAGEMENT_ROLE]) is True assert rbac.can_manage_security([rbac.MANAGEMENT_ROLE]) is False @pytest.mark.asyncio async def test_officer_can_run_the_bounty_desk(client, db_session): _, headers = await _officer(db_session) # Read the console. assert (await client.get(f"{BASE}/meta", headers=headers)).status_code == 200 meta = (await client.get(f"{BASE}/meta", headers=headers)).json()["data"] assert meta["can_manage"] is True # Intake, grade, price — the actual job. created = await client.post( f"{BASE}/reports", json={ "title": "安全员录入", "researcher_email": "wh@example.com", "affected_system": "docs.pingcap.com", }, headers=headers, ) assert created.status_code == 200, created.text rid = created.json()["data"]["id"] graded = await client.post( f"{BASE}/reports/{rid}/severity", json={"severity": "low", "apply_default_reward": True}, headers=headers, ) assert graded.status_code == 200 assert graded.json()["data"]["reward_amount_usd"] == 100.0 # Reply rendering + the reports surface. preview = await client.post( f"{BASE}/reports/{rid}/reply-preview", json={"template_key": "thanks_generic"}, headers=headers, ) assert preview.status_code == 200 assert ( await client.get(f"{BASE}/analytics/overview", headers=headers) ).status_code == 200 @pytest.mark.asyncio async def test_officer_is_refused_outside_the_security_domain( client, db_session, ): _, headers = await _officer(db_session) # Sampled from three different admin domains — the role must not have # become a second global_admin. for path in ( "/api/v1/admin/users", "/api/v1/integrations", "/api/v1/scheduled-jobs", ): resp = await client.get(path, headers=headers) assert resp.status_code in (403, 404), f"{path} → {resp.status_code}"