diff --git a/rfcs/0008-pluggable-auth/0008-pluggable-auth.md b/rfcs/0008-pluggable-auth/0008-pluggable-auth.md
new file mode 100644
index 0000000..0df30e1
--- /dev/null
+++ b/rfcs/0008-pluggable-auth/0008-pluggable-auth.md
@@ -0,0 +1,957 @@
+---
+start_date: 2026-05-27
+mlflow_issue: https://github.com/mlflow/mlflow/issues/21240
+rfc_pr:
+---
+
+
+
+| Author(s) | [Patrick Koss](https://github.com/PatrickKoss) |
+| :--------------------- | :------------------------------------------------- |
+| **Date Last Modified** | 2026-08-05 |
+
+
+
+> **Code references.** Every `file:line` reference in this document was verified
+> against MLflow `3.15.2.dev0` (commit `be812f5a1`). The auth server changes
+> often; treat the symbol names as authoritative and the line numbers as a
+> snapshot.
+
+# Summary: Pluggable Enterprise Authentication and Authorization
+
+MLflow's only built-in auth surface is a single configurable
+`authorization_function` (`mlflow/server/auth/config.py:15`). That one hook
+conflates two distinct concerns — *who are you* (authentication) and *what may
+you do* (authorization) — into one function that returns a
+`werkzeug.datastructures.Authorization` carrying nothing but a username. It is
+too thin for bearer tokens, OIDC claims, group membership, or linking an
+external identity to a local user record. Worse, the knowledge of *which
+permission a given route requires* is fused inside ~105 validator functions
+spread across six dispatch structures. Any external authorization system has to
+rediscover and duplicate that entire mapping, and re-sync it every time MLflow
+adds a route.
+
+This RFC proposes splitting auth into **two small plugin contracts** while
+keeping the expensive, churning knowledge in core:
+
+- **`AuthenticationProvider`** — turns an inbound request (headers, cookies)
+ into an `Identity`.
+- **`AuthorizationBackend`** — owns the allow/deny decision given an `Identity`
+ and a **normalized** `AuthorizationRequirement`.
+
+Exactly one of each is configured per server.
+
+The load-bearing design rule: **core retains sole ownership of the
+route → requirement mapping**, expressed as a single authoritative
+`OPERATION_REGISTRY`. Plugins never see a route, a protobuf class, or a GraphQL
+field — only the tuple `(resource_type, resource_id, action, workspace)`. A CI
+guard fails the build if any route ships without declaring a requirement.
+
+**This RFC defines contracts, not plugins.** No OIDC provider, no Kubernetes
+adapter, and no policy-engine adapter ships as part of this work. Concrete
+plugins are expected to live outside the MLflow repo and be community
+maintained, with some possibly donated back later. Where this document names
+Kubernetes `SubjectAccessReview`, OPA, or OIDC, it does so *only* to demonstrate
+that the interface shape is sufficient — those are worked examples, not
+deliverables.
+
+This is the extension RFC that **RFC 0005** ("Role-Based Access Control for
+MLflow OSS") explicitly flagged as future work in its *"Extension point:
+resolver interface"* section. It builds on 0005's
+`get_role_permission_for_resource(...)` /
+`list_accessible_workspace_names(...)` resolver surface
+(`mlflow/server/auth/sqlalchemy_store.py:2075`, `:996`) and its
+`READ/USE/EDIT/MANAGE` permission levels. It does not change 0005's role
+storage. **The default plugins reproduce today's behavior exactly: every
+authorization check that exists before this change exists after it, with the
+same outcome for the same input.**
+
+# Basic example
+
+The operator-facing change is a small config edit. The shape of the config
+mirrors the existing basic-auth INI (`mlflow/server/auth/basic_auth.ini`).
+
+**Default — identical to today's behavior.** An operator who upgrades and
+changes nothing gets exactly the current basic-auth + database RBAC:
+
+```ini
+[mlflow]
+default_permission = READ
+database_uri = sqlite:///auth.db
+admin_username = admin
+admin_password = password
+
+authn_provider = basic-auth # the authn half of today's basic auth
+authz_backend = database # the RFC 0005 role resolver, wrapped as a backend
+```
+
+An external deployment selects different plugins by name. The plugins configure
+themselves — MLflow's INI carries only the *selection*:
+
+```ini
+[mlflow]
+authn_provider = my-oidc
+authz_backend = my-policy-engine
+```
+
+**The plugin author's whole job.** An authorization plugin implements one
+required method, and it never learns MLflow's routing:
+
+```python
+class MyBackend:
+ name = "my-policy-engine"
+
+ def authorize(self, query: AuthorizationQuery) -> Decision:
+ req = query.requirement # ("experiment", "42", "update", "ml-research")
+ # ...ask whatever system owns the decision...
+ return Decision(allowed=..., reason=...)
+```
+
+The plugin sees `("experiment", "42", "update", "ml-research")`. It never sees
+`POST /api/2.0/mlflow/runs/log-metric`, never imports `LogMetric`, never learns
+that logging a metric requires `update` on the run's *parent experiment*. That
+derivation stays in core.
+
+## Motivation
+
+The legacy auth surface has three structural problems. They mirror the
+three-problem framing of RFC 0005, one layer up.
+
+**First, authentication and authorization are a single string.** The only hook
+is `auth_config.authorization_function`, a dotted path resolved through
+`importlib` (`mlflow/server/auth/__init__.py:2982`, `:2989`). The default,
+`authenticate_request_basic_auth` (`:3001`), both reads the `Authorization`
+header *and* decides the user is who they claim. There is no way to express the
+common enterprise shape "authenticate against an external IdP, but delegate the
+*decision* to an external policy system." The two concerns are welded together.
+
+**Second, the identity is too thin.** `authenticate_request()` returns a
+`werkzeug Authorization`, whose only useful attribute is `.username`. Real
+enterprise deployments need to validate a token against an IdP, carry attributes
+the authorization system will reason over, and link an external identity to a
+local record so the UI can display a real name and the review-queue features can
+assign work to a person. None of that fits through a username string. Today
+operators bolt an OAuth proxy (oauth2-proxy, Authelia, Pomerium) in front of
+MLflow; the proxy authenticates, but MLflow stays blind to who the user is
+beyond a header value.
+
+The custom-function hook also has a framework shape baked in. It is written
+against Flask's request context (`flask.request`), so the FastAPI path has to
+bridge it: `_authenticate_custom_for_fastapi` (`:4499`) constructs a synthetic
+Flask request context from the Starlette request, calls the custom function
+inside it, and converts a returned Flask `Response` back to a Starlette one
+(`_flask_response_to_starlette`, `:4546`). This works today — custom auth
+functions *are* honored on FastAPI routes — but it means every provider is
+written against a framework it may not be serving, and the adapter is a
+correctness surface that has to stay in sync with both frameworks. Deployments
+that want to avoid the bridge entirely can skip the basic-auth app and register
+their own FastAPI middleware; this is what Kubeflow's `mlflow-integration` does.
+That works, but it puts the whole route → permission mapping on the integrator.
+
+**Third, and most damaging for maintainability, the route → requirement mapping
+is fused inside the validators and scattered across six structures.** Each entry
+in `BEFORE_REQUEST_HANDLERS` (`:2605`, ~119 protobuf classes) maps a request
+class to a validator that *both* extracts the resource *and* decides
+(`validate_can_read_run` at `:1152` pulls the run id, resolves its parent
+experiment and workspace, queries grants, and returns a boolean). That knowledge
+is replicated across:
+
+| Structure | Location | Surface |
+| :--- | :--- | :--- |
+| `BEFORE_REQUEST_HANDLERS` | `:2605` | protobuf request class → validator |
+| `BEFORE_REQUEST_VALIDATORS` (+ three `.update()` blocks) | `:2762`, `:2780`, `:2804`, `:2849` | exact `(path, method)` |
+| `TRACE_PARAMETERIZED_BEFORE_REQUEST_VALIDATORS` | `:2872` | trace paths with path params |
+| `LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS` | `:2894` | logged-model paths |
+| `WEBHOOK_BEFORE_REQUEST_VALIDATORS` | `:2923` | webhook paths |
+| `_find_fastapi_validator` | `:4970` | gateway, OTel, jobs, assistant, MCP, native artifact proxy |
+| `GraphQLAuthorizationMiddleware.PROTECTED_FIELDS` | `:4302` | seven GraphQL fields |
+
+An external authorization integration cannot consume any of this. It must
+rediscover that "GetRun means read on the parent experiment," redo it for every
+operation, and re-sync on every MLflow release that adds a route. The surface
+keeps growing — MCP servers, review queues, scorers, gateway model definitions,
+and prompt-optimization jobs are all recent additions with their own validators.
+That duplication is the single hardest thing to maintain in a plugin approach,
+and it is exactly what this RFC is designed to prevent.
+
+RFC 0005 deferred the "pluggable authorization resolver" and flagged the small
+resolver interface as a known hook-in point for "a future extension RFC." This
+is that RFC.
+
+### Out of scope
+
+- **Shipping any concrete plugin.** This RFC defines the two contracts and the
+ core-side refactor that makes them viable. OIDC, Kubernetes, policy engines,
+ Kerberos, and proxy-header providers are separate work, expected to be
+ community maintained. This matches the maintainer's stated preference: make
+ MLflow pluggable, let the community maintain the plugins, with some donated
+ back.
+- **A full OIDC/SAML login experience.** Login pages, server-side sessions,
+ cookie lifecycle, single logout, and SDK token-acquisition flows are the
+ concern of a specific authentication provider plugin, not this framework RFC.
+- **Multiple simultaneous providers.** Exactly one `AuthenticationProvider` and
+ one `AuthorizationBackend` per server. See "Alternatives" for why a chain was
+ dropped.
+- **Plugin configuration format.** MLflow's INI selects plugins by name. How a
+ plugin reads its own settings (env vars, its own file, a secret mount) is the
+ plugin's business.
+- **Plugin-side caching.** Each plugin owns its own cache, TTL, and invalidation
+ strategy. Core does not wrap backends in a decision cache.
+- **Changing RFC 0005's role storage or the MLflow role model.** Roles remain a
+ concept of the built-in database backend. An external authorization system maps
+ its own groups/roles/policies to permissions inside its own plugin code; it does
+ not populate MLflow's role tables.
+- **Building an identity provider.** MLflow delegates authentication to external
+ systems.
+
+## Detailed design
+
+### The three layers
+
+The design is three layers with a hard boundary between them. The boundary is
+the whole point: it is what keeps route knowledge out of plugins.
+
+| Layer | Owner | Sees | Produces |
+| :--- | :--- | :--- | :--- |
+| **Dispatch** (route → requirement) | **MLflow core**, never a plugin | Flask `Request`, Starlette `Request`, GraphQL `info`, protobuf class, gateway path/body | an `AuthorizationRequirement` |
+| **Authentication** | `AuthenticationProvider` plugin | the request's method, path, headers, cookies, query | an `Identity` (or a challenge) |
+| **Authorization** | `AuthorizationBackend` plugin | `Identity` + `AuthorizationRequirement` + `RequestContext` | a `Decision` |
+
+```mermaid
+flowchart TD
+ req["Inbound request
(Flask / FastAPI / GraphQL / gateway)"] --> dispatch
+ dispatch["CORE dispatch
OPERATION_REGISTRY"] -->|"AuthRequest"| authn
+ authn["AuthenticationProvider
(plugin)"] -->|"Identity"| chokepoint
+ dispatch -->|"AuthorizationRequirement
(type, id, action, workspace)"| chokepoint
+ chokepoint["CORE chokepoint"] -->|"AuthorizationQuery"| authz
+ authz["AuthorizationBackend
(plugin)"] -->|"Decision"| chokepoint
+ chokepoint -->|allow| handler["route handler"]
+ chokepoint -->|deny| forbidden["403 / 401 / redirect"]
+```
+
+A plugin author never learns what a "trace tag PATCH" is, never parses a
+protobuf, never re-derives that `LogMetric` requires `update` on the parent
+experiment. Core resolves every surface into the same tuple and hands that tuple
+to the backend.
+
+### Identity
+
+The authn output and the authz subject. It replaces the thin `Authorization`.
+
+The field set is deliberately minimal: **the only first-class attributes are the
+ones core itself consumes.** Everything else an authorization system might want
+to reason over — IdP groups, raw token claims, tenant ids, employee numbers —
+travels in one opaque `metadata` mapping that core never interprets.
+
+```python
+@dataclass(frozen=True)
+class Identity:
+ # Stable principal key. The authz subject, and the link key for the local
+ # identity record. MUST be stable across logins.
+ username: str
+
+ # Optional display attributes. Core stores these on the identity record and
+ # uses them for UI rendering and work assignment. None when the provider
+ # does not supply them.
+ email: str | None = None
+ display_name: str | None = None
+ profile_url: str | None = None # the user's page on the IdP; hyperlinked in the UI
+
+ # Opaque provider payload, passed through to the authorization backend
+ # verbatim. Core never reads a key out of this. Groups, JWT claims, SAML
+ # attributes, and tenant ids all live here.
+ metadata: Mapping[str, Any] = field(default_factory=dict)
+```
+
+`frozen=True` makes the identity hashable, which matters for plugins that key
+their own caches on it.
+
+#### The identity record and why core stores it
+
+Core keeps its own table of authenticated identities, keyed by `username`,
+holding `email`, `display_name`, `profile_url`, the provider name, and
+first-seen / last-seen timestamps. On every successful authentication core
+upserts the row before authorization runs:
+
+```python
+class IdentityStore(Protocol):
+ def upsert(self, identity: Identity, provider: str) -> None: ...
+```
+
+This is **a new table, separate from basic auth's `users` table.** Basic auth
+sits behind the same plugin boundary as any other provider, so core must be
+agnostic to its storage. Mixing external identities into the basic-auth users
+table would conflate "principal MLflow has seen" with "local account with a
+password hash," and would make basic auth's schema a de facto core dependency.
+
+The record exists because several MLflow features need to name a person who is
+not necessarily a local account:
+
+- **Review queues** assign work to a user and stamp an owner
+ (`_get_request_username()` in `mlflow/server/handlers.py:4778`, fed by
+ `g.mlflow_authenticated_user` at `mlflow/server/auth/__init__.py:3083`).
+- **The UI** renders a display name rather than a raw principal string, and can
+ hyperlink `profile_url`.
+- **Audit** needs a stable record of principals the server has authenticated.
+
+Providers never write to this table; core does. The upsert is idempotent and
+attribute-refreshing, so a display name changed at the IdP propagates on next
+login.
+
+#### Trusting identity over request payload
+
+Several MLflow write paths currently take user attribution from the request body
+— most visibly the `mlflow.user` tag on run creation. With a resolved `Identity`
+in hand, core should **overwrite** those attributions from the authenticated
+principal rather than trusting a client-supplied value, whenever an auth provider
+is active. Kubeflow's MLflow auth integration already does this downstream; it
+belongs in core once identity is a first-class object. The seam exists
+(`g.mlflow_authenticated_user`); this RFC makes it authoritative.
+
+### AuthenticationProvider
+
+```python
+@dataclass(frozen=True)
+class AuthChallenge:
+ """How the client should authenticate. Core turns this into a response."""
+ status_code: int = 401
+ headers: Mapping[str, str] = field(default_factory=dict) # WWW-Authenticate, Location, Set-Cookie
+ # When True, core emits a 302 redirect for HTML clients and a 401 for API
+ # clients (decided by content negotiation, in core).
+ is_redirect: bool = False
+
+
+@dataclass(frozen=True)
+class AuthenticationResult:
+ """Exactly one outcome per provider call."""
+ kind: Literal["authenticated", "challenge"]
+ identity: Identity | None = None
+ challenge: AuthChallenge | None = None
+
+ @property
+ def is_authenticated(self) -> bool:
+ return self.kind == "authenticated"
+
+ # Factories the *plugin* calls to build its return value.
+ @staticmethod
+ def success(identity: Identity) -> "AuthenticationResult":
+ return AuthenticationResult("authenticated", identity=identity)
+
+ @staticmethod
+ def needs_challenge(challenge: AuthChallenge) -> "AuthenticationResult":
+ return AuthenticationResult("challenge", challenge=challenge)
+
+
+class AuthenticationProvider(Protocol):
+ name: str # matches the entry-point name; recorded on the identity record
+
+ def authenticate(self, request: "AuthRequest") -> AuthenticationResult: ...
+```
+
+`AuthenticationProvider.authenticate` is **the only method core calls.**
+`AuthenticationResult.success(...)` / `.needs_challenge(...)` are constructors
+the plugin uses to build what it returns; core never calls them.
+
+### AuthRequest: one interface, both frameworks
+
+MLflow runs both Flask (WSGI, `_before_request` at `:3068`) and FastAPI/Starlette
+(`fastapi_permission_middleware` at `:5125`). Custom auth functions today are
+Flask-shaped and reach FastAPI only through the request-context bridge at
+`:4499`. We should not perpetuate that: a provider should be written once,
+against neither framework.
+
+Both Flask and Starlette requests are wrapped into one read-only adapter:
+
+```python
+class AuthRequest(Protocol):
+ @property
+ def method(self) -> str: ...
+ @property
+ def path(self) -> str: ...
+ @property
+ def headers(self) -> Mapping[str, str]: ... # case-insensitive
+ @property
+ def cookies(self) -> Mapping[str, str]: ...
+ @property
+ def query(self) -> Mapping[str, str]: ...
+```
+
+### AuthorizationBackend: the component that owns the decision
+
+```python
+@dataclass(frozen=True)
+class AuthorizationRequirement:
+ resource_type: str # permissions.VALID_RESOURCE_TYPES, plus "system"
+ resource_id: str | None # concrete id / name; None for create-in-workspace and pure workspace ops
+ action: str # "read" | "use" | "update" | "delete" | "manage" | "create"
+ workspace: str | None # resolved workspace name, or None when workspaces are disabled
+
+
+@dataclass(frozen=True)
+class RequestContext:
+ """Built by core, read-only, one per request. Provided so a backend can log,
+ correlate, or apply request-shaped policy — never so it can re-derive the
+ requirement."""
+ operation: str # the OPERATION_REGISTRY key, e.g. "GetRun", "graphql.mlflowGetRun"
+ method: str # HTTP method
+ path: str # request path
+ request_id: str # per-request correlation id, also emitted in audit logs
+ surface: Literal["rest", "graphql", "gateway"]
+
+
+@dataclass(frozen=True)
+class AuthorizationQuery:
+ subject: Identity
+ requirement: AuthorizationRequirement
+ context: RequestContext
+
+
+@dataclass(frozen=True)
+class Decision:
+ allowed: bool
+ # READ/USE/EDIT/MANAGE/NO_PERMISSIONS. None when the backend does not model
+ # permission levels (a boolean-only system).
+ effective_permission: str | None = None
+ # True/False when the backend has an administrator concept and can answer;
+ # None when it has no such concept, in which case core falls back to its own
+ # super-admin rule.
+ is_admin: bool | None = None
+ reason: str | None = None # surfaced in the 403 body and the audit log
+
+
+class AuthorizationBackend(Protocol):
+ name: str
+
+ def authorize(self, query: AuthorizationQuery) -> Decision: ...
+
+ # Declares which resource types and actions this backend can decide.
+ # Called once at server startup. See "Capability negotiation".
+ def capabilities(self) -> "BackendCapabilities": ...
+
+ # Optional. Enumerates what the subject may access, so core can push a filter
+ # into the store query instead of filtering the response. See "Search".
+ def list_authorized(
+ self, subject: Identity, resource_type: str, action: str, workspace: str | None
+ ) -> "AuthorizedResources": ...
+```
+
+`RequestContext` is constructed by core at the chokepoint and is deliberately
+narrow: it carries provenance and correlation, not raw request material. It does
+**not** carry the request body, the protobuf message, or the framework request
+object — handing those to a plugin would reopen the route-knowledge boundary this
+RFC exists to close.
+
+The **six action verbs** map onto the existing `Permission` booleans
+(`mlflow/server/auth/permissions.py:8`): `read→can_read`, `use→can_use`,
+`update→can_update`, `delete→can_delete`, `manage→can_manage`, plus `create`,
+which today is a workspace-level gate (`_user_can_create_in_workspace()` at
+`:642`, ultimately a workspace-wide check in `_can_create_in_workspace()` at
+`:608`). These six cover every existing validator.
+
+#### Capability negotiation
+
+MLflow adds resource types (recently: `mcp_server`, `scorer`,
+`gateway_model_definition`). A backend pinned to an older MLflow may not know how
+to decide on a newer one. Silently allowing — or silently denying — is
+unacceptable in both directions, so the backend declares what it covers and core
+enforces the boundary at startup:
+
+```python
+@dataclass(frozen=True)
+class BackendCapabilities:
+ resource_types: frozenset[str] # e.g. permissions.VALID_RESOURCE_TYPES | {"system"}
+ actions: frozenset[str] # subset of the six verbs
+ supports_list_authorized: bool = False
+```
+
+At startup, core computes the set of `(resource_type, action)` pairs the
+`OPERATION_REGISTRY` can emit and diffs it against `capabilities()`. Behavior on
+a gap is operator-selectable:
+
+| `authz_compatibility` | Behavior on an uncovered `(resource_type, action)` |
+| :--- | :--- |
+| `strict` (default) | server refuses to start, naming the uncovered pairs |
+| `lenient` | server starts with a loud warning; requests hitting an uncovered pair are **denied** at request time with an explanatory reason |
+
+`strict` is the default because it turns a silent authorization gap into a
+startup failure. `lenient` exists for operators who want MLflow patch updates
+without waiting on a plugin release, and who do not use the newer features — they
+accept that those features are unreachable until the plugin catches up.
+
+#### The default DB backend reproduces today's behavior exactly
+
+`DefaultDbAuthorizationBackend.authorize(query)`:
+
+1. If the local user row is admin, return `Decision(allowed=True, is_admin=True)`
+ — reproduces the `sender_is_admin()` short-circuit at `:3089` (`sender_is_admin`
+ itself at `:1374`).
+2. For `create`, reproduce `_user_can_create_in_workspace()` (`:642`).
+3. Otherwise call the *existing* resolver:
+ `perm = store.get_role_permission_for_resource(user.id, requirement.resource_type,
+ requirement.resource_id, requirement.workspace)`
+ (`mlflow/server/auth/sqlalchemy_store.py:2075`), fold against
+ `default_permission` exactly as `_get_role_permission_or_default()` does
+ (`:584`), then `allowed = getattr(perm, f"can_{action}")`. Return
+ `Decision(allowed, effective_permission=perm.name)`.
+
+This is a mechanical extraction of the body already inside every `validate_can_*`
+function — the same `_role_permission_for()` / `_get_role_permission_or_default()`
+chain (`:705`, `:584`). **The default backend is today's behavior, unchanged.**
+Every check that runs before the refactor runs after it, against the same store
+methods, producing the same allow/deny. That is the backward-compatibility
+requirement, and it is asserted by a characterization test (see "Adoption
+strategy").
+
+#### Worked example: is the interface sufficient?
+
+To show the tuple is rich enough for an external system without shipping one,
+consider a Kubernetes `SubjectAccessReview`. The requirement was deliberately
+shaped so the mapping is mechanical:
+
+| Requirement field | SAR field | Mapping |
+| :--- | :--- | :--- |
+| `action` | `verb` | read→get, use→use, update→update, delete→delete, manage→`*`, create→create |
+| `resource_type` | `resource` | direct |
+| `resource_id` | `name` | direct |
+| `workspace` | `namespace` | direct |
+
+A policy-engine backend has an even simpler job: serialize
+`(subject.username, subject.metadata, requirement, context)` as the policy input
+and read `allow` back. `Identity.metadata` is what makes that work — a policy can
+reason over arbitrary IdP attributes without MLflow knowing what they are.
+
+Neither adapter is proposed for the MLflow repo. They are here to show the
+contract holds.
+
+#### What a backend can express
+
+| Capability | DB (default) | A boolean external system | A policy engine |
+| :--- | :--- | :--- | :--- |
+| `allowed` | yes | yes | yes |
+| `effective_permission` level | yes | no (boolean only) | optional |
+| `is_admin` | yes (user row) | `None` (no concept), or via policy | via policy |
+| cheap `list_authorized` | yes (one grant query) | usually not | often, via partial evaluation |
+| per-check cost | in-process | one network call | one network call |
+
+### Core keeps owning route → requirement (the centerpiece)
+
+#### Today's shape and why it traps plugins
+
+Each `BEFORE_REQUEST_HANDLERS` entry (`:2605`) maps a protobuf class to a
+validator that *both* resolves the requirement *and* decides inline. `GetRun →
+validate_can_read_run` (`:1152`) extracts the run id, looks up the experiment,
+resolves the workspace, queries grants, and returns a boolean — all fused. A
+remote backend cannot reuse any of it without re-deriving "GetRun means read on
+the parent experiment." That re-derivation, multiplied across ~105 validators and
+re-synced every release, is the maintenance trap.
+
+#### The refactor: validators become requirement descriptors
+
+Split each validator into two halves:
+
+- **Requirement resolver** (stays in core, one per operation): a pure extraction
+ `resolve(request) -> [AuthorizationRequirement]`. It pulls `run_id` from the
+ body, resolves its experiment and workspace, and returns
+ `AuthorizationRequirement("experiment", experiment_id, "read", workspace)`. No
+ decision.
+- **The decision** moves to a single chokepoint that calls `backend.authorize`.
+
+The dispatch tables change from `class → validator()` to
+`class → RequirementDescriptor`:
+
+```python
+@dataclass(frozen=True)
+class RequirementDescriptor:
+ # Pure extraction. May return several requirements (e.g. a bulk metric-history
+ # read across N runs checks read on each).
+ resolve: Callable[[Request], list[AuthorizationRequirement]]
+
+# BEFORE_REQUEST_HANDLERS becomes, e.g.:
+GetRun: RequirementDescriptor(resolve=lambda r: [_require_run(r, "read")]),
+LogMetric: RequirementDescriptor(resolve=lambda r: [_require_run(r, "update")]),
+CreateExperiment: RequirementDescriptor(resolve=lambda r: [_require_workspace_create(r)]),
+```
+
+`_require_run(r, action)` is the extraction half of today's
+`_get_permission_from_run_id` (`:887`); it returns a requirement instead of a
+`Permission`. The single chokepoint — replacing the validator call in
+`_before_request` (`:3068`) and the validator call inside
+`fastapi_permission_middleware` (`:5125`) — is:
+
+```python
+def _authorize(req, identity: Identity, descriptor) -> Response | None:
+ if descriptor is None:
+ return _handle_unmapped_route(req) # fail-closed; see the registry section
+ requirements = descriptor.resolve(req)
+ for decision in backend_authorize_all(identity, requirements, _ctx(req)):
+ if not decision.allowed:
+ return make_forbidden_response(decision.reason)
+ return None
+```
+
+`backend_authorize_all` issues one `authorize` per requirement, concurrently and
+with bounded parallelism when there is more than one — which is why the backend
+protocol needs no batch method.
+
+The dispatch functions (`_find_validator` at `:3020`, `_find_fastapi_validator`
+at `:4970`) keep their structure — the same regex/exact-match ordering
+(logged-models → webhooks → exact `(path, method)` → traces regex, and on the
+FastAPI side gateway → OTel → jobs → assistant → native artifact proxy → MCP) —
+they just return a `RequirementDescriptor` instead of a `Callable[[], bool]`. The
+fail-closed trace default (`lambda: False` at `:3062`) becomes a descriptor that
+always denies.
+
+The plugin never sees `_find_validator`, the protobuf classes, the gateway path
+regexes, or the GraphQL field names. It only ever receives an
+`AuthorizationQuery`.
+
+#### Special cases that already exist and survive cleanly
+
+- **`sender_is_admin` validators** (webhooks at `:2909`, and the admin-gated
+ user/role routes): become a descriptor producing
+ `("system", None, "manage", None)`. Core's super-admin gate, or the backend's
+ `Decision.is_admin`, handles it.
+- **`lambda: True` "authenticated but unrestricted" routes** (jobs and assistant
+ via `_get_require_authentication_validator()` at `:4862`, plus
+ `GET_CURRENT_USER`): become a `REQUIRE_AUTHENTICATED` sentinel — identity
+ required, no backend call. This is distinct from *public* (no identity needed at
+ all, `is_unprotected_route` at `:485`).
+- **After-request grant/filter handlers** (`AFTER_REQUEST_PATH_HANDLERS` at
+ `:3749`, e.g. `set_can_manage_experiment_permission` at `:3103`, and the
+ search-result filters at `:3440`+) are a separate concern, addressed under
+ "Search and list filtering" and "Drawbacks." They stay in core and are not part
+ of the plugin contract.
+
+### The authoritative OPERATION_REGISTRY and the CI guard
+
+#### One source of truth
+
+The six structures listed in "Motivation" consolidate into one declarative
+registry, keyed by a stable operation id, with an explicit protection
+classification:
+
+```python
+class Protection(Enum):
+ PUBLIC = "public" # no auth at all (health, static, landing page)
+ AUTHENTICATED = "authenticated" # identity required, no authz check
+ AUTHORIZED = "authorized" # identity + backend.authorize(requirement)
+
+
+@dataclass(frozen=True)
+class OperationSpec:
+ operation: str # "GetRun", "graphql.mlflowSearchRuns", "gateway.invoke"
+ protection: Protection
+ descriptor: RequirementDescriptor | None # required iff AUTHORIZED
+
+
+OPERATION_REGISTRY: dict[str, OperationSpec] = { ... }
+```
+
+The existing dispatch tables become *derived* from this registry (so the lookup
+structures keep their current shape and performance — no behavior change), but the
+registry is the authority and the thing reviewers edit. It is also what the
+startup capability check diffs against `BackendCapabilities`.
+
+#### The CI guard
+
+A test enumerates every registered operation across all surfaces:
+
+- **Flask/protobuf:** walk `get_endpoints(...)` (the generator already used to
+ build `BEFORE_REQUEST_VALIDATORS` at `:2762`) → every `(path, method)`.
+- **FastAPI:** introspect the FastAPI route table for `/gateway/*`, `/v1/traces`,
+ `/ajax-api/3.0/*`, MCP server paths, and native proxy-artifact paths.
+- **GraphQL:** every field on the schema's query and mutation types.
+
+For each, assert it appears in `OPERATION_REGISTRY` with an explicit
+`Protection`. A new route with no entry fails CI. This test is the most valuable
+maintainability artifact in the proposal: it forces the route → requirement
+knowledge to stay complete and in one place. It also closes today's silent
+fail-open on the FastAPI path, where an unmatched route falls through to
+`return await call_next(request)` (`:5135`).
+
+#### GraphQL: registry-driven
+
+Today `PROTECTED_FIELDS` (`:4302`) is a hardcoded set of seven fields; any field
+not in the set is unprotected. The gate `MLFLOW_SERVER_ENABLE_GRAPHQL_AUTH`
+(`:4425`) defaults to **on** (`mlflow/environment_variables.py:1602`), so GraphQL
+auth is enabled by default today — but only for those seven fields.
+
+Under the plugin model the GraphQL field → requirement map joins
+`OPERATION_REGISTRY` (operation ids like `graphql.mlflowGetRun`) and the flag is
+dropped. The middleware (`resolve` at `:4312`) resolves each field to a
+requirement and calls the same `backend.authorize` chokepoint as REST; its
+existing two-phase pattern (pre-resolve check, then `_post_resolve` filtering at
+`:4401`) maps onto `authorize` (pre) and `list_authorized` (post). The CI guard
+enforces that *every* query/mutation field is classified: read-only metadata
+fields may be `AUTHENTICATED`; data-bearing fields must be `AUTHORIZED`.
+
+#### Gateway granularity
+
+Every gateway invocation today checks `USE` on the `gateway_endpoint` resource
+(`_validate_gateway_use_permission` at `:4591`), with the endpoint name extracted
+from path or body (`_extract_gateway_endpoint_name` at `:4566`,
+`_get_gateway_validator` at `:4617`). The assessment:
+
+- `gateway_endpoint` `USE` for invocation is the right primary granularity — keep
+ it. It's the resource a caller "uses."
+- `gateway_secret` and `gateway_model_definition` have CRUD validators in
+ `BEFORE_REQUEST_HANDLERS`, but the *invocation* path checks only the endpoint,
+ not the underlying secret or model definition. For most deployments that is
+ correct: the secret is an admin artifact bound to the endpoint. **Decision:
+ invocation authorizes on the endpoint only; secret and model definition are
+ management-time resources.** Core will not issue a second mandatory check.
+
+The gateway extractor stays in core (it is route knowledge); the requirement it
+emits is `("gateway_endpoint", endpoint_id, "use", workspace)`. The current
+allow-all proxy passthrough (`validate_gateway_proxy` at `:2321`) becomes an
+explicit `AUTHENTICATED` registry entry instead of a silently-true validator,
+making the intentional openness auditable.
+
+### Search and list filtering
+
+This is the part of today's design that most needs replacing, independent of
+plugins.
+
+**Today:** core runs the store query, then filters the response and *backfills*
+by re-querying to top the page back up — `filter_search_experiments` (`:3440`),
+`filter_search_logged_models` (`:3485`), `filter_search_registered_models`
+(`:3555`), `filter_search_model_versions` (`:3611`), and
+`_backfill_readable_mcp_results` (`:4762`). If a user can read one experiment out
+of a thousand, the server can walk most of the workspace to fill a single page.
+The page-token arithmetic that keeps pagination coherent across a filtered
+response is also the fiddliest code in the auth server.
+
+**Proposed:** ask the authorization system what the subject can see, then push
+that down into the store query as a filter, so pagination works natively and no
+backfill is needed.
+
+```python
+@dataclass(frozen=True)
+class AuthorizedResources:
+ # True => the subject may perform `action` on every resource of this type
+ # in this workspace. Core issues the query unmodified, no filtering.
+ # False => `resource_ids` is the exact allowed set; core pushes it into the
+ # store query as an id filter.
+ # None => the backend cannot enumerate. Core falls back to per-result
+ # authorization (concurrent `authorize` calls over the page).
+ all: bool | None
+ resource_ids: frozenset[str] | None = None
+```
+
+- The **DB backend** answers from one grant query — the same query
+ `_role_based_read_predicate` (`:1704`) and `filter_experiment_ids` (`:1749`)
+ already use, so this is a reshape, not new cost. It returns `all=True` for a
+ workspace admin, otherwise the explicit id set.
+- A **policy engine** can often answer via partial evaluation.
+- A **boolean-only external system** returns `all=None`; core falls back to
+ filtering the page it fetched, with concurrent per-item `authorize` calls. This
+ is the honest worst case, and it is bounded by page size rather than by
+ workspace size — strictly better than today's backfill.
+
+`list_authorized` is optional; a backend that does not implement it is treated as
+`all=None`. `BackendCapabilities.supports_list_authorized` lets core log which
+mode a deployment is in.
+
+Whether to push the predicate all the way into the tracking-store SQL (rather
+than as an id list on the request) is an optimization left to implementation.
+
+### Configuration
+
+MLflow's INI stays what it is today: **basic auth's config file**. It gains
+exactly three keys, all of them *selection*, not plugin settings:
+
+| Key | Default | Meaning |
+| :--- | :--- | :--- |
+| `authn_provider` | `basic-auth` | entry-point name of the authentication provider |
+| `authz_backend` | `database` | entry-point name of the authorization backend |
+| `authz_compatibility` | `strict` | startup behavior on a capability gap |
+
+**Plugins configure themselves.** A plugin decides whether it reads env vars, its
+own file, a mounted secret, or a service-discovery endpoint. Core does not define
+`[authn.]` sections, does not parse plugin settings, and does not pass a
+config dict to plugin factories. This keeps `AuthConfig`
+(`mlflow/server/auth/config.py:10`) from becoming a registry of every plugin's
+schema, and it means a plugin's configuration can evolve without an MLflow
+release.
+
+Implementations are discovered through the entry-point pattern MLflow already
+uses (`get_entry_points` at `mlflow/server/__init__.py:220`, as with
+`mlflow.app`):
+
+```toml
+[project.entry-points."mlflow.auth.authn_provider"]
+basic-auth = "mlflow.server.auth.providers:BasicAuthProvider"
+
+[project.entry-points."mlflow.auth.authz_backend"]
+database = "mlflow.server.auth.backends:DefaultDbAuthorizationBackend"
+```
+
+**Backward-compatibility shim:** if the legacy `authorization_function` key is
+present and `authn_provider` is absent, core synthesizes a provider that wraps
+that function (adapting its `Authorization` return to `Identity(username=...)`)
+and selects `authz_backend = database`. Existing configs keep working unchanged,
+including on the FastAPI path — the shim replaces the synthetic-request-context
+bridge at `:4499`, so custom functions stop needing Flask at all.
+
+### Error handling and fail-closed
+
+An exception or timeout from either plugin is a **deny**. There is no
+`allow-on-error` mode and no cross-backend fallback: with a single configured
+backend there is nothing coherent to fall back *to*, and an escape hatch that
+turns an outage into open access is not something core should offer.
+
+This matches the existing posture: unknown trace paths deny (`:3062`),
+workspace-lookup failure denies, and RFC 0005's `check_user_permission` denies
+unknown resources. The unmapped-route handler denies. Denials carry
+`Decision.reason` (or a generic reason for a raised exception) into the 403 body
+and the audit log, and plugin exceptions are logged with a stack trace so an
+outage is diagnosable rather than merely opaque.
+
+## Drawbacks
+
+- **Per-request remote latency.** For a network-backed authorization plugin every
+ protected request becomes a round trip. Core no longer provides a decision
+ cache, so mitigation is entirely the plugin's responsibility — a deliberate
+ trade (plugins know their invalidation semantics; core does not) but a real
+ one. A busy UI page issuing many REST/GraphQL calls multiplies this.
+- **A boolean backend loses the effective permission level.** A system that
+ answers only allow/deny leaves `Decision.effective_permission` as `None`, so
+ RFC 0005's admin-UI "what is Bob's level on experiment 42?" degrades to
+ allow/deny under such a deployment. Acceptable, but it must be documented.
+- **Grant authoring is DB-backend-specific.** The after-request handler
+ `set_can_manage_experiment_permission` (`:3103`) writes a grant on resource
+ creation — meaningless for a backend whose grants live externally. Core must
+ make these handlers no-ops when the configured backend is not the DB backend.
+ The `AuthorizationBackend` protocol is deliberately *decision-only*; grant
+ *authoring* (RFC 0005's role API, the after-create MANAGE grant) stays
+ DB-backend-specific and is skipped under an external backend that owns its own
+ grants. This is real coupling and is surfaced rather than hidden.
+- **Capability negotiation can block upgrades.** `strict` mode means a lagging
+ plugin blocks an MLflow upgrade. That is the point, but `lenient` exists
+ precisely because it is sometimes the wrong trade-off.
+- **Implementation cost.** Splitting ~105 fused validators into extraction +
+ descriptor, then routing every surface through one chokepoint, is a sizeable
+ refactor of `mlflow/server/auth/__init__.py` (5,407 lines today). The risk is
+ mitigated by the default backend reproducing exact behavior and by a
+ characterization test that runs the existing auth integration suite against the
+ new chokepoint.
+
+## Alternatives
+
+**Keep the single `authorization_function` string; don't split authn/authz.**
+Rejected. It conflates the two concerns the issue explicitly asks to separate, it
+can't express "external authn + external authz," and its Flask-shaped signature
+forces the synthetic-request-context bridge (`:4499`) to make it work on FastAPI
+routes at all.
+
+**An ordered chain of authentication providers.** Rejected, after review. It
+sounds convenient (bearer, then cookie, then basic) but it requires each provider
+to answer a question it usually cannot: "is this credential mine but invalid, or
+not mine at all?" A provider that cannot distinguish those either swallows real
+failures or breaks the chain, and the resulting challenge comes from whichever
+provider happened to be last. One provider per server is unambiguous, and a
+deployment that genuinely needs multi-scheme authentication can implement one
+composite provider whose internal ordering it fully controls.
+
+**Let plugins own routing — each plugin re-derives route → requirement.**
+Rejected. This is exactly the maintainability trap the issue calls out. Every
+plugin would duplicate the mapping and drift on every new MLflow route. The whole
+design exists to prevent this.
+
+**Two authentication entry points (one Flask, one FastAPI) instead of
+`AuthRequest`.** Rejected. It doubles the provider implementation surface for
+identical token-validation logic, and a doubled surface drifts. That is the
+lesson of the bridge that exists today.
+
+**Per-resource backend methods** (`can_read_experiment`, `can_use_gateway`, …)
+mirroring the pre-RBAC layout. Rejected for the same reason RFC 0005 rejected
+per-table storage: it preserves fan-out, and it makes every new resource type a
+breaking protocol change. One `authorize(query)` plus declared capabilities is
+the consolidation.
+
+**A core-managed decision cache.** Rejected on review. Core cannot know a
+backend's invalidation semantics; a fixed-TTL wrapper is either too aggressive
+for a system with push invalidation or too lax for one without. Plugins cache.
+
+**Impact of not doing this.** Enterprises keep running an OAuth proxy in front of
+MLflow (extra infrastructure, MLflow blind to identity) or fork the auth server.
+External integrations keep duplicating the route → permission map and re-syncing
+it every release — the precise cost the issue raises.
+
+# Adoption strategy
+
+The change is largely additive, with a few deliberate behavior corrections.
+
+**basic-auth is unchanged.** It becomes `BasicAuthProvider` (the authn half of
+today's `authenticate_request_basic_auth`) plus `DefaultDbAuthorizationBackend`
+(the authz half — the RFC 0005 resolver). The default config selects exactly
+these, so an operator who upgrades and changes nothing sees identical behavior.
+**All authorization checks remain the same after the migration**: same resources,
+same required permission levels, same admin short-circuit, same fail-closed
+defaults. This is asserted by the CI guard plus a characterization test that runs
+the existing auth integration suite unmodified against the new chokepoint.
+
+**Additive (non-breaking):** the `Identity` / `Decision` types, the identity
+record table, the entry-point groups, the `OPERATION_REGISTRY`, and the
+`authn_provider` / `authz_backend` / `authz_compatibility` config keys.
+
+**Behavior changes, called out explicitly:**
+
+- **GraphQL authorization extends beyond the seven hardcoded fields.** GraphQL
+ auth is already on by default (`MLFLOW_SERVER_ENABLE_GRAPHQL_AUTH` defaults to
+ `True`), but only `PROTECTED_FIELDS` (`:4302`) is checked. Under the registry
+ every field is classified, so fields that are currently reachable without an
+ authorization check start being checked. This is a deliberate fail-closed
+ correction, scoped to the auth-enabled server; the flag itself is dropped.
+- **The FastAPI unmapped-route path flips from fail-open to fail-closed.** Today
+ an unmatched FastAPI route falls through to `call_next` (`:5135`). Under the
+ registry it denies. The CI guard makes this discoverable before release;
+ operators who mount custom FastAPI routes on the auth app must register them.
+- **User attribution is taken from the authenticated identity**, not from a
+ client-supplied request field, when an auth provider is active. A client that
+ currently sets `mlflow.user` to an arbitrary value will see it overwritten.
+- **`Identity` replaces the `Authorization` return contract** of
+ `authenticate_request()`. A third party that wrote a custom
+ `authorization_function` returning a `werkzeug Authorization` is shimmed (the
+ returned object is adapted to `Identity(username=...)`), so it keeps working but
+ is soft-deprecated.
+
+**Sequencing.**
+
+1. Land the `OPERATION_REGISTRY` + descriptor refactor and the CI guard with the
+ decision still made by the existing DB code path. No plugin surface yet; this
+ is pure consolidation and is independently valuable.
+2. Introduce the two protocols, the identity record, and the default plugins,
+ with the legacy `authorization_function` shim live.
+3. Document the entry-point migration; deprecate the shim one minor release
+ later.
+
+RFC 0005's role model and resolver interface are a prerequisite — this RFC
+assumes 0005 has landed.
+
+# Open questions
+
+- **Are `system` / super-admin operations backend-gated or core-only?** Truly
+ global operations (create user, delete workspace) use `sender_is_admin` today
+ (`:1374`). Do we model them as `("system", None, "manage")` and let a backend
+ authorize them, or keep super-admin a core-only gate that a backend can only
+ *assert* (via `Decision.is_admin`) but not *grant*? Leaning toward core-only for
+ `system` ops, backend-decided everywhere else.
+- **How is `is_admin=None` resolved?** When a backend reports no admin concept,
+ core falls back to "its own super-admin rule" — which today means the local user
+ row's `is_admin` flag. Under an external provider there may be no local row with
+ meaningful admin state. Options: a configured list of admin principals, an
+ `authorize(("system", None, "manage", None))` probe, or simply no admins outside
+ the DB backend.
+- **Does `list_authorized` need an id-set size bound?** Pushing a
+ hundred-thousand-id filter into a store query is its own problem. A cap that
+ degrades to `all=None` above some threshold is probably right, but the threshold
+ is an implementation question.
+- **Should the identity record be writable by MLflow admins?** Correcting a
+ display name locally, or marking a principal disabled independently of the IdP,
+ is plausible but expands the record from a cache into a store with its own
+ conflict-resolution rules. Leaning toward read-only-from-IdP for v1.
+- **Per-request workspace-resolution cost under remote backends.** The workspace
+ lookup happens in core, *before* the backend call, to build the requirement. It
+ stays in core and keeps its existing cache (`workspace_cache_ttl_seconds`), but
+ it adds a store round-trip per request that a remote backend can't avoid.