diff --git a/litellm/proxy/auth_v2/README.md b/litellm/proxy/auth_v2/README.md index 2bdd8143f97..1813222f3f3 100644 --- a/litellm/proxy/auth_v2/README.md +++ b/litellm/proxy/auth_v2/README.md @@ -46,7 +46,7 @@ always run first. `401` with a combined `WWW-Authenticate` challenge built from each scheme. 2. Resolve identity. The verified `Credential` is handed to the configured - `IdentityResolver`, which builds the `Principal`. The DB resolver looks the subject up in + `Resolver`, which builds the `Principal`. The DB resolver looks the subject up in the proxy's Prisma tables (key object, user, teams, org). A blocked key or unknown subject raises `401`/`403` here, before any route logic runs. @@ -87,8 +87,8 @@ built once at the composition root. dispatch to it by where its credential lives). `build_authenticators` constructs and orders them from `AuthConfig`. JWT verification for OIDC/OAuth2 is shared via `JWTVerifier`. -`resolvers.py` holds the `IdentityResolver` / `IdentityStore` protocols and the single -`DbIdentityStore` implementation against Prisma. The store also handles SCIM user/group +`resolvers.py` holds the `Resolver` and `ProvisioningStore` protocols and the single +`DbResolver` implementation against Prisma. The store also handles SCIM user/group provisioning so a provisioned user is immediately resolvable. `utils.py` holds the pure SCIM/role-mapping helpers the store uses. @@ -108,7 +108,7 @@ the right status and challenge header. ## Adding things A new credential scheme is a new `Authenticator` plus a branch in `build_authenticators`. -A new identity backend is a new `IdentityStore`. A new authorization method (ReBAC, an +A new identity backend is a new `Resolver` (also a `ProvisioningStore` if it provisions). A new authorization method (ReBAC, an external PDP) is a new `Authorizer` passed as `AuthSecurity(..., authorizer=...)`. Dependencies are injected at construction, so each of these is unit-testable with a fake in place of the real backend. diff --git a/litellm/proxy/auth_v2/__init__.py b/litellm/proxy/auth_v2/__init__.py index 9c28253c934..e5fc4702eb2 100644 --- a/litellm/proxy/auth_v2/__init__.py +++ b/litellm/proxy/auth_v2/__init__.py @@ -11,7 +11,7 @@ from litellm.proxy.auth_v2.config import ( ) from litellm.proxy.auth_v2.models import Principal from litellm.proxy.auth_v2.authorization import Role -from litellm.proxy.auth_v2.resolvers import IdentityResolver, ProvisioningStore +from litellm.proxy.auth_v2.resolvers import ProvisioningStore, Resolver from litellm.proxy.auth_v2.security import AuthSecurity __all__ = [ @@ -19,7 +19,7 @@ __all__ = [ "AuthConfig", "Principal", "Role", - "IdentityResolver", + "Resolver", "ProvisioningStore", "ApiKeySchemeConfig", "HttpBasicConfig", diff --git a/litellm/proxy/auth_v2/resolvers.py b/litellm/proxy/auth_v2/resolvers.py index 156c435ec3b..5c259adecb6 100644 --- a/litellm/proxy/auth_v2/resolvers.py +++ b/litellm/proxy/auth_v2/resolvers.py @@ -1,7 +1,7 @@ from __future__ import annotations import uuid -from typing import TYPE_CHECKING, List, Optional, Protocol, runtime_checkable +from typing import TYPE_CHECKING, List, Optional, Protocol from scim2_models import Group as ScimGroup from scim2_models import User as ScimUser @@ -44,8 +44,7 @@ if TYPE_CHECKING: from litellm.proxy.utils import PrismaClient -@runtime_checkable -class IdentityResolver(Protocol): +class Resolver(Protocol): async def resolve(self, credential: Credential) -> Principal: """Resolve a verified credential to a Principal. @@ -59,7 +58,6 @@ class IdentityResolver(Protocol): ... -@runtime_checkable class ProvisioningStore(Protocol): async def upsert_user(self, user: ScimUser) -> ScimUser: ... async def get_user(self, resource_id: str) -> Optional[ScimUser]: ... @@ -71,31 +69,20 @@ class ProvisioningStore(Protocol): async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: ... -@runtime_checkable -class IdentityStore(IdentityResolver, ProvisioningStore, Protocol): - """An identity backend: resolves credentials and provisions SCIM users/groups. - - This is the single interface every implementation satisfies (in-memory, - database, ...). Resolution and provisioning live behind one store so a - provisioned user is immediately resolvable. - """ - - -class DbIdentityStore(IdentityStore): +class DbResolver(Resolver, ProvisioningStore): """Resolves credentials against the proxy's Prisma tables and provisions SCIM users/groups into ``LiteLLM_UserTable`` / ``LiteLLM_TeamTable``. - The Prisma client and key cache are injected so this stays a plain object - the composition root can build once the proxy DB is connected. + Resolution and provisioning share one object so a provisioned user is + immediately resolvable. The Prisma client and key cache are injected so this + stays a plain object the composition root can build once the proxy DB is + connected. """ def __init__(self, prisma_client: "PrismaClient", cache: "DualCache") -> None: self._prisma = prisma_client self._cache = cache - # ------------------------------------------------------------------ # - # IdentityResolver - # ------------------------------------------------------------------ # async def resolve(self, credential: Credential) -> Principal: if credential.method == AuthMethod.API_KEY: return await self._resolve_api_key(credential) @@ -238,9 +225,6 @@ class DbIdentityStore(IdentityStore): name = org.organization_alias if org is not None else None return OrganizationIdentity(id=user.organization_id, name=name) - # ------------------------------------------------------------------ # - # ProvisioningStore - # ------------------------------------------------------------------ # async def upsert_user(self, user: ScimUser) -> ScimUser: repo = UserRepository(self._prisma) data = scim_user_to_db(user) diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index 8ee83ac5c57..68661ddab29 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -24,7 +24,7 @@ from litellm.proxy.auth_v2.authorization import ( Role, ) from litellm.proxy.auth_v2.authenticators.session import SessionAuthenticator -from litellm.proxy.auth_v2.resolvers import IdentityResolver +from litellm.proxy.auth_v2.resolvers import Resolver from litellm.proxy.auth_v2.sessions import ( InMemorySessionStore, RedisSessionStore, @@ -83,7 +83,7 @@ class AuthSecurity: def __init__( self, config: AuthConfig, - resolver: IdentityResolver, + resolver: Resolver, authorizer: Optional[Authorizer] = None, authenticators: Optional[List[Authenticator]] = None, basic_verifier: Optional[BasicAuthVerifier] = None, diff --git a/tests/test_litellm/proxy/auth_v2/test_resolver.py b/tests/test_litellm/proxy/auth_v2/test_resolver.py index 7ff048466ea..039113badb6 100644 --- a/tests/test_litellm/proxy/auth_v2/test_resolver.py +++ b/tests/test_litellm/proxy/auth_v2/test_resolver.py @@ -14,14 +14,14 @@ from litellm.proxy.auth_v2.models import ( PrincipalType, SecuritySchemeType, ) -from litellm.proxy.auth_v2.resolvers import DbIdentityStore +from litellm.proxy.auth_v2.resolvers import DbResolver class _FakeCache: """Stands in for the DualCache that get_key_object / get_user_object read. Both helpers return a cache hit before touching the DB, so seeding this and - injecting it into DbIdentityStore exercises the real resolver mapping without + injecting it into DbResolver exercises the real resolver mapping without a database. A non-None prisma client is still required (the helpers guard on it); it is never reached on a hit. """ @@ -39,8 +39,8 @@ class _FakeCache: _PRISMA_STUB = object() -def _store(entries: Optional[Dict[str, object]] = None) -> DbIdentityStore: - return DbIdentityStore(_PRISMA_STUB, _FakeCache(entries)) +def _store(entries: Optional[Dict[str, object]] = None) -> DbResolver: + return DbResolver(_PRISMA_STUB, _FakeCache(entries)) def _api_key_credential(raw: str) -> Credential: @@ -112,7 +112,7 @@ async def test_api_key_lookup_is_keyed_on_hashed_token(): raw = "sk-live-abc" key = UserAPIKeyAuth(token=hash_token(raw), user_id="u-1") # cache seeded under the RAW key, not its hash -> resolver hashes first -> miss - store = DbIdentityStore(None, _FakeCache({raw: key})) + store = DbResolver(None, _FakeCache({raw: key})) with pytest.raises(AuthError) as exc: await store.resolve(_api_key_credential(raw)) assert exc.value.status_code == 401 @@ -130,7 +130,7 @@ async def test_blocked_key_is_rejected_403(): async def test_unknown_key_is_rejected_401(): # cache miss + no prisma -> get_key_object raises -> resolver maps to 401 - store = DbIdentityStore(None, _FakeCache()) + store = DbResolver(None, _FakeCache()) with pytest.raises(AuthError) as exc: await store.resolve(_api_key_credential("sk-live-unknown")) assert exc.value.status_code == 401 @@ -166,7 +166,7 @@ async def test_mtls_credential_resolves_to_service_account(): client_certificate=ClientCertificate(subject_dn="CN=svc-a,O=Co"), ) # service-account path does no identity lookup, so no cache/prisma needed - principal = await DbIdentityStore(None, _FakeCache()).resolve(credential) + principal = await DbResolver(None, _FakeCache()).resolve(credential) assert principal.principal_type == PrincipalType.SERVICE_ACCOUNT assert principal.user is None diff --git a/tests/test_litellm/proxy/auth_v2/test_security.py b/tests/test_litellm/proxy/auth_v2/test_security.py index 1f41d898ce3..b23422b8cba 100644 --- a/tests/test_litellm/proxy/auth_v2/test_security.py +++ b/tests/test_litellm/proxy/auth_v2/test_security.py @@ -55,10 +55,10 @@ class _FakeResolver: """Resolver double for the security-layer tests. These tests inject fully-formed Principals (arbitrary scopes/roles) keyed by - API key, which the production DbIdentityStore cannot express; DbIdentityStore + API key, which the production DbResolver cannot express; DbResolver has its own coverage in test_resolver.py. An API-key credential is looked up by its raw-key claim; anything else echoes the credential's subject. Returns a - fresh Principal per the IdentityResolver contract. + fresh Principal per the Resolver contract. """ def __init__(self, by_key: Dict[str, Principal]) -> None: