From 15c7a59d76a151028e5d3f051e18adb2d587442c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 19 May 2026 22:15:17 -0700 Subject: [PATCH] ci(proxy-mgmt-behavior): seed scratch keys via proxy_admin actor, not master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second CI run failed: ``/key/generate`` with explicit ``user_id`` returned 403 "User can only create keys for themselves. Got user_id=X, Your ID=None" in every test that called ``_create_scratch_key`` with a per-actor user_id. The bare master key's auth path was producing ``user_id=None`` in the fresh CI Postgres, which doesn't trigger the PROXY_ADMIN bypass in ``_user_can_only_create_keys_for_themselves`` reliably. Locally the same master key path worked, masking the issue. Fix: every ``_create_scratch_key`` helper now takes a seeder cleartext and the test bodies pass ``world.keys[Actor.PROXY_ADMIN].cleartext``. That actor was seeded with ``user_role=PROXY_ADMIN`` AND a concrete ``user_id``, so the bypass fires deterministically in both environments. No behavior shift in the matrices themselves — all 128 scenarios still pass locally; only the setup helper's auth identity changed. The bare-master smoke (test_smoke + test_scratch_teardown) is intentionally left on the master key path: those tests don't pass ``user_id`` in the body so they don't hit the user_id-mismatch gate. --- .../management/test_key_delete.py | 21 ++++++++++++++---- .../management/test_key_regenerate.py | 14 +++++++----- .../management/test_key_update.py | 22 ++++++++++++------- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/tests/proxy_behavior/management/test_key_delete.py b/tests/proxy_behavior/management/test_key_delete.py index 083b1e55104..51668e37830 100644 --- a/tests/proxy_behavior/management/test_key_delete.py +++ b/tests/proxy_behavior/management/test_key_delete.py @@ -11,7 +11,6 @@ from typing import Any, Dict, Optional import pytest from .actors import TEAM_ALPHA, TEAM_BETA, Actor -from .conftest import MASTER_KEY pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -47,20 +46,29 @@ _SCENARIOS = [ async def _create_scratch_key( proxy_client, + seeder_cleartext: str, scratch_prefix: str, *, user_id: str, team_id: Optional[str] = None, ) -> str: + """Seed a scratch key using the proxy_admin actor (not the bare master key). + + The seeded proxy_admin actor's auth path produces user_role=PROXY_ADMIN + + a concrete user_id from the DB, which deterministically triggers the + ``_user_can_only_create_keys_for_themselves`` PROXY_ADMIN bypass. The + bare master key takes a different auth resolution path whose behavior + differs between fresh-CI and warm-local environments. + """ body: Dict[str, Any] = {"key_alias": scratch_prefix, "user_id": user_id} if team_id is not None: body["team_id"] = team_id resp = await proxy_client.post( "/key/generate", - headers={"Authorization": f"Bearer {MASTER_KEY}"}, + headers={"Authorization": f"Bearer {seeder_cleartext}"}, json=body, ) - assert resp.status_code == 200, f"setup: master /key/generate failed: {resp.text}" + assert resp.status_code == 200, f"setup: seeder /key/generate failed: {resp.text}" return resp.json()["key"] @@ -84,11 +92,15 @@ async def test_key_delete_authz_matrix( if target_shape == "self": target_cleartext = await _create_scratch_key( - proxy_client, scratch.prefix, user_id=caller.user_id + proxy_client, + world.keys[Actor.PROXY_ADMIN].cleartext, + scratch.prefix, + user_id=caller.user_id, ) elif target_shape == "owner": target_cleartext = await _create_scratch_key( proxy_client, + world.keys[Actor.PROXY_ADMIN].cleartext, scratch.prefix, user_id=world.keys[Actor.OWNER].user_id, team_id=TEAM_ALPHA, @@ -96,6 +108,7 @@ async def test_key_delete_authz_matrix( elif target_shape == "cross_org": target_cleartext = await _create_scratch_key( proxy_client, + world.keys[Actor.PROXY_ADMIN].cleartext, scratch.prefix, user_id=world.keys[Actor.CROSS_ORG_USER].user_id, team_id=TEAM_BETA, diff --git a/tests/proxy_behavior/management/test_key_regenerate.py b/tests/proxy_behavior/management/test_key_regenerate.py index cad02282321..cbe677c7926 100644 --- a/tests/proxy_behavior/management/test_key_regenerate.py +++ b/tests/proxy_behavior/management/test_key_regenerate.py @@ -16,7 +16,6 @@ from typing import Any, Dict, Optional import pytest from .actors import TEAM_ALPHA, TEAM_BETA, Actor -from .conftest import MASTER_KEY pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -56,20 +55,22 @@ _SCENARIOS = [ async def _create_scratch_key( proxy_client, + seeder_cleartext: str, scratch_prefix: str, *, user_id: str, team_id: Optional[str] = None, ) -> str: + """Seeded under the proxy_admin actor (deterministic PROXY_ADMIN bypass).""" body: Dict[str, Any] = {"key_alias": scratch_prefix, "user_id": user_id} if team_id is not None: body["team_id"] = team_id resp = await proxy_client.post( "/key/generate", - headers={"Authorization": f"Bearer {MASTER_KEY}"}, + headers={"Authorization": f"Bearer {seeder_cleartext}"}, json=body, ) - assert resp.status_code == 200, f"setup: master /key/generate failed: {resp.text}" + assert resp.status_code == 200, f"setup: seeder /key/generate failed: {resp.text}" return resp.json()["key"] @@ -88,13 +89,15 @@ async def test_key_regenerate_authz_matrix( ): caller = world.keys[actor] + seeder = world.keys[Actor.PROXY_ADMIN].cleartext if target_shape == "self": target_cleartext = await _create_scratch_key( - proxy_client, scratch.prefix, user_id=caller.user_id + proxy_client, seeder, scratch.prefix, user_id=caller.user_id ) elif target_shape == "owner": target_cleartext = await _create_scratch_key( proxy_client, + seeder, scratch.prefix, user_id=world.keys[Actor.OWNER].user_id, team_id=TEAM_ALPHA, @@ -102,6 +105,7 @@ async def test_key_regenerate_authz_matrix( elif target_shape == "cross_org": target_cleartext = await _create_scratch_key( proxy_client, + seeder, scratch.prefix, user_id=world.keys[Actor.CROSS_ORG_USER].user_id, team_id=TEAM_BETA, @@ -160,7 +164,7 @@ async def test_key_path_regenerate_smoke(proxy_client, scratch, world): path form, and the same rotation contract holds.""" caller = world.keys[Actor.PROXY_ADMIN] target_cleartext = await _create_scratch_key( - proxy_client, scratch.prefix, user_id=caller.user_id + proxy_client, caller.cleartext, scratch.prefix, user_id=caller.user_id ) resp = await proxy_client.post( diff --git a/tests/proxy_behavior/management/test_key_update.py b/tests/proxy_behavior/management/test_key_update.py index b5d1723da8f..6520fbfa555 100644 --- a/tests/proxy_behavior/management/test_key_update.py +++ b/tests/proxy_behavior/management/test_key_update.py @@ -23,7 +23,6 @@ from typing import Any, Dict, List import pytest from .actors import TEAM_ALPHA, TEAM_BETA, Actor -from .conftest import MASTER_KEY pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -79,14 +78,20 @@ MARKER_MODEL = "behavior-pin-update-marker-model" async def _create_scratch_key( proxy_client, - prisma, + seeder_cleartext: str, scratch_prefix: str, *, user_id: str, team_id: str = None, organization_id: str = None, ) -> str: - """Seed a fresh key tagged with scratch_prefix and return its cleartext.""" + """Seed a fresh key tagged with scratch_prefix using the proxy_admin actor. + + Using the seeded PROXY_ADMIN actor (not the bare master key) makes the + setup call's auth path deterministic: user_role=PROXY_ADMIN + a concrete + user_id, which reliably triggers + ``_user_can_only_create_keys_for_themselves``'s admin bypass. + """ body: Dict[str, Any] = {"key_alias": scratch_prefix, "user_id": user_id} if team_id is not None: body["team_id"] = team_id @@ -94,10 +99,10 @@ async def _create_scratch_key( body["organization_id"] = organization_id resp = await proxy_client.post( "/key/generate", - headers={"Authorization": f"Bearer {MASTER_KEY}"}, + headers={"Authorization": f"Bearer {seeder_cleartext}"}, json=body, ) - assert resp.status_code == 200, f"setup: master /key/generate failed: {resp.text}" + assert resp.status_code == 200, f"setup: seeder /key/generate failed: {resp.text}" return resp.json()["key"] @@ -117,14 +122,15 @@ async def test_key_update_authz_matrix( ): caller = world.keys[actor] + seeder = world.keys[Actor.PROXY_ADMIN].cleartext if target_shape == "self": target_cleartext = await _create_scratch_key( - proxy_client, prisma, scratch.prefix, user_id=caller.user_id + proxy_client, seeder, scratch.prefix, user_id=caller.user_id ) elif target_shape == "owner": target_cleartext = await _create_scratch_key( proxy_client, - prisma, + seeder, scratch.prefix, user_id=world.keys[Actor.OWNER].user_id, team_id=TEAM_ALPHA, @@ -132,7 +138,7 @@ async def test_key_update_authz_matrix( elif target_shape == "cross_org": target_cleartext = await _create_scratch_key( proxy_client, - prisma, + seeder, scratch.prefix, user_id=world.keys[Actor.CROSS_ORG_USER].user_id, team_id=TEAM_BETA,