refactor(auth_v2): rename resolver classes, drop dead protocol boilerplate

Rename IdentityResolver -> Resolver and DbIdentityStore -> DbResolver so the
names stop colliding with authentication; the package these move to is
resolvers/, not identity/. Drop the IdentityStore union protocol, which was only
used as DbResolver's base; DbResolver now inherits Resolver and ProvisioningStore
directly. Drop the @runtime_checkable decorators on these protocols, which had no
isinstance callers. ProvisioningStore keeps its name.
This commit is contained in:
Yassin Kortam 2026-06-18 17:56:57 -07:00
parent 03f1c11e9b
commit 770ae5902c
6 changed files with 24 additions and 40 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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