ci(proxy-mgmt-behavior): seed scratch keys via proxy_admin actor, not master

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.
This commit is contained in:
Yuneng Jiang 2026-05-19 22:15:17 -07:00
parent 1e21cb6574
commit 15c7a59d76
No known key found for this signature in database
3 changed files with 40 additions and 17 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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,