"""Round 16 pre-launch audit: relationship-graph self-only clamp. Bug class — an authenticated employee can BFS the graph centred on their own person_id with ``depth=5, fanout=200, max_nodes=2000`` and follow ``REL_MANAGES`` / ``REL_REPORTS_TO`` edges up the chain to scrape display_name + work_email of the entire reachable org. The platform deliberately keeps ``GET /api/v1/users`` (full directory) admin-only; this regression locks the back-door shut. Privileged callers (``can_view_relationship_graph``) keep the full ``depth=5`` envelope — this clamp ONLY applies to self-only employees. """ from __future__ import annotations from datetime import UTC, datetime import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.domains.relationships.constants import ( NODE_TYPE_PERSON, REL_HOLDS_SEAT, REL_MANAGES, REL_MEMBER_OF, ) from app.domains.relationships.models import RelRelationship def _edge(**kwargs) -> RelRelationship: """Build a RelRelationship with required NOT-NULL timestamps so individual tests don't have to keep stamping ``first_seen_at`` / ``last_seen_at``. """ now = datetime.now(UTC) kwargs.setdefault("first_seen_at", now) kwargs.setdefault("last_seen_at", now) return RelRelationship(**kwargs) @pytest.mark.asyncio async def test_employee_self_graph_caps_depth_to_one( client, employee_user, ): """Employee → ``depth=5`` query → server should silently clamp to 1. The frontend can ask for any depth; we don't 422 a too-large depth for a self-only caller (that would force the /me embed to track its own role to know what to ask for). We just clamp. """ user, token = employee_user resp = await client.get( "/api/v1/relationships/graph", params={ "center_type": NODE_TYPE_PERSON, "center_id": str(user.id), "depth": 5, }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["success"] is True # depth in the response reflects the clamp. assert body["data"]["depth"] == 1 @pytest.mark.asyncio async def test_employee_cannot_traverse_management_chain_to_peers( client, db_session: AsyncSession, manager_and_subordinate, ): """Subordinate → ``depth=5`` would walk subordinate → manager → peer. The clamp + edge-type filter must drop the manager edge so only the subordinate's OWN node comes back. """ from app.identity.person_models import IdentityPerson manager, subordinate, _manager_token = manager_and_subordinate # Materialise a peer of the subordinate (another report under the # same manager). The BFS would reach this peer at depth=2 if the # MANAGES / REPORTS_TO edges were traversable. import uuid peer = IdentityPerson( id=uuid.uuid4(), work_email="peer@test.com", display_name="Peer User", employment_status="active", source_system="successfactors", hris_person_id="sf-peer-001", manager_person_id=manager.id, ) db_session.add(peer) # Materialise the manages edges (manager → subordinate, manager → peer). db_session.add(_edge( from_type=NODE_TYPE_PERSON, from_id=str(manager.id), to_type=NODE_TYPE_PERSON, to_id=str(subordinate.id), rel_type=REL_MANAGES, from_label=manager.display_name, to_label=subordinate.display_name, source="sync.identity", )) db_session.add(_edge( from_type=NODE_TYPE_PERSON, from_id=str(manager.id), to_type=NODE_TYPE_PERSON, to_id=str(peer.id), rel_type=REL_MANAGES, from_label=manager.display_name, to_label=peer.display_name, source="sync.identity", )) await db_session.commit() # Mint a subordinate-as-employee token (the fixture only gives a # manager token; the subordinate has employee role). from app.core.security import create_access_token sub_token = create_access_token(str(subordinate.id), ["employee"]) resp = await client.get( "/api/v1/relationships/graph", params={ "center_type": NODE_TYPE_PERSON, "center_id": str(subordinate.id), "depth": 5, }, headers={"Authorization": f"Bearer {sub_token}"}, ) assert resp.status_code == 200, resp.text body = resp.json()["data"] returned_ids = {n["id"] for n in body["nodes"]} # Subordinate (centre) is always present. assert str(subordinate.id) in returned_ids # Manager and peer MUST NOT leak through the management chain on # the self-only path. assert str(manager.id) not in returned_ids, ( "Self-only graph leaked manager node — REL_MANAGES edges must " "be filtered out for non-privileged callers." ) assert str(peer.id) not in returned_ids, ( "Self-only graph leaked peer node — depth must clamp to 1 + " "rel_types must drop management chain." ) @pytest.mark.asyncio async def test_employee_explicit_management_rel_type_is_403( client, employee_user, ): """If the employee explicitly asks for ONLY org-chart edges (e.g. ``rel_types=manages,reports_to``) we 403 rather than silently return an empty graph — the latter masks the gate as "no data" in the UI and gives the impression the directory is empty.""" user, token = employee_user resp = await client.get( "/api/v1/relationships/graph", params={ "center_type": NODE_TYPE_PERSON, "center_id": str(user.id), "rel_types": "manages,reports_to,member_of", }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_employee_self_graph_keeps_own_resource_edges( client, db_session: AsyncSession, employee_user, ): """The clamp must NOT break the legitimate /me embed — the employee can still see their own holds_seat / assigned_to / owns edges at depth=1. """ user, token = employee_user # Materialise a synthetic holds_seat edge from the employee to a # software_seat node. The exact target ids don't matter — we only # need to verify the edge survives the filter. from app.domains.relationships.constants import ( NODE_TYPE_DEPARTMENT, NODE_TYPE_SOFTWARE_SEAT, ) db_session.add(_edge( from_type=NODE_TYPE_PERSON, from_id=str(user.id), to_type=NODE_TYPE_SOFTWARE_SEAT, to_id="seat-1", rel_type=REL_HOLDS_SEAT, from_label=user.display_name, to_label="My Zoom Seat", source="sync.seat", )) # Adversarial: also materialise a member_of edge that should be # filtered out. db_session.add(_edge( from_type=NODE_TYPE_PERSON, from_id=str(user.id), to_type=NODE_TYPE_DEPARTMENT, to_id="dept-1", rel_type=REL_MEMBER_OF, from_label=user.display_name, to_label="Engineering", source="sync.identity", )) await db_session.commit() resp = await client.get( "/api/v1/relationships/graph", params={ "center_type": NODE_TYPE_PERSON, "center_id": str(user.id), }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json()["data"] edge_kinds = {e["rel_type"] for e in body["edges"]} assert REL_HOLDS_SEAT in edge_kinds, ( "Self-only graph dropped a legitimate holds_seat edge — the " "clamp went too far and broke the /me embed." ) assert REL_MEMBER_OF not in edge_kinds, ( "Self-only graph leaked a member_of edge — department-" "membership edges are intentionally hidden on the self path." ) @pytest.mark.asyncio async def test_person_nodes_annotated_with_employment_status( db_session: AsyncSession, ): """``get_subgraph`` must stamp ``employment_status`` onto person nodes so the frontend can tint a departed owner (``left``) red rather than rendering them like active staff. We mark, not hide.""" import uuid from app.domains.relationships.query_service import ( RelationshipQueryService, ) from app.identity.person_models import IdentityPerson center = IdentityPerson( id=uuid.uuid4(), work_email="center@test.com", display_name="Center Active", employment_status="active", source_system="successfactors", hris_person_id="sf-center-001", ) leaver = IdentityPerson( id=uuid.uuid4(), work_email="leaver@test.com", display_name="Departed Owner", employment_status="left", source_system="successfactors", hris_person_id="sf-leaver-001", ) db_session.add_all([center, leaver]) # A person→person edge so the BFS reaches the leaver at depth 1. db_session.add(_edge( from_type=NODE_TYPE_PERSON, from_id=str(center.id), to_type=NODE_TYPE_PERSON, to_id=str(leaver.id), rel_type=REL_MANAGES, from_label=center.display_name, to_label=leaver.display_name, source="test", )) await db_session.flush() sub = await RelationshipQueryService(db_session).get_subgraph( center_type=NODE_TYPE_PERSON, center_id=str(center.id), depth=1, ) by_id = {n.id: n for n in sub.nodes} assert by_id[str(center.id)].attributes.get("employment_status") == "active" assert by_id[str(leaver.id)].attributes.get("employment_status") == "left"