From 83d13d8fa1a4076a61807e44dcae26ad4773036b Mon Sep 17 00:00:00 2001 From: Rahul Kharse Date: Thu, 6 Aug 2026 16:44:35 +0000 Subject: [PATCH 1/2] feat: Add RFC for fine-grained sub-resource permissions Propose making all DB-indexed resource types independently permissionable behind a configuration flag. In simplified mode (default), behavior is identical to today. In fine-grained mode, admins can grant on run, trace, assessment, logged_model, model_version, and other sub-resources independently. Key use cases: - Run access without experiment management (#11496) - Assessment-only write access (evaluation boundary) - Model version lifecycle separation from registered_model --- .../0000-sub-resource-permissions.md | 452 ++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 rfcs/0000-sub-resource-permissions/0000-sub-resource-permissions.md diff --git a/rfcs/0000-sub-resource-permissions/0000-sub-resource-permissions.md b/rfcs/0000-sub-resource-permissions/0000-sub-resource-permissions.md new file mode 100644 index 0000000..0d20fd7 --- /dev/null +++ b/rfcs/0000-sub-resource-permissions/0000-sub-resource-permissions.md @@ -0,0 +1,452 @@ +start_date: 2026-08-06 +mlflow_issue: https://github.com/mlflow/mlflow/issues/11496 +rfc_pr: + +# Summary + +MLflow 3.13's RBAC model recognizes a fixed set of resource types (`experiment`, +`registered_model`, `prompt`, `scorer`, `workspace`). Resources that are children +of a permissionable parent (e.g., runs under experiments, model versions under +registered models) are not independently permissionable — they always resolve to +their parent's permission level. + +This RFC proposes making **all DB-indexed resource types** independently +permissionable, gated behind a configuration flag: + +- **`permission_granularity = simplified`** (default): today's behavior. Only + existing resource types are recognized. Sub-resources resolve to their parent. +- **`permission_granularity = fine_grained`** (opt-in): all resource types are + recognized. Admins grant on each resource type independently. + +The design is fully backward compatible: the default mode is identical to today. +Operators opt into fine-grained mode when they need per-resource-type control. + +# Basic example + +### Simplified mode (default — today's behavior) + +```python +# EDIT on experiment = EDIT on everything (runs, traces, assessments, logged models) +client.add_role_permission(ds_role.id, "experiment", "*", "EDIT") +# Sub-resources are not independently permissionable. Identical to today. + +# Sub-resource grants are accepted with a warning but have no effect: +client.add_role_permission(evaluator_role.id, "trace", "*", "READ") +# ⚠️ Warning: "Resource type 'trace' is only enforced in fine_grained mode. +# Grant will be stored but has no effect until permission_granularity +# is changed to 'fine_grained'." +# The grant is stored — ready for when the operator switches modes. +``` + +_A warning rather than an error allows admins to pre-create sub-resource grants +before switching modes — see [Adoption strategy](#adoption-strategy) for the +safe transition path._ + +### Fine-grained mode (opt-in) + +```ini +[mlflow] +permission_granularity = fine_grained +``` + +```python +# Evaluator: can view experiments and write assessments, nothing else +client.add_role_permission(evaluator_role.id, "experiment", "*", "READ") +client.add_role_permission(evaluator_role.id, "assessment", "*", "EDIT") + +# Data scientist: full access to everything +client.add_role_permission(ds_role.id, "experiment", "*", "EDIT") +client.add_role_permission(ds_role.id, "run", "*", "EDIT") +client.add_role_permission(ds_role.id, "trace", "*", "EDIT") +client.add_role_permission(ds_role.id, "assessment", "*", "EDIT") +client.add_role_permission(ds_role.id, "logged_model", "*", "EDIT") + +# Pipeline: can log runs and traces, but only read assessments +client.add_role_permission(pipeline_role.id, "experiment", "*", "EDIT") +client.add_role_permission(pipeline_role.id, "run", "*", "EDIT") +client.add_role_permission(pipeline_role.id, "trace", "*", "EDIT") +client.add_role_permission(pipeline_role.id, "assessment", "*", "READ") +client.add_role_permission(pipeline_role.id, "logged_model", "*", "EDIT") +``` + +Effective permissions in fine-grained mode: + +| User | experiment | run | trace | assessment | logged_model | +|------|-----------|-----|-------|------------|--------------| +| evaluator | READ | — | — | **EDIT** | — | +| data-scientist | EDIT | EDIT | EDIT | EDIT | EDIT | +| pipeline | EDIT | EDIT | EDIT | **READ** | EDIT | + +_(— = NO_PERMISSIONS, no grant configured)_ + +## Motivation + +RFC 0005 established workspace-scoped roles with permission grants on resource +types. Its `VALID_RESOURCE_TYPES` set covers only top-level resources — +sub-experiment resources (runs, traces, assessments, logged models) are not +recognized, and their validators resolve to the parent experiment's permission +level. This was a reasonable simplification for v1, but as MLflow's resource +model grows — particularly with traces and assessments for LLM evaluation — the +limitation creates real access control gaps. + +### Use cases that cannot be expressed today + +**1. Run access without experiment management (#11496)** + +An organization uses MLflow Model Registry. Users need to create runs (to log +models for registration) but should NOT be able to create or manage experiments +— only the admin should control experiment lifecycle. Today, run permissions +depend entirely on experiment permissions: restricting experiment access to +NO_PERMISSIONS also blocks run creation, and granting experiment EDIT to enable +run creation also grants experiment management (create new experiments, rename, +delete). + +What they want: `NO_PERMISSIONS` on experiments + `EDIT` on runs. + +Today: impossible. The only workaround is granting experiment EDIT, which also +lets users create new experiments and get automatic MANAGE on them — the opposite +of what the admin intended. + +**2. Assessment-only write access (validation boundary)** + +An enterprise has a validation workflow where production pipelines log traces +and human evaluators annotate them with assessments. Evaluators need to view +everything and write assessments, but should NOT be able to create runs, modify +traces, or alter experiments. + +What they want: `READ` on experiments/runs/traces + `EDIT` on assessments. + +Today: `CreateAssessment` and `CreateRun` both require experiment EDIT — granting +one inevitably grants the other. There is no way to separate assessment-write +from run-write or trace-write. This is because `CreateAssessment` uses +`validate_can_update_trace_by_trace_id`, which resolves to the parent +experiment's permission — conflating assessment writes with trace updates. + +**3. Model version lifecycle separation** + +A platform team owns the model registry namespace (which models exist, their +descriptions, aliases) while data scientists push new versions. Today, granting +experiment EDIT to enable `CreateModelVersion` also grants the ability to rename +or delete the registered model itself. + +What they want: `MANAGE` on `registered_model` for platform team + +`EDIT` on `model_version` for data scientists. + +Today: `model_version` inherits from `registered_model` — you cannot separate +"push a new version" from "manage the model entry." + +### Out of scope + +- **Fine-grained auth for non-DB-indexed resources** (e.g., individual spans + within a trace) — these are stored in blob/artifact storage, not queryable as + independent DB entities. +- **Fail-closed enforcement for unregistered resource types** — today, routes + without a registered validator are fail-open (any authenticated user can + access). This proposal preserves that behavior. +- **Parent-scoped sub-resource grants** — in fine-grained mode, a grant like + `(trace, *, EDIT)` applies to all traces in the workspace, not to traces within + a specific parent resource. There is no way to express "EDIT on traces, but only + within experiment X." This would require a richer `resource_pattern` syntax or + condition-based matching, which is not addressed here. + +## Detailed design + +### New resource types + +The following resources have their own APIs but currently inherit permissions from +a parent resource type. In fine-grained mode, each becomes independently +permissionable. + +_Verified against MLflow 3.15.1 source (`mlflow/server/auth/__init__.py`)._ + +**Inherits from `experiment`:** + +| Resource | DB table | API examples | Current resolution | Proposed resolution (fine-grained) | +|----------|----------|--------------|-------------------|-----------------------------------| +| `run` | `runs` | CreateRun, UpdateRun, DeleteRun, LogMetric, LogBatch | `_get_permission_from_run_id()` → experiment | `_get_permission_from_run_id()` → run | +| `trace` | `trace_info` | StartTrace, EndTrace, GetTrace, SearchTraces, SetTraceTag | `_get_permission_from_trace()` → experiment | `_get_permission_from_trace()` → trace | +| `assessment` | `assessments` | CreateAssessment, UpdateAssessment, DeleteAssessment | `validate_can_update_trace_by_trace_id` → experiment | `_get_permission_from_assessment()` → assessment | +| `logged_model` | `logged_models` | CreateLoggedModel, GetLoggedModel, DeleteLoggedModel | `_get_permission_from_model_id()` → experiment | `_get_permission_from_model_id()` → logged_model | +| `prompt_optimization_job` | `jobs` | CreatePromptOptimizationJob, GetPromptOptimizationJob | `_get_permission_from_prompt_optimization_job_id()` → experiment | `_get_permission_from_prompt_optimization_job_id()` → prompt_optimization_job | +| `review_queue` | `review_queues` | CreateReviewQueue, UpdateReviewQueue, AddItemsToReviewQueue | `_get_permission_from_review_queue_id()` → experiment | `_get_permission_from_review_queue_id()` → review_queue | +| `label_schema` | `label_schemas` | CreateLabelSchema, UpdateLabelSchema, DeleteLabelSchema | `_get_permission_from_label_schema_id()` → experiment | `_get_permission_from_label_schema_id()` → label_schema | + +**Inherits from `registered_model`:** + +| Resource | DB table | API examples | Current resolution | Proposed resolution (fine-grained) | +|----------|----------|--------------|-------------------|-----------------------------------| +| `model_version` | `model_versions` | CreateModelVersion, UpdateModelVersion, DeleteModelVersion | `_get_permission_from_model_version()` → registered_model | `_get_permission_from_model_version()` → model_version | + +**Inherits from `scorer`:** + +| Resource | DB table | API examples | Current resolution | Proposed resolution (fine-grained) | +|----------|----------|--------------|-------------------|-----------------------------------| +| `scorer_version` | `scorer_versions` | ListScorerVersions | `validate_can_read_scorer` → scorer | `_get_permission_from_scorer_version()` → scorer_version | + +**Inherits from `gateway_endpoint`:** + +| Resource | DB table | API examples | Current resolution | Proposed resolution (fine-grained) | +|----------|----------|--------------|-------------------|-----------------------------------| +| `gateway_endpoint_binding` | `gateway_endpoint_bindings` | CreateGatewayEndpointBinding, DeleteGatewayEndpointBinding | `validate_can_update_gateway_endpoint` → gateway_endpoint | `_get_permission_from_gateway_endpoint_binding()` → gateway_endpoint_binding | + +### Configuration + +```ini +[mlflow] +# "simplified" = today's resource types only (experiment, registered_model, prompt, scorer, workspace) +# "fine_grained" = adds run, trace, assessment, logged_model, model_version, +# prompt_optimization_job, review_queue, label_schema, +# scorer_version, gateway_endpoint_binding +permission_granularity = simplified # default +``` + +**Resource type sets:** + +```python +# Current VALID_RESOURCE_TYPES (MLflow 3.15.1) +SIMPLIFIED_RESOURCE_TYPES = frozenset({ + "experiment", + "registered_model", + "prompt", + "scorer", + "gateway_secret", + "gateway_endpoint", + "gateway_model_definition", + "mcp_server", + "workspace", +}) + +FINE_GRAINED_RESOURCE_TYPES = SIMPLIFIED_RESOURCE_TYPES | frozenset({ + "run", + "trace", + "assessment", + "logged_model", + "model_version", + "prompt_optimization_job", + "review_queue", + "label_schema", + "scorer_version", + "gateway_endpoint_binding", +}) +``` + +**Methodology:** This list was derived by identifying every `_get_permission_from_*` +function in `mlflow/server/auth/__init__.py` (3.15.1) that resolves to a different +`resource_type` than the resource being accessed. Each entry has its own DB table, +its own API surface, and currently inherits permissions from a parent. + +When `simplified` is active, the `add_role_permission` API accepts sub-resource +types but emits a warning — the grants are stored but have no effect until +fine-grained mode is enabled: + +```python +def _validate_resource_type(resource_type: str): + if resource_type not in FINE_GRAINED_RESOURCE_TYPES: + raise MlflowException( + f"Invalid resource type '{resource_type}'.", + INVALID_PARAMETER_VALUE, + ) + if permission_granularity == "simplified" and resource_type not in SIMPLIFIED_RESOURCE_TYPES: + logger.warning( + f"Resource type '{resource_type}' is only enforced in fine_grained mode. " + f"Grant will be stored but has no effect until permission_granularity is " + f"changed to 'fine_grained'." + ) +``` + +### Workspace resolution for sub-resources + +Sub-resources don't have their own workspace column — they are identified by +UUID (globally unique) rather than by name, so they don't need workspace-scoped +uniqueness constraints. When workspaces were introduced (migration +`1b5f0d9ad7c1`), the `workspace` column was added only to named resources where +uniqueness is workspace-scoped (`experiments`, `registered_models`, +`model_versions`, `secrets`, `endpoints`, `model_definitions`, `jobs`). +Sub-resources like `runs`, `trace_info`, `assessments`, and `logged_models` were +left without a workspace column because their identity is a UUID, not a name. + +Each validator already resolves the workspace today by loading the parent +resource (e.g., trace → experiment → workspace). This resolution is unchanged +in fine-grained mode — the only difference is what `resource_type` is passed to +the permission check after workspace is resolved. + +Note: `assessment` maps to `experiment` directly (not to `trace`). This enables +"write assessments without trace-write access" — evaluators can annotate existing +traces without being able to `SetTraceTag` or `DeleteTraceTag`. + +### Resolution logic + +A helper method resolves the effective resource type and ID based on the +configured mode: + +```python +def resolve_resource(resource_type: str, resource_id: str, parent_type: str, parent_id: str): + """Return the (resource_type, resource_id) to check permissions against. + + In simplified mode: returns the parent (today's behavior). + In fine-grained mode: returns the sub-resource itself. + """ + if permission_granularity == "simplified": + return parent_type, parent_id + else: + return resource_type, resource_id +``` + +Each validator calls this helper with both its own type and its parent's: + +```python +def _get_permission_from_trace_request_id() -> Permission: + trace = get_trace(request_id) + experiment = get_experiment(trace.experiment_id) + workspace = experiment.workspace + + check_type, check_id = resolve_resource( + resource_type="trace", + resource_id=trace.request_id, + parent_type="experiment", + parent_id=trace.experiment_id, + ) + return store.get_role_permission_for_resource(user_id, check_type, check_id, workspace) +``` + +- **Simplified mode:** `resolve_resource` returns `("experiment", experiment_id)` — identical to today. +- **Fine-grained mode:** `resolve_resource` returns `("trace", trace_id)` — checks for a direct trace grant. + +### Workspace MANAGE interaction + +Workspace MANAGE (`(workspace, *, MANAGE)`) continues to fold into every +resource-type check, including sub-resources in both modes. A workspace admin has +unrestricted access to all resources in the workspace. This is unchanged. + +### Search filtering + +Search filtering follows the same pattern as today's `filter_search_experiments`: + +```python +def filter_search_traces(user, traces, workspace): + return [t for t in traces if resolve_permission(user_id, "trace", t.id, workspace).can_read] +``` + +In simplified mode, this resolves to experiment-level permission (existing +behavior). In fine-grained mode, it checks for trace-level grants directly. + +### Extensibility + +Any future resource type indexed in the DB can be added with: +1. One constant in `FINE_GRAINED_RESOURCE_TYPES` +2. A validator that resolves workspace (via parent lookup) and passes its own resource type + +No other changes to the resolution engine, API surface, or DB schema. + +### Performance + +**Simplified mode:** Identical to today. No new code paths execute. + +**Fine-grained mode:** One direct lookup per resource type in the already-loaded +role permissions (in-memory). No additional DB queries — role permissions for the +workspace are loaded once regardless of how many resource types are checked. + +## Drawbacks + +- **Fine-grained mode requires more grants per role.** Admins must explicitly + grant on every sub-resource type a role needs. A role that previously needed one + experiment grant now needs up to 5+. Mitigated: this is opt-in — only + deployments that need fine-grained control enable it. + +- **Admins must choose between two permission models.** The deployment has either + simplified or fine-grained mode — admins need to understand which is active and + how grants behave in that mode. Mitigated: the default is simplified (today's + behavior), so admins who don't need fine-grained control never encounter the + choice. + +- **Migrating between modes requires temporarily over-scoping.** When switching + from simplified to fine-grained, admins must pre-create sub-resource grants + before flipping the flag. If an admin switches the flag without pre-creating + grants, users lose access until grants are added. Mitigated: the recommended + transition path (create grants first, then switch) avoids access disruption. + +# Alternatives + +### A. Inheritance-based permission model + +Add an `inherit` flag to each grant, allowing some grants to flow to children +and others not. + +```python +# inherit=True (default, backward compatible): EDIT flows to runs, traces, assessments, logged_models +client.add_role_permission(role.id, "experiment", "*", "EDIT", inherit=True) + +# inherit=False: EDIT applies only to experiment metadata, children get nothing +client.add_role_permission(role.id, "experiment", "*", "EDIT", inherit=False) +# Must separately grant on children: +client.add_role_permission(role.id, "trace", "*", "READ") +``` + +**Rejected because:** creates conflicts (what if one grant says inherit=True and +another says inherit=False on the same resource type?). Complicates the mental +model — admins must understand per-grant inheritance semantics. Note: `inherit=True` +would be the default to ensure backward compatibility, but the per-grant flag +still introduces ambiguity when multiple grants on the same resource type +disagree. + +### B. Capability-based permission levels + +Split EDIT into `EDIT_RUNS`, `EDIT_TRACES`, etc. + +**Rejected because:** breaks the scalar hierarchy that `max_permission()` depends +on. Contradicts the simplification direction of RFC 0005. + +### C. Action-based permissions + +Grant specific actions (`create_trace`, `delete_run`). + +**Rejected because:** contradicts RFC 0005's design choice of permission levels +over per-action grants. Creates N² explosion. Unmanageable admin UX. + +# Adoption strategy + +**This is not a breaking change.** The default mode (`simplified`) is identical +to today's behavior. + +**Adoption path for operators who want fine-grained control:** + +1. Upgrade to the version shipping this change (no behavior change) +2. Audit existing roles and determine which sub-resources each role should access +3. Add explicit sub-resource grants to each role (can be done while still in + simplified mode — the grants are stored but unused until mode switches) +4. Switch to fine-grained mode: + ```ini + permission_granularity = fine_grained + ``` +5. Verify access is as expected +6. Adjust parent grants as needed (e.g., downgrade `experiment` from EDIT to READ + now that sub-resources are independently granted) + +**Reverting to simplified mode:** + +1. Ensure parent-level grants (experiment, registered_model) cover the desired + access — in simplified mode, sub-resource grants are ignored and all access + derives from the parent +2. Switch back to simplified: + ```ini + permission_granularity = simplified + ``` +3. Sub-resource grants remain stored but have no effect — they can be cleaned up + or left in place for a future switch back + +# Open questions + +1. **How does this interact with a pluggable auth backend?** An + `OPERATION_REGISTRY` approach would emit + `AuthorizationRequirement(resource_type="trace", ...)` for trace operations. + The `DefaultDbAuthorizationBackend` would implement `resolve_resource` with + the mode-dependent logic. The designs are complementary. + +2. **Should the admin UI show which mode is active and the effective permissions + per resource type?** Proposed: yes. In fine-grained mode, the UI should + clearly show which resource types a role has grants on and which are ungranted. + +3. **Should new resource types added in future releases default to simplified or + fine-grained behavior?** Proposed: new sub-resource types follow the same + pattern — they are added to `FINE_GRAINED_RESOURCE_TYPES` and resolve to their + parent in simplified mode. From e0811eed385dd8119b6f3873b7f8d3824fea312e Mon Sep 17 00:00:00 2001 From: Rahul Kharse Date: Mon, 31 Aug 2026 23:55:03 +0000 Subject: [PATCH 2/2] docs: Revise sub-resource permissions RFC per review feedback Rework to the max/escalation model (top-level grants inherit; optional child grants raise), address reviewer comments on the resource set, resolution mechanics, scoping boundaries, and behavioral compatibility. --- .../0000-sub-resource-permissions.md | 970 +++++++++++------- 1 file changed, 585 insertions(+), 385 deletions(-) diff --git a/rfcs/0000-sub-resource-permissions/0000-sub-resource-permissions.md b/rfcs/0000-sub-resource-permissions/0000-sub-resource-permissions.md index 0d20fd7..745d65c 100644 --- a/rfcs/0000-sub-resource-permissions/0000-sub-resource-permissions.md +++ b/rfcs/0000-sub-resource-permissions/0000-sub-resource-permissions.md @@ -4,449 +4,649 @@ rfc_pr: # Summary -MLflow 3.13's RBAC model recognizes a fixed set of resource types (`experiment`, -`registered_model`, `prompt`, `scorer`, `workspace`). Resources that are children -of a permissionable parent (e.g., runs under experiments, model versions under -registered models) are not independently permissionable — they always resolve to -their parent's permission level. +MLflow's RBAC model recognizes a fixed set of top-level resource types +(`experiment`, `registered_model`, `prompt`, `scorer`, `mcp_server`, +`gateway_*`, `workspace`). Resources that are children of a permissionable +parent (e.g., runs and assessments under experiments, versions under registered +models) are **not** independently permissionable — every operation on them +resolves to the parent's permission level. + +This RFC makes a curated set of child resource types **independently grantable**, +using a single, uniform resolution rule: a child's effective permission is the +**most-permissive** of its inherited parent permission and any direct grant on +the child. -This RFC proposes making **all DB-indexed resource types** independently -permissionable, gated behind a configuration flag: +``` +effective(child) = max(inherited_parent_permission, direct_child_grant) +``` -- **`permission_granularity = simplified`** (default): today's behavior. Only - existing resource types are recognized. Sub-resources resolve to their parent. -- **`permission_granularity = fine_grained`** (opt-in): all resource types are - recognized. Admins grant on each resource type independently. +- **No grant on the child** → the child inherits its parent's permission exactly + as today. This is fully backward compatible: existing deployments behave + identically with no configuration change. +- **A grant on the child** → the child is *raised* to that level (escalation). -The design is fully backward compatible: the default mode is identical to today. -Operators opt into fine-grained mode when they need per-resource-type control. +This is the same monotonic, allow-only fold MLflow already uses when combining +workspace-wide and resource-specific grants. # Basic example -### Simplified mode (default — today's behavior) +Granting a child type raises it above its parent; not granting it leaves it +inheriting from the parent. ```python -# EDIT on experiment = EDIT on everything (runs, traces, assessments, logged models) +# Baseline (unchanged from today): EDIT on experiment flows to all children. client.add_role_permission(ds_role.id, "experiment", "*", "EDIT") -# Sub-resources are not independently permissionable. Identical to today. - -# Sub-resource grants are accepted with a warning but have no effect: -client.add_role_permission(evaluator_role.id, "trace", "*", "READ") -# ⚠️ Warning: "Resource type 'trace' is only enforced in fine_grained mode. -# Grant will be stored but has no effect until permission_granularity -# is changed to 'fine_grained'." -# The grant is stored — ready for when the operator switches modes. -``` - -_A warning rather than an error allows admins to pre-create sub-resource grants -before switching modes — see [Adoption strategy](#adoption-strategy) for the -safe transition path._ - -### Fine-grained mode (opt-in) +# → runs, traces, assessments all resolve to EDIT via inheritance. -```ini -[mlflow] -permission_granularity = fine_grained -``` - -```python -# Evaluator: can view experiments and write assessments, nothing else +# Validation-boundary evaluator: read everything, additionally WRITE assessments. client.add_role_permission(evaluator_role.id, "experiment", "*", "READ") client.add_role_permission(evaluator_role.id, "assessment", "*", "EDIT") +# → experiment/run/trace stay READ (inherited); assessment raised to EDIT. -# Data scientist: full access to everything -client.add_role_permission(ds_role.id, "experiment", "*", "EDIT") -client.add_role_permission(ds_role.id, "run", "*", "EDIT") -client.add_role_permission(ds_role.id, "trace", "*", "EDIT") -client.add_role_permission(ds_role.id, "assessment", "*", "EDIT") -client.add_role_permission(ds_role.id, "logged_model", "*", "EDIT") - -# Pipeline: can log runs and traces, but only read assessments -client.add_role_permission(pipeline_role.id, "experiment", "*", "EDIT") +# Run-logging service account (#11496): read the experiment, log runs — without +# gaining experiment-management (rename/delete) rights. +client.add_role_permission(pipeline_role.id, "experiment", "*", "READ") client.add_role_permission(pipeline_role.id, "run", "*", "EDIT") -client.add_role_permission(pipeline_role.id, "trace", "*", "EDIT") -client.add_role_permission(pipeline_role.id, "assessment", "*", "READ") -client.add_role_permission(pipeline_role.id, "logged_model", "*", "EDIT") +# → experiment stays READ (cannot rename/delete); runs raised to EDIT. ``` -Effective permissions in fine-grained mode: - -| User | experiment | run | trace | assessment | logged_model | -|------|-----------|-----|-------|------------|--------------| -| evaluator | READ | — | — | **EDIT** | — | -| data-scientist | EDIT | EDIT | EDIT | EDIT | EDIT | -| pipeline | EDIT | EDIT | EDIT | **READ** | EDIT | +Effective permissions: -_(— = NO_PERMISSIONS, no grant configured)_ +| User | experiment | run | trace | assessment | +|------|-----------|-----|-------|------------| +| data-scientist | EDIT | EDIT (inherited) | EDIT (inherited) | EDIT (inherited) | +| evaluator | READ | READ (inherited) | READ (inherited) | **EDIT** (raised) | +| pipeline | READ | **EDIT** (raised) | READ (inherited) | READ (inherited) | ## Motivation RFC 0005 established workspace-scoped roles with permission grants on resource types. Its `VALID_RESOURCE_TYPES` set covers only top-level resources — -sub-experiment resources (runs, traces, assessments, logged models) are not -recognized, and their validators resolve to the parent experiment's permission -level. This was a reasonable simplification for v1, but as MLflow's resource -model grows — particularly with traces and assessments for LLM evaluation — the -limitation creates real access control gaps. - -### Use cases that cannot be expressed today - -**1. Run access without experiment management (#11496)** - -An organization uses MLflow Model Registry. Users need to create runs (to log -models for registration) but should NOT be able to create or manage experiments -— only the admin should control experiment lifecycle. Today, run permissions -depend entirely on experiment permissions: restricting experiment access to -NO_PERMISSIONS also blocks run creation, and granting experiment EDIT to enable -run creation also grants experiment management (create new experiments, rename, -delete). - -What they want: `NO_PERMISSIONS` on experiments + `EDIT` on runs. - -Today: impossible. The only workaround is granting experiment EDIT, which also -lets users create new experiments and get automatic MANAGE on them — the opposite -of what the admin intended. - -**2. Assessment-only write access (validation boundary)** - -An enterprise has a validation workflow where production pipelines log traces -and human evaluators annotate them with assessments. Evaluators need to view -everything and write assessments, but should NOT be able to create runs, modify -traces, or alter experiments. - -What they want: `READ` on experiments/runs/traces + `EDIT` on assessments. - -Today: `CreateAssessment` and `CreateRun` both require experiment EDIT — granting -one inevitably grants the other. There is no way to separate assessment-write -from run-write or trace-write. This is because `CreateAssessment` uses -`validate_can_update_trace_by_trace_id`, which resolves to the parent -experiment's permission — conflating assessment writes with trace updates. - -**3. Model version lifecycle separation** - -A platform team owns the model registry namespace (which models exist, their -descriptions, aliases) while data scientists push new versions. Today, granting -experiment EDIT to enable `CreateModelVersion` also grants the ability to rename -or delete the registered model itself. - -What they want: `MANAGE` on `registered_model` for platform team + -`EDIT` on `model_version` for data scientists. - -Today: `model_version` inherits from `registered_model` — you cannot separate -"push a new version" from "manage the model entry." +sub-experiment resources (runs, traces, assessments, logged models) and registry +versions are not recognized, and their validators resolve to the parent's +permission level. Below are some use cases where this level of granularity is insufficient. + +### Use cases +**1. Run logging without experiment management (#11496)** + +Users need to log **runs** (e.g. to log models for registration) but must not +create, rename, delete, or otherwise **manage experiments** — only admins manage +experiments. Today run permissions inherit from the experiment, so the only way to +grant run-logging is to grant experiment `EDIT`, which also permits renaming and +tagging the experiment. + +- **Example user:** a data scientist or pipeline role that logs runs into + admin-created experiments. +- **Policy:** `(experiment, *, READ)` + `(run, *, EDIT)` +- **Result:** can discover/read experiments and create/log runs; cannot rename, + delete, or manage experiments. + +The experiment grant is `READ`, not lower: a user must be able to read an +experiment to discover and target it (`GetExperiment` / `SearchExperiments` require +experiment `can_read`), and must be a workspace member (workspace `USE`). Run +`EDIT` then supplies the create/log capability on runs. Experiment *creation* is a +separate workspace-level gate (USE/MANAGE), not experiment EDIT, and is unchanged +by this RFC. + +**2. Assessment write access (validation boundary)** + +A production app runs training/evaluation via a service role; end users only view +results and submit **feedback/assessments** through the UI. They must not modify or +delete runs, traces, models, or aliases. Today `CreateAssessment` requires +experiment `can_update` (EDIT), which also permits modifying runs and traces. + +- **Example user:** a human evaluator who annotates results with assessments. +- **Policy:** `(experiment, *, READ)` + `(assessment, *, EDIT)` +- **Result:** can view everything and create/update assessments; cannot modify + runs, traces, or experiments. + +**3. Model-version logging without registry management** + +Data scientists push new model versions; the platform team owns the registry +namespace (model names, descriptions, aliases). Today `CreateModelVersion` +requires the parent `registered_model`'s update capability, so enabling version +creation also permits managing the model entry. + +- **Example user:** a data scientist who registers new versions of existing models. +- **Policy:** `(registered_model, *, READ)` + `(model_version, *, EDIT)` +- **Result:** can create/update model versions; cannot rename, delete, or + re-alias the registered model. + +Making `model_version` a first-class grantable type also sets up a future +capability this RFC does **not** include: condition-based access control on +versions — e.g. protecting versions that carry a given alias (`champion`, +`production`) or tag. That is deferred to the condition-based access-control RFC; +this RFC grants on the `model_version` type at wildcard grain only. ### Out of scope -- **Fine-grained auth for non-DB-indexed resources** (e.g., individual spans - within a trace) — these are stored in blob/artifact storage, not queryable as - independent DB entities. -- **Fail-closed enforcement for unregistered resource types** — today, routes - without a registered validator are fail-open (any authenticated user can - access). This proposal preserves that behavior. -- **Parent-scoped sub-resource grants** — in fine-grained mode, a grant like - `(trace, *, EDIT)` applies to all traces in the workspace, not to traces within - a specific parent resource. There is no way to express "EDIT on traces, but only - within experiment X." This would require a richer `resource_pattern` syntax or - condition-based matching, which is not addressed here. +- **Condition-based (attribute/tag) access control.** This RFC grants on resource + *types* at wildcard grain; scoping a grant by attribute — e.g. model versions + with a given alias (`champion`, `production`) or tag, traces for a given agent — + is deferred to the condition-based access-control RFC. Making these types + grantable here is what those future conditions attach to. +- **Request-level (pre-request) condition push-down for the new child types.** + Efficient conditional filtering — injecting the caller's authorized predicate + into the query before it hits the store (see mlflow/mlflow#24964) — is not added + for `run`/`trace`/`assessment`/`model_version` here. This RFC stays at wildcard + grain precisely so search filtering needs no push-down (a single + wildcard-can-read check per type); any future per-attribute or per-id conditional + search for these types depends on that push-down mechanism and is deferred with + the condition-based access-control work. +- **Per-individual-resource grants for high-cardinality children.** Grants on + `run`/`trace`/`assessment` are supported at **wildcard** grain + (`(trace, *, LEVEL)`) only; per-ID grants (`(trace, , LEVEL)`) are out + of scope. The blocker is **search**: with per-ID grants, a `SearchTraces` result + must be filtered to the caller's authorized ids, which today means post-response + filtering — scan a page, drop unauthorized rows, re-fetch to refill — pathological + for high-cardinality children. Efficient filtering needs the authorized predicate + pushed into the store query before it runs; that pre-request push-down is the + subject of [mlflow/mlflow#24964](https://github.com/mlflow/mlflow/issues/24964) + (no merged PR yet). Wildcard grain sidesteps this entirely (one boolean per type), + so per-ID grants are deferred until that mechanism lands. +- **Grants scoped to a collection defined by an attribute value** (e.g. a chatbot + **session** — the traces sharing a session id). Session is **not a first-class + field**: it is a trace **tag** — a key/value row in the `trace_tags` table with + key `mlflow.trace.session` — not a column on `trace_info`, and there is no session + table or API. So a session is not a DB resource this RFC's resource-type grants + can attach to; it is an attribute predicate over traces. Such tag/attribute- + predicate grants belong to the condition-based access-control RFC (which must + support *additive* conditions for the escalation direction — raising one session + above a read-only parent — to work). Same reasoning for any attribute-defined + collection (per-agent, per-experiment). +- **Scoping child access to a parent boundary.** A child grant is workspace-wide + (`(assessment, *)` matches every assessment in the workspace, across all + experiments) or — in future — per-instance (per-id). There is no per-parent grain, + so "assessment EDIT on the assessments *of one experiment*" (or of one agent) + cannot be expressed as a single grant. This is deferred to the condition-based + access-control RFC; making the child type grantable here is what such a + parent-boundary (or attribute) condition attaches to. +- **Fine-grained auth for non-DB-indexed resources** (e.g. individual spans + within a trace) — stored in blob/artifact storage, not independently queryable. +- **Tooling to ensure future contributions keep the auth route map complete.** + This RFC adds and re-points entries in the validator map, but does not add a + build/test-time check that every new or renamed route is wired to a validator. + Guaranteeing coverage as the codebase evolves is a separate concern (and the + fail-open/fail-closed default for an unmapped route is being handled upstream in + [mlflow/mlflow#25308](https://github.com/mlflow/mlflow/pull/25308)). ## Detailed design +The design has two aspects, each a top-level section below: + +1. **[Granting permissions](#granting-permissions)** — making the new resource types + grantable and defining what a *valid* grant on them is, so an operator can only + express permissions the model supports (which types and their metadata, and which + `(resource_type, pattern, level)` grants are accepted or rejected). +2. **[Enforcing permissions](#enforcing-permissions)** — mapping an incoming request + to a permission validator and running it to allow or deny (the entry point and + validator map, the permission fold (parent resolution and route re-pointing), + and search filtering). + +### Granting permissions + +#### Grantable resource types +**Proposed methodology.** A child type is made independently grantable when we have +identified customer use cases in which the child needs escalated permissions +relative to its parent and to the other children under that parent. The max model +fits this precisely: it is monotonic and allow-only, so a child grant *raises* that +child above its inherited level. Children with no such use case keep inheriting from +their parent, which keeps the grant surface small. This is the inclusion criterion +for this RFC and future additions. + +Two scoping principles bound the candidate set. First, this RFC targets only routes +that **already have resource-scoped authorization** — routes gated by authentication +alone (e.g. the generic job endpoints) or with no gate are outside the model, since +there is no parent permission to inherit from or escalate against. Second, as a +forward principle, new permissionable resources should be **DB-hosted, independently +queryable entities**: a grant matches against a stored `(resource_type, pattern)` and +search filtering requires the store to resolve the resource, so a type that is not +DB-indexed cannot participate in this model. + +The set was derived by enumerating **both** ways `_before_request` dispatches a +validator, which exist because MLflow's routes come in two shapes: + +1. **Exact-match map** (`BEFORE_REQUEST_VALIDATORS`) — a dict keyed by exact + `(path, method)`, generated automatically from the proto service definitions. + This covers routes with a **fixed path** (`/api/2.0/mlflow/runs/create`), where + an O(1) dict lookup suffices. +2. **Regex/prefix dispatch** — for routes the exact map cannot key: those with a + **path parameter** (e.g. `/mlflow/traces//tags`, logged-model and webhook + routes), matched by regex in separate maps, and endpoints **not registered as + proto routes at all** (the MCP server via `_get_mcp_server_validator`, + artifact-proxy paths, `/v1/traces`), matched by `path.startswith(...)`. + +For each route, across both mechanisms, we identified whether its permission +resolves to a **different** resource type than the resource acted on (i.e. it +inherits from a parent). Scanning both — not just the proto map — is what surfaces +`mcp_server`/`mcp_server_version`, which live only in the regex/prefix path and a +proto-map-only scan misses. + +##### The model with sub-resources added + +The **top-level** grantable types are unchanged by this RFC — they are what is +grantable today (`VALID_RESOURCE_TYPES`, plus `prompt`, which is grant-namespaced +separately). For completeness, the full set and whether each has a sub-resource +this RFC touches: + +_Verified against MLflow 3.15.1 source._ + +| Top-level type | Sub-resource(s) in scope? | +|----------------|---------------------------| +| `workspace` | — (the root scope; parents everything) | +| `experiment` | `run`, `trace`, `assessment`, `logged_model`, `review_queue` (and `label_schema`, `prompt_optimization_job`, excluded) | +| `registered_model` | `model_version` | +| `prompt` | `prompt_version` | +| `scorer` | `scorer_version` (excluded) | +| `mcp_server` | `mcp_server_version` (excluded) | +| `gateway_endpoint` | `gateway_endpoint_binding` (excluded) | +| `gateway_model_definition` | — | +| `gateway_secret` | — | + +The table below then details every **sub-resource** and whether it becomes +independently grantable. The two tables together are the whole grantable model once +this RFC lands; sub-resources marked **Yes** are what it adds. All grants are +per-workspace. + +| Parent | Sub-resource | Grantable? | [Grain](#search-filtering) | Escalation use case | Addable later? | +|--------|--------------|------------|-------|---------------------|----------------| +| `experiment` | `run` | **Yes** | wildcard | log runs without experiment management | in scope | +| `experiment` | `trace` | **Yes** | wildcard | trace-level escalation alongside runs/assessments | in scope | +| `experiment` | `assessment` (via trace) | **Yes** | wildcard | write feedback/assessments while everything else stays READ | in scope | +| `experiment` | `logged_model` | **Yes** | wildcard | log models without experiment management (parallels runs) | in scope | +| `experiment` | `review_queue` | **Yes** | wildcard | operate a review/labeling queue (add/remove items) without experiment EDIT | in scope | +| `experiment` | `label_schema` | No | — | none today — authored by whoever manages the experiment | Yes | +| `experiment` | `prompt_optimization_job` | No | — | dormant entity — exists in the data model but has no active SDK/UI surface (candidate for removal), so no persona needs it | Yes | +| `registered_model` | `model_version` | **Yes** | wildcard | push versions without registry management (use case 3) | in scope | +| `prompt` | `prompt_version` | **Yes** | wildcard | push prompt versions without managing the prompt entry (mirrors `model_version`) | in scope | +| `scorer` | `scorer_version` | No | — | none today — versions are immutable/append-only (no alias, tag, or mutable field to protect) | Yes | +| `mcp_server` | `mcp_server_version` | No | — | none today — inherits; parallels `model_version` if a use case arises | Yes | +| `gateway_endpoint` | `gateway_endpoint_binding` | No | — | gated on the `gateway_endpoint` alone today (the endpoint controls create/delete/list of its bindings); the binding is a two-sided link (endpoint + consumer), and requiring both sides' permissions would change existing behavior — out of scope | No | + +Notes: +- **Grain is wildcard-only in this RFC.** Every grantable child is at `*` grain; + id-level grain (`(trace, , …)`) will be added once request-level search-filter + push-down is in place (see [Search filtering](#search-filtering)). + +#### Grant validation +When an operator **adds or updates a permission grant** (`grant_user_permission` / +the role-permission APIs), the input must be validated so only permissions the +model supports can be expressed. + +Two of the three checks are **already enforced today** and need no change — the new +types simply flow through them: + +- **The auth model must explicitly declare the resource type:** `_validate_resource_type` + rejects any type not in `VALID_RESOURCE_TYPES` (which now includes the new children). +- **The permission level must be valid:** `get_permission` rejects anything that is + not `READ`/`USE`/`EDIT`/`MANAGE`. + +The **one new rule** is pattern validation, which does not exist today — +`resource_pattern` is currently passed to the store unchecked: + +- **The pattern must be a kind the type declares:** `resource_pattern` must match + one of `TYPE[resource_type].patterns` — so a concrete-id child grant + (`(run, , …)`) is rejected while `(run, *, …)` and a parent's + `(experiment, "*" | id, …)` are accepted, enforcing the wildcard-only grain at the + source rather than in the fold. + +Rejections surface as a validation error on the grant API naming the offending +field, so the operator gets an explicit reason rather than a silently-ineffective +grant. The valid grants are therefore `(child_type, "*", LEVEL)` for any grantable +child, or the existing `(parent_type, "*" | id, LEVEL)` for a parent — anything else +is refused up front. + +### Enforcing permissions + +An incoming request is mapped to a validator, which resolves the target's effective +permission and allows or denies. The subsections below cover the entry point and +validator map, the permission fold that answers the check (including how a child +resolves its parent and how its routes re-point onto the child), and search-result +filtering. + +The overall flow (dashed = added by this RFC): + +```mermaid +flowchart TD + REQ["Request on a child
(e.g. LogMetric on a run)"] --> V["_before_request → validator
resolves child's workspace via parent
(run → experiment → workspace)"] + V --> FOLD["get_role_permission_for_resource:
one pass over the user's role grants,
max_permission over all matching clauses"] + + FOLD --> WS["clause: workspace-wide MANAGE
(workspace, *, MANAGE)"] + FOLD --> CHILD["clause: direct grant on the child type
(run, *) — wildcard-only"] + FOLD --> PARENT["clause: grant on the parent type
(experiment, * or id) — inheritance"] + + WS --> MAX(["max of all matching clauses"]) + CHILD -.-> MAX + PARENT -.-> MAX + + MAX --> DECIDE{"effective permission
satisfies route capability?"} + DECIDE -->|yes| ALLOW["allow"] + DECIDE -->|no| DENY["403"] + + class CHILD,PARENT newpart + classDef newpart stroke-dasharray:5 5,stroke-width:2px +``` -### New resource types - -The following resources have their own APIs but currently inherit permissions from -a parent resource type. In fine-grained mode, each becomes independently -permissionable. - -_Verified against MLflow 3.15.1 source (`mlflow/server/auth/__init__.py`)._ - -**Inherits from `experiment`:** - -| Resource | DB table | API examples | Current resolution | Proposed resolution (fine-grained) | -|----------|----------|--------------|-------------------|-----------------------------------| -| `run` | `runs` | CreateRun, UpdateRun, DeleteRun, LogMetric, LogBatch | `_get_permission_from_run_id()` → experiment | `_get_permission_from_run_id()` → run | -| `trace` | `trace_info` | StartTrace, EndTrace, GetTrace, SearchTraces, SetTraceTag | `_get_permission_from_trace()` → experiment | `_get_permission_from_trace()` → trace | -| `assessment` | `assessments` | CreateAssessment, UpdateAssessment, DeleteAssessment | `validate_can_update_trace_by_trace_id` → experiment | `_get_permission_from_assessment()` → assessment | -| `logged_model` | `logged_models` | CreateLoggedModel, GetLoggedModel, DeleteLoggedModel | `_get_permission_from_model_id()` → experiment | `_get_permission_from_model_id()` → logged_model | -| `prompt_optimization_job` | `jobs` | CreatePromptOptimizationJob, GetPromptOptimizationJob | `_get_permission_from_prompt_optimization_job_id()` → experiment | `_get_permission_from_prompt_optimization_job_id()` → prompt_optimization_job | -| `review_queue` | `review_queues` | CreateReviewQueue, UpdateReviewQueue, AddItemsToReviewQueue | `_get_permission_from_review_queue_id()` → experiment | `_get_permission_from_review_queue_id()` → review_queue | -| `label_schema` | `label_schemas` | CreateLabelSchema, UpdateLabelSchema, DeleteLabelSchema | `_get_permission_from_label_schema_id()` → experiment | `_get_permission_from_label_schema_id()` → label_schema | - -**Inherits from `registered_model`:** - -| Resource | DB table | API examples | Current resolution | Proposed resolution (fine-grained) | -|----------|----------|--------------|-------------------|-----------------------------------| -| `model_version` | `model_versions` | CreateModelVersion, UpdateModelVersion, DeleteModelVersion | `_get_permission_from_model_version()` → registered_model | `_get_permission_from_model_version()` → model_version | - -**Inherits from `scorer`:** - -| Resource | DB table | API examples | Current resolution | Proposed resolution (fine-grained) | -|----------|----------|--------------|-------------------|-----------------------------------| -| `scorer_version` | `scorer_versions` | ListScorerVersions | `validate_can_read_scorer` → scorer | `_get_permission_from_scorer_version()` → scorer_version | +The subsections below walk each box: the entry point + validator map, the fold and +its clauses, and how the child's parent (and workspace) are resolved. -**Inherits from `gateway_endpoint`:** +#### Entry point and validator interface -| Resource | DB table | API examples | Current resolution | Proposed resolution (fine-grained) | -|----------|----------|--------------|-------------------|-----------------------------------| -| `gateway_endpoint_binding` | `gateway_endpoint_bindings` | CreateGatewayEndpointBinding, DeleteGatewayEndpointBinding | `validate_can_update_gateway_endpoint` → gateway_endpoint | `_get_permission_from_gateway_endpoint_binding()` → gateway_endpoint_binding | +**Today** every gated request passes through the Flask `_before_request` hook, which +looks up a validator in a route → callable map and denies if it returns false. **We +reuse that entry point and map.** **For sub-resources we only add/re-point entries** +in the map (a route now names the child validator) — no new dispatch mechanism. -### Configuration +The full call chain for a gated request: -```ini -[mlflow] -# "simplified" = today's resource types only (experiment, registered_model, prompt, scorer, workspace) -# "fine_grained" = adds run, trace, assessment, logged_model, model_version, -# prompt_optimization_job, review_queue, label_schema, -# scorer_version, gateway_endpoint_binding -permission_granularity = simplified # default ``` - -**Resource type sets:** - -```python -# Current VALID_RESOURCE_TYPES (MLflow 3.15.1) -SIMPLIFIED_RESOURCE_TYPES = frozenset({ - "experiment", - "registered_model", - "prompt", - "scorer", - "gateway_secret", - "gateway_endpoint", - "gateway_model_definition", - "mcp_server", - "workspace", -}) - -FINE_GRAINED_RESOURCE_TYPES = SIMPLIFIED_RESOURCE_TYPES | frozenset({ - "run", - "trace", - "assessment", - "logged_model", - "model_version", - "prompt_optimization_job", - "review_queue", - "label_schema", - "scorer_version", - "gateway_endpoint_binding", -}) +_before_request(request) # Flask entry hook (auth, admin bypass) + └─ _find_validator(request) # proto class → validator, via BEFORE_REQUEST_HANDLERS + (+ LOGGED_MODEL_/WEBHOOK_ maps; e.g. CreateRun: validate_can_update_run) + └─ validator() # e.g. validate_can_update_run — the capability gate + └─ _get_permission_from_run_id() # the existing per-child resolver (custom logic) + └─ get_role_permission_for_resource(user, resource_type, resource_id, workspace, + child_type, child_id) # max over grants + → .can_update / .can_read / … # validator checks the resulting Permission + → allow, or make_forbidden_response() # 403 on failure ``` -**Methodology:** This list was derived by identifying every `_get_permission_from_*` -function in `mlflow/server/auth/__init__.py` (3.15.1) that resolves to a different -`resource_type` than the resource being accessed. Each entry has its own DB table, -its own API surface, and currently inherits permissions from a parent. - -When `simplified` is active, the `add_role_permission` API accepts sub-resource -types but emits a warning — the grants are stored but have no effect until -fine-grained mode is enabled: +- **`_find_validator`** maps the request's proto class to a validator (the map this + RFC re-points; `mcp_server`/artifact routes use path-based dispatch instead). +- **the validator** (`validate_can_*`) is the per-route capability gate — it calls a + resolver and checks the resulting `Permission`'s `can_*` flag. +- **the resolver** (`_get_permission_from_*`, unchanged custom logic) resolves the + parent + workspace and now also passes the child to the fold. Detailed in + [Proposed changes to the permission fold](#proposed-changes-to-the-permission-fold). +- **`get_role_permission_for_resource`** is the grant fold — max over the parent and + child clauses. Detailed in [The permission fold](#the-permission-fold). + +Whether a route with no validator is allowed or denied (fail-open vs. fail-closed) +is a platform-level concern being addressed upstream in +[mlflow/mlflow#25308](https://github.com/mlflow/mlflow/pull/25308) and is orthogonal +to this RFC — the routes this RFC touches are all explicitly mapped. + +#### The permission fold today + +**Today** the validators call `get_role_permission_for_resource(...)`, a single +most-permissive fold: it loads the user's roles in the workspace and, in one pass, +`max_permission`s in every grant matching the queried resource — the workspace-wide +MANAGE grant, plus any grant on the queried `resource_type` at pattern `*` or the +resource id. A resource type is a bare string constant in a `frozenset` (no per-type +object), and inheritance is handled one level up: a child route (e.g. +`_get_permission_from_run_id`) resolves its parent and queries with the *parent's* +type (`resource_type="experiment"`), so a run's permission is whatever the experiment +fold returns. The real method, lightly elided: ```python -def _validate_resource_type(resource_type: str): - if resource_type not in FINE_GRAINED_RESOURCE_TYPES: - raise MlflowException( - f"Invalid resource type '{resource_type}'.", - INVALID_PARAMETER_VALUE, - ) - if permission_granularity == "simplified" and resource_type not in SIMPLIFIED_RESOURCE_TYPES: - logger.warning( - f"Resource type '{resource_type}' is only enforced in fine_grained mode. " - f"Grant will be stored but has no effect until permission_granularity is " - f"changed to 'fine_grained'." - ) +# permissions.py (today) +RESOURCE_TYPE_EXPERIMENT = "experiment" +RESOURCE_TYPE_REGISTERED_MODEL = "registered_model" +RESOURCE_TYPE_PROMPT = "prompt" +RESOURCE_TYPE_SCORER = "scorer" +RESOURCE_TYPE_WORKSPACE = "workspace" +# ... gateway_*, mcp_server +VALID_RESOURCE_TYPES = frozenset({RESOURCE_TYPE_EXPERIMENT, RESOURCE_TYPE_REGISTERED_MODEL, ...}) + +# SqlAlchemyStore.get_role_permission_for_resource (today) — uses the type strings directly +def get_role_permission_for_resource(self, user_id, resource_type, resource_id, workspace): + with self.ManagedSessionMaker() as session: + roles = ... # load user's roles in `workspace` + if not roles: + return None + best = None + for role in roles: + for rp in role.permissions: + # workspace-admin fold + if rp.resource_type == RESOURCE_TYPE_WORKSPACE and rp.resource_pattern == "*": + if resource_type == RESOURCE_TYPE_WORKSPACE or rp.permission == MANAGE.name: + best = max_permission(best, rp.permission) + continue + # resource-type-specific fold — matches only the queried type, wildcard or its id + if rp.resource_type == resource_type and rp.resource_pattern in ("*", resource_id): + best = max_permission(best, rp.permission) + return get_permission(best) if best is not None else None ``` -### Workspace resolution for sub-resources - -Sub-resources don't have their own workspace column — they are identified by -UUID (globally unique) rather than by name, so they don't need workspace-scoped -uniqueness constraints. When workspaces were introduced (migration -`1b5f0d9ad7c1`), the `workspace` column was added only to named resources where -uniqueness is workspace-scoped (`experiments`, `registered_models`, -`model_versions`, `secrets`, `endpoints`, `model_definitions`, `jobs`). -Sub-resources like `runs`, `trace_info`, `assessments`, and `logged_models` were -left without a workspace column because their identity is a UUID, not a name. - -Each validator already resolves the workspace today by loading the parent -resource (e.g., trace → experiment → workspace). This resolution is unchanged -in fine-grained mode — the only difference is what `resource_type` is passed to -the permission check after workspace is resolved. - -Note: `assessment` maps to `experiment` directly (not to `trace`). This enables -"write assessments without trace-write access" — evaluators can annotate existing -traces without being able to `SetTraceTag` or `DeleteTraceTag`. - -### Resolution logic - -A helper method resolves the effective resource type and ID based on the -configured mode: - -```python -def resolve_resource(resource_type: str, resource_id: str, parent_type: str, parent_id: str): - """Return the (resource_type, resource_id) to check permissions against. - - In simplified mode: returns the parent (today's behavior). - In fine-grained mode: returns the sub-resource itself. - """ - if permission_granularity == "simplified": - return parent_type, parent_id - else: - return resource_type, resource_id -``` +#### Proposed changes to the permission fold -Each validator calls this helper with both its own type and its parent's: +**What we implement:** the resolvers already resolve a child's parent today (e.g. +`_get_permission_from_run_id` loads the run and gets its `experiment_id`), so the +parent/child pairing is known *at the resolver*. We keep every resolver's custom +logic intact and change only what it passes to the fold: the resolver still passes +the parent `(resource_type, resource_key)` it computes today, and **additionally** +passes the child `(child_type, child_id)`. The fold then folds in grants matching +either. The only per-type metadata the fold needs is the **patterns** each type may +be matched at — so `TYPE` is a flat `{name → patterns}` map (no hierarchy, no +resolution callables; the hierarchy stays implicit in the resolver, where it already +lives): ```python -def _get_permission_from_trace_request_id() -> Permission: - trace = get_trace(request_id) - experiment = get_experiment(trace.experiment_id) - workspace = experiment.workspace - - check_type, check_id = resolve_resource( - resource_type="trace", - resource_id=trace.request_id, - parent_type="experiment", - parent_id=trace.experiment_id, - ) - return store.get_role_permission_for_resource(user_id, check_type, check_id, workspace) +class PatternKind(Enum): + WILDCARD = auto() # "*" — matches any resource of the type + ID = auto() # an exact resource id + # REGEX = auto() # future: a pattern matched against the id (not in this RFC) + +WILDCARD_AND_ID = frozenset({PatternKind.WILDCARD, PatternKind.ID}) # parent grain (today's behavior) +WILDCARD_ONLY = frozenset({PatternKind.WILDCARD}) # child grain (per-id deferred) + +# name → allowed pattern kinds. Not serialized — grant rows still store the type string; +# TYPE is a static in-process table (like get_permission for permission levels). +TYPE = { + "workspace": WILDCARD_AND_ID, + "experiment": WILDCARD_AND_ID, + "registered_model": WILDCARD_AND_ID, + "prompt": WILDCARD_AND_ID, + "run": WILDCARD_ONLY, + "trace": WILDCARD_ONLY, + "assessment": WILDCARD_ONLY, + "logged_model": WILDCARD_ONLY, + "model_version": WILDCARD_ONLY, + # ... prompt_version, review_queue → WILDCARD_ONLY +} +# VALID_RESOURCE_TYPES is replaced with a check to TYPE.keys() — the set is derived, not maintained separately. + +def matches(rp, patterns, resource_id): # patterns: frozenset[PatternKind] → O(1) membership + if PatternKind.WILDCARD in patterns and rp.resource_pattern == "*": + return True + if PatternKind.ID in patterns and rp.resource_pattern == resource_id: + return True + # (PatternKind.REGEX would be handled here in future — re.fullmatch(rp.resource_pattern, resource_id)) + return False + +# SqlAlchemyStore.get_role_permission_for_resource — the caller passes the resource it +# resolved (parent, exactly as today) plus, for a sub-resource, the child (child_type, +# child_id). The fold looks up only the PATTERNS for each type from TYPE and maxes grants +# matching either. Signature gains two optional args; existing callers are unaffected. +def get_role_permission_for_resource(self, user_id, resource_type, resource_id, workspace, + child_type=None, child_id=None): # (NEW) optional child clause + with self.ManagedSessionMaker() as session: + roles = ... # (unchanged) load user's roles in `workspace` + if not roles: + return None + best = None + for role in roles: + for rp in role.permissions: + # (unchanged) workspace-admin fold + if rp.resource_type == RESOURCE_TYPE_WORKSPACE and rp.resource_pattern == "*": + if resource_type == RESOURCE_TYPE_WORKSPACE or rp.permission == MANAGE.name: + best = max_permission(best, rp.permission) + continue + # self/parent clause — the resource the caller resolved (today's behavior) + if rp.resource_type == resource_type and matches(rp, TYPE[resource_type], resource_id): + best = max_permission(best, rp.permission) + # (NEW) child clause — only when the caller supplied one + elif child_type and rp.resource_type == child_type \ + and matches(rp, TYPE[child_type], child_id): + best = max_permission(best, rp.permission) + return get_permission(best) if best is not None else None ``` -- **Simplified mode:** `resolve_resource` returns `("experiment", experiment_id)` — identical to today. -- **Fine-grained mode:** `resolve_resource` returns `("trace", trace_id)` — checks for a direct trace grant. - -### Workspace MANAGE interaction - -Workspace MANAGE (`(workspace, *, MANAGE)`) continues to fold into every -resource-type check, including sub-resources in both modes. A workspace admin has -unrestricted access to all resources in the workspace. This is unchanged. - -### Search filtering - -Search filtering follows the same pattern as today's `filter_search_experiments`: +Re-pointing a route means naming a **child** validator in `BEFORE_REQUEST_HANDLERS` +where it named the parent's, and giving that validator's resolver the child clause. +For `CreateRun`: ```python -def filter_search_traces(user, traces, workspace): - return [t for t in traces if resolve_permission(user_id, "trace", t.id, workspace).can_read] +# BEFORE_REQUEST_HANDLERS +- CreateRun: validate_can_update_experiment # gated on the parent experiment ++ CreateRun: validate_can_update_run # gated on the run (child) and parent experiment + +# the validator (same one-line shape as every validate_can_*) ++ def validate_can_update_run(): ++ return _get_permission_from_run_id().can_update + +# the resolver — today's parent/workspace logic UNCHANGED; only the child clause is added + def _get_permission_from_run_id(): + run = get_run(run_id); experiment_id = run.info.experiment_id + return _get_role_permission_or_default(_role_permission_for( + resource_type="experiment", resource_key=experiment_id, # parent — as today + workspace_lookup_id=experiment_id, workspace_fetcher=get_experiment, ++ child_type="run", child_id=run_id, # (NEW) the child clause + )) ``` -In simplified mode, this resolves to experiment-level permission (existing -behavior). In fine-grained mode, it checks for trace-level grants directly. - -### Extensibility - -Any future resource type indexed in the DB can be added with: -1. One constant in `FINE_GRAINED_RESOURCE_TYPES` -2. A validator that resolves workspace (via parent lookup) and passes its own resource type - -No other changes to the resolution engine, API surface, or DB schema. - -### Performance - -**Simplified mode:** Identical to today. No new code paths execute. - -**Fine-grained mode:** One direct lookup per resource type in the already-loaded -role permissions (in-memory). No additional DB queries — role permissions for the -workspace are loaded once regardless of how many resource types are checked. +Below table summarrizes this for all APIs supported today: + +| Route | Gate today (3.15.1) | Gate added under this RFC | +|-------|---------------------|---------------------| +| `CreateRun`, `LogMetric`, `LogBatch`, `SetTag`, `UpdateRun` | experiment `can_update` | `run` `can_update` | +| `StartTrace`, `SetTraceTag` | experiment `can_update` | `trace` `can_update` | +| `CreateAssessment`, `UpdateAssessment`, `DeleteAssessment` | trace→experiment `can_update` | `assessment` `can_update` | +| `CreateLoggedModel` | experiment `can_update` | `logged_model` `can_update` | +| `CreateModelVersion` | registered_model update | `model_version` `can_update` | +| `CreateModelVersion` (prompt) | prompt update | `prompt_version` `can_update` | +| `CreateReviewQueue`, `AddItemsToReviewQueue`, `RemoveItemsFromReviewQueue` | experiment `can_update` (mixed) | `review_queue` `can_update` | + +#### Search filtering + +Result-set filtering must scope to the caller's authorized set **without** +over-fetching. This RFC supports wildcard-grain child grants, so the read predicate +is a single check per resource type: + +- `(child, *, READ+)` → the caller can read all children of that type in the + workspace (one boolean, as `filter_search_experiments` uses `wildcard_can_read` + today). +- No child grant → inherited from the parent (today's behavior). + +Per-ID child grants are **not** supported precisely because they cannot be filtered +efficiently: post-response filtering requires scanning/​over-fetching to fill a +page, which is pathological for high-cardinality children like traces. The scalable +path is request-level (pre-request) predicate push-down (see mlflow/mlflow#24964), +which injects the caller's authorized set into the store query so the search never +over-fetches. **Id-level grain will be added for these child types once that +search-filter push-down is in place**; until then the grain is wildcard-only. This +RFC does not depend on the push-down because wildcard grain already routes through +the cheap `wildcard_can_read` path above. + +#### Performance + +- **No child grant (the common/back-compat case):** the fold does one extra + in-memory comparison — the new parent clause is evaluated against the role + permission rows that are **already loaded** for the workspace (the fold iterates + them regardless). It is an added `max_permission` operand in the existing loop, + not a new DB query. +- **Child grant present:** same cost — the child grant is among the same + already-loaded rows. Resolution stays one pass over the in-memory rows, not + additional round-trips. + +#### UI impact + +The MLflow UI is a REST client of the same server: its data calls go through +`/ajax-api/2.0/...`, which the auth layer registers validators for **identically** +to `/api/2.0/...`. There is no separate UI authorization surface — only `/static`, +`/favicon.ico`, and `/health` are unprotected. So UI **search, create, edit, and +delete are already covered** by the base API surface this RFC modifies; no +UI-specific enforcement is added. The consequences are UX, not authorization: + +- **Search / list views** reflect the wildcard read predicate automatically — a + user with `(run, *, READ)` sees runs in list responses; one relying on inherited + experiment READ sees the same as today. No client change required. +- **Create / edit / delete controls** will succeed or return 403 based on the + re-pointed child gate. A user who previously could log runs *only* because they + held experiment EDIT is unaffected (that grant still inherits and satisfies the + child capability); a user newly granted `(run, *, EDIT)` can now use those + controls without experiment EDIT. The UI does not need to know which grant + satisfied the check. +- **Parent discovery still requires parent READ.** A child grant raises only the + child, never the parent (grants never flow upward). So a user with just + `(run, *, EDIT)` does **not** thereby see the experiment the run lives in — + listing/opening experiments gates on `(experiment, …)` READ, a different resource + type the run grant never matches. To navigate to a run in the UI a user needs + experiment READ as well, which is why the run-logging use case pairs them: + `(experiment, *, READ)` + `(run, *, EDIT)`. This matches today's behavior — child + access has never implied parent visibility. +- **Optional (not required):** the UI could gray out controls the caller lacks + capability for, but MLflow's UI does not do capability-driven control hiding + today, so this RFC does not add it — a 403 on action is the existing pattern and + remains correct. ## Drawbacks -- **Fine-grained mode requires more grants per role.** Admins must explicitly - grant on every sub-resource type a role needs. A role that previously needed one - experiment grant now needs up to 5+. Mitigated: this is opt-in — only - deployments that need fine-grained control enable it. - -- **Admins must choose between two permission models.** The deployment has either - simplified or fine-grained mode — admins need to understand which is active and - how grants behave in that mode. Mitigated: the default is simplified (today's - behavior), so admins who don't need fine-grained control never encounter the - choice. - -- **Migrating between modes requires temporarily over-scoping.** When switching - from simplified to fine-grained, admins must pre-create sub-resource grants - before flipping the flag. If an admin switches the flag without pre-creating - grants, users lose access until grants are added. Mitigated: the recommended - transition path (create grants first, then switch) avoids access disruption. +- **More grants per role when escalation is used.** A role that needs child-level + access must add a child grant per type. Mitigated: unused child types simply + inherit; only roles that need escalation add grants. # Alternatives -### A. Inheritance-based permission model - -Add an `inherit` flag to each grant, allowing some grants to flow to children -and others not. - -```python -# inherit=True (default, backward compatible): EDIT flows to runs, traces, assessments, logged_models -client.add_role_permission(role.id, "experiment", "*", "EDIT", inherit=True) - -# inherit=False: EDIT applies only to experiment metadata, children get nothing -client.add_role_permission(role.id, "experiment", "*", "EDIT", inherit=False) -# Must separately grant on children: -client.add_role_permission(role.id, "trace", "*", "READ") -``` - -**Rejected because:** creates conflicts (what if one grant says inherit=True and -another says inherit=False on the same resource type?). Complicates the mental -model — admins must understand per-grant inheritance semantics. Note: `inherit=True` -would be the default to ensure backward compatibility, but the per-grant flag -still introduces ambiguity when multiple grants on the same resource type -disagree. - -### B. Capability-based permission levels - -Split EDIT into `EDIT_RUNS`, `EDIT_TRACES`, etc. - -**Rejected because:** breaks the scalar hierarchy that `max_permission()` depends -on. Contradicts the simplification direction of RFC 0005. - -### C. Action-based permissions - -Grant specific actions (`create_trace`, `delete_run`). - -**Rejected because:** contradicts RFC 0005's design choice of permission levels -over per-action grants. Creates N² explosion. Unmanageable admin UX. +### A. Two-mode flag (`simplified` / `fine_grained`) + +A global config flag selects between "children always inherit" and "children are +independently permissioned." **Rejected:** it is all-or-nothing (raised in review +as the primary objection), maintains two resolution regimes behind a branch, and +requires a mode-transition story (pre-creating grants before flipping). The `max` +model needs no flag — inheritance is simply `max` with no child grant present. + +### B. Downward-override / most-specific-wins + +A child grant *replaces* the inherited parent permission, so it can raise **or** +lower a child (e.g. "EDIT the experiment but restrict traces to READ"), including a +configurable `NONE`/deny action raised in review — e.g. `(experiment, *, READ)` + +`(assessment, *, EDIT)` + `(run, *, NONE)` + `(logged_model, *, NONE)` to let an +evaluator write assessments while *hiding* runs and models. +**Rejected — this does not fit MLflow's authorization model.** MLflow RBAC is +allow-only and monotonic: every grant combination folds with `max_permission` +(workspace-wide and resource-specific grants alike), and there is no explicit +deny anywhere in the model. A `NONE`/deny action is downward-override by another +name — `max("READ", "NONE")` cannot lower the inherited READ, so honoring it would +require abandoning the `max` fold for a most-specific-wins rule with explicit deny. +That would introduce a non-monotonic rule that behaves differently from the rest of +the system — adding a grant could *reduce* a user's access, which is surprising and +inconsistent with how every other grant works. We are electing not to support +lowering a child below its parent. Note the *escalation* half of the evaluator case +is already served additively (`(experiment, READ)` + `(assessment, EDIT)` → view all, +write assessments); the only thing `NONE` adds is *hiding* sibling children, a deny +capability with no current demand on record — every requirement is an escalation. If +a genuine restriction use case ever arises, it should be an explicit, opt-in +resolution mode with its own precedence rules, not a change to this model. + +### C. Per-grant `inherit` flag + +Add `inherit=True|False` to each grant. **Rejected:** ambiguous when two grants on +the same resource type disagree, and it relocates rather than removes the +two-behavior complexity — every validator still branches on inherit-or-not. + +### D. Capability-split or action-based permissions + +Split `EDIT` into `EDIT_RUNS`/`EDIT_TRACES`, or grant per-action. **Rejected:** +breaks the scalar permission hierarchy `max_permission` depends on, and +contradicts RFC 0005's level-based design. N² explosion in admin UX. # Adoption strategy -**This is not a breaking change.** The default mode (`simplified`) is identical -to today's behavior. - -**Adoption path for operators who want fine-grained control:** - -1. Upgrade to the version shipping this change (no behavior change) -2. Audit existing roles and determine which sub-resources each role should access -3. Add explicit sub-resource grants to each role (can be done while still in - simplified mode — the grants are stored but unused until mode switches) -4. Switch to fine-grained mode: - ```ini - permission_granularity = fine_grained - ``` -5. Verify access is as expected -6. Adjust parent grants as needed (e.g., downgrade `experiment` from EDIT to READ - now that sub-resources are independently granted) - -**Reverting to simplified mode:** - -1. Ensure parent-level grants (experiment, registered_model) cover the desired - access — in simplified mode, sub-resource grants are ignored and all access - derives from the parent -2. Switch back to simplified: - ```ini - permission_granularity = simplified - ``` -3. Sub-resource grants remain stored but have no effect — they can be cleaned up - or left in place for a future switch back +**Not a breaking change.** With no child grants configured, every child inherits +from its parent exactly as today — no configuration and no behavior change on +upgrade. -# Open questions +Operators who want child-level escalation: +1. Upgrade (no behavior change). +2. Add wildcard child grants where a role needs more access to a child than its + parent grant gives (e.g. `(assessment, *, EDIT)` for evaluators, + `(run, *, EDIT)` for logging service accounts). +3. Optionally downgrade the parent grant now that the child is granted directly + (e.g. experiment `EDIT` → `READ` once `(run, *, EDIT)` covers logging). -1. **How does this interact with a pluggable auth backend?** An - `OPERATION_REGISTRY` approach would emit - `AuthorizationRequirement(resource_type="trace", ...)` for trace operations. - The `DefaultDbAuthorizationBackend` would implement `resolve_resource` with - the mode-dependent logic. The designs are complementary. +There is no mode to flip and nothing to revert: removing a child grant returns +that child to pure inheritance. -2. **Should the admin UI show which mode is active and the effective permissions - per resource type?** Proposed: yes. In fine-grained mode, the UI should - clearly show which resource types a role has grants on and which are ungranted. +# Open questions -3. **Should new resource types added in future releases default to simplified or - fine-grained behavior?** Proposed: new sub-resource types follow the same - pattern — they are added to `FINE_GRAINED_RESOURCE_TYPES` and resolve to their - parent in simplified mode. +_None at this time; will be added as they come up during review._