From ac971d7e8b7881504b522b671996307638ab01e6 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 20:06:04 -0700 Subject: [PATCH] test(auth_v2): pin the role allowlist on the OIDC-login and SAML-SSO paths The veria review-response fix gates IdP-asserted roles through the same per-provider allowlist on every role-bearing path, not just JWT bearer tokens. - rbac: filter_claim_roles (the shared gate) denies a self-asserted role by default, filters to the allowlist, and only admits platform roles behind the explicit allow_platform_roles flag - saml: a signed SSO assertion asserting platform_admin yields a session whose Principal has no roles by default, and is filtered to the allowlist when set - oidc: the login callback's identity build (map userinfo -> gate roles -> session) denies platform_admin by default and filters to the allowlist Full auth_v2 suite: 173 passing. --- tests/test_litellm/proxy/auth_v2/test_oidc.py | 45 +++++++++++++++++++ tests/test_litellm/proxy/auth_v2/test_rbac.py | 23 ++++++++++ tests/test_litellm/proxy/auth_v2/test_saml.py | 34 ++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/tests/test_litellm/proxy/auth_v2/test_oidc.py b/tests/test_litellm/proxy/auth_v2/test_oidc.py index 36c5bca1bc2..441e828788f 100644 --- a/tests/test_litellm/proxy/auth_v2/test_oidc.py +++ b/tests/test_litellm/proxy/auth_v2/test_oidc.py @@ -48,3 +48,48 @@ async def test_callback_seam_upserts_userinfo_into_store(): assert fetched is not None assert fetched.external_id == "idp-subject-123" assert fetched.user_name == "dana" + + +async def _oidc_login_session_roles(userinfo, provider): + # mirror the callback's identity build: map userinfo, gate roles, store a session, + # then authenticate + resolve through the same seam a request would + from litellm.proxy.auth_v2.authenticators import _apply_role_policy + from litellm.proxy.auth_v2.oidc.router import _mapped_claims + from litellm.proxy.auth_v2.session import SessionAuthenticator, SessionStore + + from auth_v2_helpers import make_request + + claims = _mapped_claims(userinfo) + _apply_role_policy(claims, provider) + store = SessionStore() + sid = store.create_session( + {"method": "oidc", "subject": userinfo["sub"], "claims": claims} + ) + authenticator = SessionAuthenticator("litellm_session", store) + credential = await authenticator.authenticate( + make_request(cookies={"litellm_session": sid}) + ) + principal = await InMemoryIdentityStore().resolve(credential) + return [role.value for role in principal.roles] + + +async def test_oidc_login_platform_role_denied_by_default(): + provider = OIDCProviderConfig(issuer="https://idp.example.com", audience=["x"]) + userinfo = { + "sub": "u", + "email": "e@x.com", + "roles": ["platform_admin", "org_admin"], + } + assert await _oidc_login_session_roles(userinfo, provider) == [] + + +async def test_oidc_login_roles_filtered_to_allowlist(): + provider = OIDCProviderConfig( + issuer="https://idp.example.com", audience=["x"], allowed_roles=["org_admin"] + ) + userinfo = { + "sub": "u", + "email": "e@x.com", + "roles": ["platform_admin", "org_admin"], + } + assert await _oidc_login_session_roles(userinfo, provider) == ["org_admin"] diff --git a/tests/test_litellm/proxy/auth_v2/test_rbac.py b/tests/test_litellm/proxy/auth_v2/test_rbac.py index f034f9ba7dd..7dc9738bf57 100644 --- a/tests/test_litellm/proxy/auth_v2/test_rbac.py +++ b/tests/test_litellm/proxy/auth_v2/test_rbac.py @@ -136,3 +136,26 @@ def test_act_matcher_is_anchored(tmp_path): viewer = _principal(roles=[Role.PLATFORM_VIEWER]) assert engine.enforce(viewer, "/x", "GET") assert not engine.enforce(viewer, "/x", "GETX") + + +# --------------------------------------------------------------------------- # +# filter_claim_roles: the shared allowlist gate for JWT, OIDC-login and SAML +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "roles,allowed,allow_platform,expected", + [ + # default deny: a self-asserted role grants nothing + (["platform_admin", "org_admin"], [], False, []), + # allowlist filters; platform role excluded even if listed without the gate + (["platform_admin", "org_admin"], ["org_admin"], False, ["org_admin"]), + (["platform_admin"], ["platform_admin"], False, []), + # platform role only survives with the explicit gate + (["platform_admin"], ["platform_admin"], True, ["platform_admin"]), + ], +) +def test_filter_claim_roles(roles, allowed, allow_platform, expected): + from litellm.proxy.auth_v2.rbac import filter_claim_roles + + assert filter_claim_roles(roles, allowed, allow_platform) == expected diff --git a/tests/test_litellm/proxy/auth_v2/test_saml.py b/tests/test_litellm/proxy/auth_v2/test_saml.py index 1e971e0c84f..3d341384450 100644 --- a/tests/test_litellm/proxy/auth_v2/test_saml.py +++ b/tests/test_litellm/proxy/auth_v2/test_saml.py @@ -177,11 +177,45 @@ def _build_app(saml_env: SamlEnv): "subject": principal.subject, "auth_method": principal.auth_method.value, "email": principal.user.email if principal.user else None, + "roles": [role.value for role in principal.roles], } return app, store +def _saml_session_roles(env, *, asserted_roles): + app, _ = _build_app(env) + client = TestClient(app) + acs = client.post( + "/auth/saml/acs", + data={ + "SAMLResponse": env.mint_response( + identity={"email": ["alice@example.com"], "roles": asserted_roles} + ) + }, + follow_redirects=False, + ) + client.cookies.set("litellm_session", acs.cookies["litellm_session"]) + return client.get("/whoami").json()["roles"] + + +def test_saml_sso_platform_role_denied_by_default(saml_env): + # H1 on the SSO path: an IdP-asserted platform_admin grants nothing by default + roles = _saml_session_roles( + saml_env, asserted_roles=["platform_admin", "org_admin"] + ) + assert roles == [] + + +def test_saml_sso_roles_filtered_to_allowlist(saml_env): + env = SamlEnv( + config=saml_env.config.model_copy(update={"allowed_roles": ["org_admin"]}), + idp=saml_env.idp, + ) + roles = _saml_session_roles(env, asserted_roles=["platform_admin", "org_admin"]) + assert roles == ["org_admin"] + + # --------------------------------------------------------------------------- # # Metadata + login redirect # --------------------------------------------------------------------------- #