fix(proxy): refuse a teamless native-client grant for a user who has teams

The consent page offers the team picker, but a form posted without a team
sealed a teamless grant and the token endpoint minted an unscoped
credential for a team member, escaping the team attribution classic lite
login always applies. The minter now refuses such a grant on redemption
and refresh alike; memberships whose team rows are gone still count as no
team so they cannot lock a user out
This commit is contained in:
mateo-berri 2026-08-20 05:37:35 -07:00
parent c011b4b56f
commit 17455f4086
4 changed files with 30 additions and 8 deletions

View file

@ -154,7 +154,7 @@ PROXY_API_AUDIENCE: Final[SessionAudience] = "proxy_api"
proxy base URL itself as the RFC 8707 ``resource``: the grant then mints the proxy-API CLI
credential that LLM routes accept, instead of the MCP-only session pair."""
ProxyCredentialMintFailure = Literal[ReloadUserFailure, "not_a_member"]
ProxyCredentialMintFailure = Literal[ReloadUserFailure, "not_a_member", "team_required"]
class MintedProxyCredential(BaseModel):
@ -167,7 +167,8 @@ class MintedProxyCredential(BaseModel):
class MintProxyCredential(Protocol):
"""Injected proxy-API credential minter ``(user_id, team_id)``: reloads the user live,
checks team membership, and mints the same credential ``lite login`` mints."""
checks team membership, refuses a teamless grant for a user who has teams to pick from,
and mints the same credential ``lite login`` mints."""
def __call__(
self, user_id: str, team_id: str | None, /
@ -924,6 +925,10 @@ def _mint_failure_response(failure: ProxyCredentialMintFailure) -> Response:
return _oauth_error(
400, "invalid_grant", "the user is no longer a member of the team this grant was issued for"
)
case "team_required":
return _oauth_error(
400, "invalid_grant", "this user belongs to a team; sign in again and pick the team for this credential"
)
case "unavailable" | "unresolvable" | "no_active_key":
return _reload_failure_response(failure)
case _:

View file

@ -45,7 +45,11 @@ async def mint_proxy_credential(
live, so a team the user left between consent and redemption (or between refreshes)
refuses the grant instead of minting a credential attributed to a team they are no
longer on. The team is exactly the one the consent page sealed into the grant; nothing
is picked on the user's behalf here, so a refresh can never move the credential. The
is picked on the user's behalf here, so a refresh can never move the credential, and a
grant that names no team is refused for a user with a live team to pick from (the same
rule ``lite login`` applies), so a user cannot step outside their teams' attribution by
posting the consent form without one. Memberships whose team rows are gone count as no
team at all, the way ``lite login`` treats them, so they can never lock a user out. The
user row handed to the minter carries no team list, exactly like ``lite login``'s, so
the minter's own first-team fallback stays inert."""
user: Final = await load_active_user_by_id(user_id)
@ -55,9 +59,11 @@ async def mint_proxy_credential(
return "no_active_key"
if team_id is not None and team_id not in user.teams:
return "not_a_member"
details: Final = await _team_details(user.teams) if team_id is not None else ()
details: Final = await _team_details(user.teams) if user.teams else ()
if details is None:
return "unavailable"
if team_id is None and any(detail.team_id is not None for detail in details):
return "team_required"
selected: Final = selected_cli_sso_team_detail(details, team_id)
if selected is None:
return "not_a_member"

View file

@ -1414,6 +1414,7 @@ async def test_native_code_without_a_minter_is_refused_server_side():
"failure, status, error",
[
("not_a_member", 400, "invalid_grant"),
("team_required", 400, "invalid_grant"),
("no_active_key", 400, "invalid_grant"),
("unavailable", 503, "temporarily_unavailable"),
("unresolvable", 500, "server_error"),

View file

@ -74,16 +74,26 @@ async def test_mint_refuses_a_user_without_a_role(load_user, fetch_teams):
@pytest.mark.asyncio
async def test_mint_without_a_chosen_team_never_picks_one_for_a_team_member(load_user, fetch_teams):
async def test_mint_refuses_a_teamless_grant_for_a_team_member(load_user, fetch_teams):
"""The consent page is the only place a team gets chosen, so a grant sealed without one
stays unscoped on every redemption and refresh instead of drifting onto the first team."""
is refused for a user with teams instead of minting an unscoped credential or drifting
onto the first team, on redemption and on every refresh alike."""
assert await mint_proxy_credential("u1", None) == "team_required"
load_user.assert_awaited_once_with("u1")
fetch_teams.assert_awaited_once_with(ANY, ["team-a", "team-b"])
@pytest.mark.asyncio
async def test_mint_for_a_member_of_only_deleted_teams_is_unscoped(load_user, fetch_teams):
"""Memberships whose team rows are gone offer nothing to pick, so they never lock the
user out of signing in."""
load_user.return_value = _user(teams=["team-gone"])
fetch_teams.return_value = ()
minted = await mint_proxy_credential("u1", None)
assert isinstance(minted, MintedProxyCredential)
assert minted.user_id == "u1"
assert minted.team_id is None
assert minted.expires_in == CLI_JWT_EXPIRATION_HOURS * 3600
load_user.assert_awaited_once_with("u1")
fetch_teams.assert_not_awaited()
decoded = _decoded(minted)
assert decoded.user_id == "u1"
assert decoded.team_id is None