|
| 1 | +""" |
| 2 | +E2E authorization smoke tests for a non-admin acting on their own corpuser entity. |
| 3 | +
|
| 4 | +Self-service profile editing is allowed by an explicit self short-circuit in ``CorpUserType``, |
| 5 | +not by any policy. The generic write APIs have no such short-circuit and must keep denying |
| 6 | +writes to your own user entity - otherwise any user could patch their own roleMembership and |
| 7 | +grant themselves the Admin role. |
| 8 | +""" |
| 9 | + |
| 10 | +import logging |
| 11 | +import uuid |
| 12 | + |
| 13 | +import pytest |
| 14 | + |
| 15 | +from datahub.metadata.schema_classes import RoleMembershipClass |
| 16 | +from tests.consistency_utils import wait_for_writes_to_sync |
| 17 | +from tests.privileges.utils import create_user, remove_user |
| 18 | +from tests.utils import ( |
| 19 | + get_frontend_session, |
| 20 | + get_frontend_url, |
| 21 | + login_as, |
| 22 | + with_test_retry, |
| 23 | +) |
| 24 | + |
| 25 | +logger = logging.getLogger(__name__) |
| 26 | + |
| 27 | +pytestmark = pytest.mark.no_cypress_suite1 |
| 28 | + |
| 29 | +_UNIQUE = uuid.uuid4().hex[:8] |
| 30 | +TEST_USER_EMAIL = f"self.auth.test.{_UNIQUE}@smoke.datahub.test" |
| 31 | +TEST_USER_URN = f"urn:li:corpuser:{TEST_USER_EMAIL}" |
| 32 | +OTHER_USER_EMAIL = f"self.auth.other.{_UNIQUE}@smoke.datahub.test" |
| 33 | +OTHER_USER_URN = f"urn:li:corpuser:{OTHER_USER_EMAIL}" |
| 34 | +TEST_USER_PASSWORD = "user" |
| 35 | + |
| 36 | +ADMIN_ROLE_URN = "urn:li:dataHubRole:Admin" |
| 37 | + |
| 38 | +UPDATE_CORP_USER_PROPERTIES_MUTATION = """ |
| 39 | +mutation updateCorpUserProperties($urn: String!, $input: CorpUserUpdateInput!) { |
| 40 | + updateCorpUserProperties(urn: $urn, input: $input) { |
| 41 | + urn |
| 42 | + editableProperties { |
| 43 | + aboutMe |
| 44 | + title |
| 45 | + } |
| 46 | + } |
| 47 | +} |
| 48 | +""" |
| 49 | + |
| 50 | +PATCH_ENTITY_MUTATION = """ |
| 51 | +mutation patchEntity($input: PatchEntityInput!) { |
| 52 | + patchEntity(input: $input) { |
| 53 | + urn |
| 54 | + success |
| 55 | + error |
| 56 | + } |
| 57 | +} |
| 58 | +""" |
| 59 | + |
| 60 | + |
| 61 | +@pytest.fixture(scope="module", autouse=True) |
| 62 | +def self_auth_setup(auth_session): |
| 63 | + admin_session = get_frontend_session() |
| 64 | + admin_session = create_user(admin_session, TEST_USER_EMAIL, TEST_USER_PASSWORD) |
| 65 | + admin_session = create_user(admin_session, OTHER_USER_EMAIL, TEST_USER_PASSWORD) |
| 66 | + |
| 67 | + yield |
| 68 | + |
| 69 | + remove_user(admin_session, TEST_USER_URN) |
| 70 | + remove_user(admin_session, OTHER_USER_URN) |
| 71 | + |
| 72 | + |
| 73 | +def _is_graphql_auth_denied(res: dict) -> bool: |
| 74 | + errors = res.get("errors") or [] |
| 75 | + if not errors: |
| 76 | + return False |
| 77 | + code = errors[0].get("extensions", {}).get("code") |
| 78 | + return code in (401, 403) |
| 79 | + |
| 80 | + |
| 81 | +@with_test_retry(max_attempts=10) |
| 82 | +def _post_graphql_as_user(email: str, password: str, payload: dict) -> dict: |
| 83 | + user_session = login_as(email, password) |
| 84 | + response = user_session.post(f"{get_frontend_url()}/api/v2/graphql", json=payload) |
| 85 | + response.raise_for_status() |
| 86 | + return response.json() |
| 87 | + |
| 88 | + |
| 89 | +def _update_profile_payload(target_urn: str, about_me: str) -> dict: |
| 90 | + return { |
| 91 | + "query": UPDATE_CORP_USER_PROPERTIES_MUTATION, |
| 92 | + "variables": { |
| 93 | + "urn": target_urn, |
| 94 | + "input": {"aboutMe": about_me, "title": "Analyst"}, |
| 95 | + }, |
| 96 | + } |
| 97 | + |
| 98 | + |
| 99 | +def test_user_can_edit_own_profile(): |
| 100 | + """A user with no granted privileges can still edit their own profile.""" |
| 101 | + about_me = f"Self edit {_UNIQUE}" |
| 102 | + res = _post_graphql_as_user( |
| 103 | + TEST_USER_EMAIL, |
| 104 | + TEST_USER_PASSWORD, |
| 105 | + _update_profile_payload(TEST_USER_URN, about_me), |
| 106 | + ) |
| 107 | + |
| 108 | + assert not _is_graphql_auth_denied(res), res |
| 109 | + editable = ((res.get("data") or {}).get("updateCorpUserProperties") or {}).get( |
| 110 | + "editableProperties" |
| 111 | + ) or {} |
| 112 | + assert editable.get("aboutMe") == about_me, res |
| 113 | + assert editable.get("title") == "Analyst", res |
| 114 | + |
| 115 | + |
| 116 | +def test_user_cannot_edit_another_users_profile(): |
| 117 | + """The self short-circuit must not extend to other users' profiles.""" |
| 118 | + res = _post_graphql_as_user( |
| 119 | + TEST_USER_EMAIL, |
| 120 | + TEST_USER_PASSWORD, |
| 121 | + _update_profile_payload(OTHER_USER_URN, f"Cross edit {_UNIQUE}"), |
| 122 | + ) |
| 123 | + |
| 124 | + assert _is_graphql_auth_denied(res), res |
| 125 | + |
| 126 | + |
| 127 | +def test_user_cannot_patch_own_role_membership(graph_client): |
| 128 | + """A user must not be able to grant themselves the Admin role on their own entity. |
| 129 | +
|
| 130 | + patchEntity is gated on EDIT_ENTITY, which the default self policy must never grant. |
| 131 | + """ |
| 132 | + payload = { |
| 133 | + "query": PATCH_ENTITY_MUTATION, |
| 134 | + "variables": { |
| 135 | + "input": { |
| 136 | + "urn": TEST_USER_URN, |
| 137 | + "entityType": "corpuser", |
| 138 | + "aspectName": "roleMembership", |
| 139 | + "patch": [ |
| 140 | + { |
| 141 | + "op": "ADD", |
| 142 | + "path": f"/roles/{ADMIN_ROLE_URN}", |
| 143 | + "value": ADMIN_ROLE_URN, |
| 144 | + } |
| 145 | + ], |
| 146 | + "arrayPrimaryKeys": [{"arrayField": "roles", "keys": []}], |
| 147 | + "forceGenericPatch": True, |
| 148 | + } |
| 149 | + }, |
| 150 | + } |
| 151 | + res = _post_graphql_as_user(TEST_USER_EMAIL, TEST_USER_PASSWORD, payload) |
| 152 | + |
| 153 | + # PatchEntityResolver reports authorization failures in the payload rather than as a |
| 154 | + # GraphQL error, so check the reason too - success=False alone would also be satisfied |
| 155 | + # by an unrelated failure. |
| 156 | + result = (res.get("data") or {}).get("patchEntity") or {} |
| 157 | + assert result.get("success") is False, res |
| 158 | + assert "unauthorized" in (result.get("error") or "").lower(), res |
| 159 | + |
| 160 | + wait_for_writes_to_sync(mcp_only=True) |
| 161 | + role_membership = graph_client.get_aspect(TEST_USER_URN, RoleMembershipClass) |
| 162 | + assert role_membership is None or ADMIN_ROLE_URN not in role_membership.roles, ( |
| 163 | + f"Test user was granted {ADMIN_ROLE_URN}: {role_membership}" |
| 164 | + ) |
0 commit comments