test(proxy_behavior): pin /key/info authz matrix (24 scenarios)

Slice 8 of the management-endpoints behavior-pinning effort. 8 actors ×
3 target keys (own, OWNER's key in org_a, CROSS_ORG_USER's key in org_b)
covering self-read, same-team-peer read, and cross-org read.

Notable pinned behaviors (intentionally surfaced for review, not "fixed"):

  * ORG_ADMIN gets 403 on individual key info even within their own org
    — visibility is scoped to "your own keys" + "your team's keys", not
    "your org's keys".
  * Same-team peers (INTERNAL_USER, UNRELATED_SAME_ORG, SERVICE_ACCOUNT)
    DO see each other's keys. Whether that is desired is for the team
    to decide; this PR only pins the existing behavior so unintentional
    changes flip the matrix red.

Wall-time is unchanged (~4.3s for the slice on its own).

Plan: https://www.notion.so/36643b8acdab8128a581ced0f6a4744d
This commit is contained in:
Yuneng Jiang 2026-05-19 21:43:38 -07:00
parent f7b881aecb
commit f5bd477292
No known key found for this signature in database

View file

@ -0,0 +1,103 @@
"""Slice 8 — actor × target authz matrix for ``GET /key/info``.
Targets are three fixed seeded keys representing the canonical relations:
* **own** — the actor's own key (each actor's relation to itself).
* **owner_key** — OWNER actor's key in org_a / team_alpha. From any
other org_a / team_alpha actor's perspective this is "same-team, not
mine". From cross_org_user's perspective this is cross-org.
* **cross_org_user_key** — CROSS_ORG_USER's key in org_b / team_beta.
From any org_a actor's perspective this is cross-org.
24 (actor × target) scenarios after applying the matrix; status codes pinned
against current handler behavior so future PRs that change the visibility
boundary turn the suite red.
"""
import pytest
from .actors import Actor
from .conftest import MASTER_KEY # noqa: F401 (kept for symmetry with sibling files)
pytestmark = pytest.mark.asyncio(loop_scope="session")
# (id, actor, target_actor, expected_status)
_SCENARIOS = [
# ─── target = own key ─────────────────────────────────────────────────
("own/proxy_admin", Actor.PROXY_ADMIN, Actor.PROXY_ADMIN, 200),
("own/org_admin", Actor.ORG_ADMIN, Actor.ORG_ADMIN, 200),
("own/team_admin", Actor.TEAM_ADMIN, Actor.TEAM_ADMIN, 200),
("own/internal_user", Actor.INTERNAL_USER, Actor.INTERNAL_USER, 200),
("own/owner", Actor.OWNER, Actor.OWNER, 200),
("own/unrelated_same_org", Actor.UNRELATED_SAME_ORG, Actor.UNRELATED_SAME_ORG, 200),
("own/cross_org_user", Actor.CROSS_ORG_USER, Actor.CROSS_ORG_USER, 200),
("own/service_account", Actor.SERVICE_ACCOUNT, Actor.SERVICE_ACCOUNT, 200),
# ─── target = OWNER's key (org_a / team_alpha) ────────────────────────
# NB: org_admin currently 403s on individual key info even within their own
# org — visibility is scoped to "your own keys" + "your team's keys", not
# "your org's keys". Same-team peers (internal_user, unrelated_same_org,
# service_account) DO see each other's keys.
("owner_key/proxy_admin", Actor.PROXY_ADMIN, Actor.OWNER, 200),
("owner_key/org_admin", Actor.ORG_ADMIN, Actor.OWNER, 403),
("owner_key/team_admin", Actor.TEAM_ADMIN, Actor.OWNER, 200),
("owner_key/internal_user", Actor.INTERNAL_USER, Actor.OWNER, 200),
("owner_key/owner", Actor.OWNER, Actor.OWNER, 200),
("owner_key/unrelated_same_org", Actor.UNRELATED_SAME_ORG, Actor.OWNER, 200),
("owner_key/cross_org_user", Actor.CROSS_ORG_USER, Actor.OWNER, 403),
("owner_key/service_account", Actor.SERVICE_ACCOUNT, Actor.OWNER, 200),
# ─── target = CROSS_ORG_USER's key (org_b / team_beta) ────────────────
("cross_org/proxy_admin", Actor.PROXY_ADMIN, Actor.CROSS_ORG_USER, 200),
("cross_org/org_admin", Actor.ORG_ADMIN, Actor.CROSS_ORG_USER, 403),
("cross_org/team_admin", Actor.TEAM_ADMIN, Actor.CROSS_ORG_USER, 403),
("cross_org/internal_user", Actor.INTERNAL_USER, Actor.CROSS_ORG_USER, 403),
("cross_org/owner", Actor.OWNER, Actor.CROSS_ORG_USER, 403),
(
"cross_org/unrelated_same_org",
Actor.UNRELATED_SAME_ORG,
Actor.CROSS_ORG_USER,
403,
),
("cross_org/cross_org_user", Actor.CROSS_ORG_USER, Actor.CROSS_ORG_USER, 200),
("cross_org/service_account", Actor.SERVICE_ACCOUNT, Actor.CROSS_ORG_USER, 403),
]
@pytest.mark.parametrize(
"actor,target_actor,expected_status",
[(a, t, s) for (_id, a, t, s) in _SCENARIOS],
ids=[s[0] for s in _SCENARIOS],
)
async def test_key_info_authz_matrix(
actor: Actor,
target_actor: Actor,
expected_status: int,
proxy_client,
world,
):
caller = world.keys[actor]
target = world.keys[target_actor]
resp = await proxy_client.get(
f"/key/info?key={target.cleartext}",
headers={"Authorization": f"Bearer {caller.cleartext}"},
)
assert resp.status_code == expected_status, (
f"{actor.value} GET /key/info?key=<{target_actor.value}> → "
f"{resp.status_code} (expected {expected_status}). body={resp.text}"
)
if expected_status == 200:
body = resp.json()
# The handler echoes back whatever ``key`` was passed in the query string,
# so body["key"] == cleartext when we querystring it. When the auth key
# IS the target (no query), it returns the hashed form. Either match is
# fine — the canonical identity check is on info.user_id.
assert body.get("key") in (target.cleartext, target.hashed), (
f"{actor.value} → target {target_actor.value}: wrong key in response "
f"(got {body.get('key')!r}, expected cleartext or hashed of target)"
)
assert body["info"].get("user_id") == target.user_id, (
f"{actor.value} → target {target_actor.value}: wrong user_id "
f"(got {body['info'].get('user_id')!r}, expected {target.user_id!r})"
)