Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
062d374
add default group permission param
grudloffev Mar 10, 2025
a49303a
add get_user_groups util
grudloffev Mar 10, 2025
45fa3f5
add utils missing typehints
grudloffev Mar 10, 2025
144fba1
add group permissions in after hook
grudloffev Mar 10, 2025
3b87de6
delete model group permissions in after hook
grudloffev Mar 10, 2025
e12c190
change after hook names to match purpose
grudloffev Mar 10, 2025
95d78e4
add debugging
grudloffev Mar 10, 2025
388d904
use appropriate group key
grudloffev Mar 10, 2025
915e535
add user_groups to session
grudloffev Mar 10, 2025
e92d88a
fix incorrect key
grudloffev Mar 10, 2025
1bf046a
set to filtered user groups
grudloffev Mar 10, 2025
390761d
fix group filter and include admin
grudloffev Mar 10, 2025
d794466
fix typo
grudloffev Mar 10, 2025
6b58911
refactor get_user_groups
grudloffev Mar 14, 2025
c307763
add debugin of session set
grudloffev Mar 14, 2025
78530be
fix typo
grudloffev Mar 14, 2025
f104dd2
get groups from store in case not available elsewhere
grudloffev Mar 17, 2025
6b0bf70
use filtered groups when populating store
grudloffev Mar 17, 2025
5518067
filter groups retrieved from token
grudloffev Mar 17, 2025
b544259
fix fetching groups from token
grudloffev Mar 17, 2025
6c4ff5d
Merge branch 'main' of https://github.com/mlflow-oidc/mlflow-oidc-auth
grudloffev Mar 18, 2025
b0da77b
refactor get_user_groups and remove some logs
grudloffev Mar 18, 2025
3550542
small refactor in callback
grudloffev Mar 18, 2025
336601d
remove todo and log
grudloffev Mar 18, 2025
b4c4b8a
use utils.get_user_groups in callback
grudloffev Mar 18, 2025
eca63ff
add vscode folder to gitignore
grudloffev Mar 18, 2025
b5b1c3c
remove innecessary line
grudloffev Mar 18, 2025
dd49a91
update docs
grudloffev Mar 18, 2025
c2bf716
pass username to get_user_groups
grudloffev Mar 18, 2025
5b3720a
do not use storage to get user_groups (this allows for updating them …
grudloffev Mar 19, 2025
b5b6902
add docstring
grudloffev Mar 19, 2025
5f747ca
add tests
grudloffev Mar 19, 2025
54b1f32
update docstring
grudloffev Mar 19, 2025
35442fc
revert use of get_user_groups in callback
grudloffev Mar 19, 2025
8734aed
handle user with no groups
grudloffev Mar 19, 2025
a589f72
handle sql exception when query has no results
grudloffev Mar 19, 2025
ace5a2a
extract user group filtering to function
grudloffev Mar 21, 2025
ad4ffbd
fix https://github.com/mlflow-oidc/mlflow-oidc-auth/issues/94
grudloffev Mar 24, 2025
3e103bf
Merge branch 'main' of https://github.com/mlflow-oidc/mlflow-oidc-aut…
grudloffev Jun 5, 2025
413f242
add typechecking comment
grudloffev Jun 5, 2025
0e18684
remove use of session to store user groups
grudloffev Jun 5, 2025
3987f6f
Merge branch 'main' of https://github.com/mlflow-oidc/mlflow-oidc-aut…
grudloffev Jun 5, 2025
7105f87
Merge branch 'mlflow-oidc:main' into main
grudloffev Jun 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,6 @@ integration-tests/

# until it ready
web-react/

# VS Code
.vscode
61 changes: 30 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,39 +18,38 @@ python3 -m pip install mlflow-oidc-auth
The plugin required the following environment variables but also supported `.env` file

## Application configuration
| Parameter | Description|
|---|---|
| OIDC_REDIRECT_URI | Application redirect/callback url (https://example.com/callback) |
| OIDC_DISCOVERY_URL | OIDC Discovery URL |
| OIDC_CLIENT_SECRET | OIDC Client Secret |
| OIDC_CLIENT_ID | OIDC Client ID |
| OIDC_GROUP_DETECTION_PLUGIN | OIDC plugin to detect groups |
| OIDC_PROVIDER_DISPLAY_NAME | any text to display |
| OIDC_SCOPE | OIDC scope |
| OIDC_GROUP_NAME | User group name to be allowed login to MLFlow, currently supported groups in OIDC claims and Microsoft Entra ID groups |
| OIDC_ADMIN_GROUP_NAME | User group name to be allowed login to MLFlow manage and define permissions, currently supported groups in OIDC claims and Microsoft Entra ID groups |
| OIDC_AUTHORIZATION_URL | OIDC Auth URL (if discovery URL is not defined) |
| OIDC_TOKEN_URL | OIDC Token URL (if discovery URL is not defined) |
| OIDC_USER_URL | OIDC User info URL (if discovery URL is not defined) |
| SECRET_KEY | Key to perform cookie encryption |
| LOG_LEVEL | Application log level |
| OIDC_USERS_DB_URI | Database connection string |
| OIDC_ALEMBIC_VERSION_TABLE | Name of the table to use for alembic versions (defaults to alembic_version if not provided) |
| Parameter | Description| Default | Mandatory |
|---|---|---|---|
| OIDC_REDIRECT_URI | Application redirect/callback url (https://example.com/callback) | None | Yes |
| OIDC_DISCOVERY_URL | OIDC Discovery URL | None | Yes |
| OIDC_CLIENT_SECRET | OIDC Client Secret | None | Yes |
| OIDC_CLIENT_ID | OIDC Client ID | None | Yes |
| OIDC_GROUP_DETECTION_PLUGIN | OIDC plugin to detect groups | None | No |
| OIDC_PROVIDER_DISPLAY_NAME | any text to display | "Login with OIDC" | No |
| OIDC_SCOPE | OIDC scope | "openid,email,profile" | No |
| OIDC_GROUP_NAME | User group name to be allowed login to MLFlow, currently supported groups in OIDC claims and Microsoft Entra ID groups | "mlflow" | No |
| OIDC_ADMIN_GROUP_NAME | User group name to be allowed login to MLFlow manage and define permissions, currently supported groups in OIDC claims and Microsoft Entra ID groups | "mlflow-admin" | No |
| SECRET_KEY | Key to perform cookie encryption | A secret key will be generated | No |
| LOG_LEVEL | Application log level | "INFO" | No |
| OIDC_USERS_DB_URI | Database connection string | "sqlite:///auth.db" | No |
| OIDC_ALEMBIC_VERSION_TABLE | Name of the table to use for alembic versions | "alembic_version" | No |
| DEFAULT_MLFLOW_PERMISSION | Default fallback permission on all resources | "MANAGE" | No |
| DEFAULT_MLFLOW_GROUP_PERMISSION | Default group permission assigned on resource creation, no permission will be assigned if unspecified | None | No |

## Application session storage configuration
| Parameter | Description | Default |
|---|---|---|
| SESSION_TYPE | Flask session type (filesystem or redis supported) | filesystem |
| SESSION_FILE_DIR | The directory where session files are stored | flask_session |
| SESSION_PERMANENT | Whether use permanent session or not | False |
| PERMANENT_SESSION_LIFETIME | Server-side session expiration time (in seconds) | 86400 |
| SESSION_KEY_PREFIX | A prefix that is added before all session keys | mlflow_oidc: |
| REDIS_HOST | Redis hostname | localhost |
| REDIS_PORT | Redis port | 6379 |
| REDIS_DB | Redis DB number | 0 |
| REDIS_USERNAME | Redis username | None |
| REDIS_PASSWORD | Redis password | None |
| REDIS_SSL | Use SSL | false |
| Parameter | Description | Default | Mandatory |
|---|---|---|---|
| SESSION_TYPE | Flask session type (filesystem or redis supported) | filesystem | No |
| SESSION_FILE_DIR | The directory where session files are stored | flask_session | No |
| SESSION_PERMANENT | Whether use permanent session or not | False | No |
| PERMANENT_SESSION_LIFETIME | Server-side session expiration time (in seconds) | 86400 | No |
| SESSION_KEY_PREFIX | A prefix that is added before all session keys | mlflow_oidc: | No |
| REDIS_HOST | Redis hostname | localhost | No |
| REDIS_PORT | Redis port | 6379 | No |
| REDIS_DB | Redis DB number | 0 | No |
| REDIS_USERNAME | Redis username | None | No |
| REDIS_PASSWORD | Redis password | None | No |
| REDIS_SSL | Use SSL | false | No |

# Configuration examples

Expand Down
60 changes: 30 additions & 30 deletions docs/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,35 @@
The plugin required the following environment variables but also supported `.env` file

## Application configuration
| Parameter | Description|
|---|---|
| OIDC_REDIRECT_URI | Application redirect/callback url (https://example.com/callback) |
| OIDC_DISCOVERY_URL | OIDC Discovery URL |
| OIDC_CLIENT_SECRET | OIDC Client Secret |
| OIDC_CLIENT_ID | OIDC Client ID |
| OIDC_GROUP_DETECTION_PLUGIN | OIDC plugin to detect groups |
| OIDC_PROVIDER_DISPLAY_NAME | any text to display |
| OIDC_SCOPE | OIDC scope |
| OIDC_GROUP_NAME | User group name to be allowed login to MLFlow, currently supported groups in OIDC claims and Microsoft Entra ID groups |
| OIDC_ADMIN_GROUP_NAME | User group name to be allowed login to MLFlow manage and define permissions, currently supported groups in OIDC claims and Microsoft Entra ID groups |
| OIDC_AUTHORIZATION_URL | OIDC Auth URL (if discovery URL is not defined) |
| OIDC_TOKEN_URL | OIDC Token URL (if discovery URL is not defined) |
| OIDC_USER_URL | OIDC User info URL (if discovery URL is not defined) |
| SECRET_KEY | Key to perform cookie encryption |
| LOG_LEVEL | Application log level |
| OIDC_USERS_DB_URI | Database connection string |
| Parameter | Description| Default | Mandatory |
|---|---|---|---|
| OIDC_REDIRECT_URI | Application redirect/callback url (https://example.com/callback) | None | Yes |
| OIDC_DISCOVERY_URL | OIDC Discovery URL | None | Yes |
| OIDC_CLIENT_SECRET | OIDC Client Secret | None | Yes |
| OIDC_CLIENT_ID | OIDC Client ID | None | Yes |
| OIDC_GROUP_DETECTION_PLUGIN | OIDC plugin to detect groups | None | No |
| OIDC_PROVIDER_DISPLAY_NAME | any text to display | "Login with OIDC" | No |
| OIDC_SCOPE | OIDC scope | "openid,email,profile" | No |
| OIDC_GROUP_NAME | User group name to be allowed login to MLFlow, currently supported groups in OIDC claims and Microsoft Entra ID groups | "mlflow" | No |
| OIDC_ADMIN_GROUP_NAME | User group name to be allowed login to MLFlow manage and define permissions, currently supported groups in OIDC claims and Microsoft Entra ID groups | "mlflow-admin" | No |
| SECRET_KEY | Key to perform cookie encryption | A secret key will be generated | No |
| LOG_LEVEL | Application log level | "INFO" | No |
| OIDC_USERS_DB_URI | Database connection string | "sqlite:///auth.db" | No |
| OIDC_ALEMBIC_VERSION_TABLE | Name of the table to use for alembic versions | "alembic_version" | No |
| DEFAULT_MLFLOW_PERMISSION | Default fallback permission on all resources | "MANAGE" | No |
| DEFAULT_MLFLOW_GROUP_PERMISSION | Default group permission assigned on resource creation, no permission will be assigned if unspecified | None | No |

## Application session storage configuration
| Parameter | Description | Default |
|---|---|---|
| SESSION_TYPE | Flask session type (filesystem or redis supported) | filesystem |
| SESSION_FILE_DIR | The directory where session files are stored | flask_session |
| SESSION_PERMANENT | Whether use permanent session or not | False |
| PERMANENT_SESSION_LIFETIME | Server-side session expiration time (in seconds) | 86400 |
| SESSION_KEY_PREFIX | A prefix that is added before all session keys | mlflow_oidc: |
| REDIS_HOST | Redis hostname | localhost |
| REDIS_PORT | Redis port | 6379 |
| REDIS_DB | Redis DB number | 0 |
| REDIS_USERNAME | Redis username | None |
| REDIS_PASSWORD | Redis password | None |
| REDIS_SSL | Use SSL | false |
| Parameter | Description | Default | Mandatory |
|---|---|---|---|
| SESSION_TYPE | Flask session type (filesystem or redis supported) | filesystem | No |
| SESSION_FILE_DIR | The directory where session files are stored | flask_session | No |
| SESSION_PERMANENT | Whether use permanent session or not | False | No |
| PERMANENT_SESSION_LIFETIME | Server-side session expiration time (in seconds) | 86400 | No |
| SESSION_KEY_PREFIX | A prefix that is added before all session keys | mlflow_oidc: | No |
| REDIS_HOST | Redis hostname | localhost | No |
| REDIS_PORT | Redis port | 6379 | No |
| REDIS_DB | Redis DB number | 0 | No |
| REDIS_USERNAME | Redis username | None | No |
| REDIS_PASSWORD | Redis password | None | No |
| REDIS_SSL | Use SSL | false | No |
1 change: 1 addition & 0 deletions mlflow_oidc_auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def get_bool_env_variable(variable, default_value):
class AppConfig:
def __init__(self):
self.DEFAULT_MLFLOW_PERMISSION = os.environ.get("DEFAULT_MLFLOW_PERMISSION", "MANAGE")
self.DEFAULT_MLFLOW_GROUP_PERMISSION = os.environ.get("DEFAULT_MLFLOW_GROUP_PERMISSION", None)
self.SECRET_KEY = os.environ.get("SECRET_KEY", secrets.token_hex(16))
self.OIDC_USERS_DB_URI = os.environ.get("OIDC_USERS_DB_URI", "sqlite:///auth.db")
self.OIDC_GROUP_NAME = [group.strip() for group in os.environ.get("OIDC_GROUP_NAME", "mlflow").split(",")]
Expand Down
32 changes: 22 additions & 10 deletions mlflow_oidc_auth/hooks/after_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from mlflow.utils.proto_json_utils import message_to_json, parse_dict
from mlflow.utils.search_utils import SearchUtils

from mlflow_oidc_auth.config import config
from mlflow_oidc_auth.permissions import MANAGE
from mlflow_oidc_auth.store import store
from mlflow_oidc_auth.utils import (
Expand All @@ -25,26 +26,38 @@
fetch_registered_models_paginated,
fetch_readable_registered_models,
fetch_readable_experiments,
get_user_groups,
)


def _set_can_manage_experiment_permission(resp: Response):
response_message = CreateExperiment.Response() # type: ignore
def _set_initial_experiment_permission(resp: Response):
response_message = CreateExperiment.Response()
parse_dict(resp.json, response_message)
experiment_id = response_message.experiment_id
username = get_username()
store.create_experiment_permission(experiment_id, username, MANAGE.name)
user_groups = get_user_groups(username)
if permission := config.DEFAULT_MLFLOW_GROUP_PERMISSION:
for group_name in user_groups:
store.create_group_experiment_permission(group_name, experiment_id, permission)


def _set_can_manage_registered_model_permission(resp: Response):
def _set_initial_registered_model_permission(resp: Response):
response_message = CreateRegisteredModel.Response() # type: ignore
parse_dict(resp.json, response_message)
name = response_message.registered_model.name
model_name = response_message.registered_model.name
username = get_username()
store.create_registered_model_permission(name, username, MANAGE.name)
store.create_registered_model_permission(model_name, username, MANAGE.name)
user_groups = get_user_groups(username)
if permission := config.DEFAULT_MLFLOW_GROUP_PERMISSION:
for group_name in user_groups:
store.create_group_model_permission(group_name, model_name, permission)


def _delete_can_manage_registered_model_permission(resp: Response):
# TODO: Should a _delete_experiment_permission be added?


def _delete_registered_model_permission(resp: Response):
"""
Delete registered model permission when the model is deleted.

Expand All @@ -53,7 +66,6 @@ def _delete_can_manage_registered_model_permission(resp: Response):
we have to delete the permission record when the model is deleted otherwise it
conflicts with the new model registered with the same name.
"""
# Get model name from request context because it's not available in the response
model_name = get_model_name()
store.wipe_group_model_permissions(model_name)
store.wipe_registered_model_permissions(model_name)
Expand Down Expand Up @@ -130,9 +142,9 @@ def _filter_search_registered_models(resp: Response):


AFTER_REQUEST_PATH_HANDLERS = {
CreateExperiment: _set_can_manage_experiment_permission,
CreateRegisteredModel: _set_can_manage_registered_model_permission,
DeleteRegisteredModel: _delete_can_manage_registered_model_permission,
CreateExperiment: _set_initial_experiment_permission,
CreateRegisteredModel: _set_initial_registered_model_permission,
DeleteRegisteredModel: _delete_registered_model_permission,
SearchExperiments: _filter_search_experiments,
SearchRegisteredModels: _filter_search_registered_models,
}
Expand Down
72 changes: 71 additions & 1 deletion mlflow_oidc_auth/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import unittest
from unittest.mock import MagicMock, patch
from flask import Flask, session, request

from flask import Flask
from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import BAD_REQUEST, INVALID_PARAMETER_VALUE, RESOURCE_DOES_NOT_EXIST

from mlflow_oidc_auth.permissions import Permission
from mlflow_oidc_auth.utils import (
get_is_admin,
get_user_groups,
get_permission_from_store_or_default,
PermissionResult,
can_manage_experiment,
can_manage_registered_model,
Expand Down Expand Up @@ -49,6 +52,7 @@
class TestUtils(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.app.secret_key = 'test_secret_key'
self.app.config["TESTING"] = True
self.app_context = self.app.app_context()
self.app_context.push()
Expand Down Expand Up @@ -199,6 +203,72 @@ def mock_func():
mock_get_is_admin.return_value = True
self.assertEqual(mock_func(), "success")

@patch("importlib.import_module")
@patch("mlflow_oidc_auth.utils.app")
@patch('mlflow_oidc_auth.utils.config')
@patch('mlflow_oidc_auth.utils.validate_token')
def test_get_user_groups_from_plugin(self, mock_validate_token,
mock_config, mock_app,
mock_import_module):
mock_validate_token.return_value = {"test_oidc_groups":
['group1', 'group2']}
mock_config.OIDC_GROUPS_ATTRIBUTE = "test_oidc_groups"
mock_config.OIDC_GROUP_NAME = ["group1", "group2"]
mock_config.OIDC_GROUP_DETECTION_PLUGIN = "groups_plugin"
mock_plugin = MagicMock()
mock_plugin.get_user_groups.return_value = ['group1', 'group2']
mock_import_module.return_value = mock_plugin
mock_app.logger.debug = MagicMock()

with self.app.test_request_context(headers={'Authorization':
'Bearer test_token'}):
groups = get_user_groups()
assert groups == ['group1', 'group2']
mock_app.logger.debug.assert_called_once_with(
f"Groups from plugin: {groups}"
)

@patch("mlflow_oidc_auth.utils.app")
@patch('mlflow_oidc_auth.utils.config')
@patch('mlflow_oidc_auth.utils.validate_token')
def test_get_user_groups_from_bearer_token(self, mock_validate_token,
mock_config, mock_app):
mock_validate_token.return_value = {"test_oidc_groups":
['group1', 'group2']}
mock_config.OIDC_GROUPS_ATTRIBUTE = "test_oidc_groups"
mock_config.OIDC_GROUP_NAME = ["group1", "group2"]
mock_config.OIDC_GROUP_DETECTION_PLUGIN = None
mock_app.logger.debug = MagicMock()

with self.app.test_request_context(headers={'Authorization':
'Bearer test_token'}):
groups = get_user_groups()
assert groups == ['group1', 'group2']
mock_app.logger.debug.assert_called_once_with(
f"Groups from bearer token: {groups}"
)

@patch("mlflow_oidc_auth.utils.app")
@patch('mlflow_oidc_auth.utils.store.get_groups_for_user')
def test_get_user_groups_from_store(self, mock_get_groups_for_user,
mock_app):
mock_get_groups_for_user.return_value = ['group1', 'group2']
mock_app.logger.debug = MagicMock()

with self.app.test_request_context():
groups = get_user_groups(username='test_user')
assert groups == ['group1', 'group2']
mock_app.logger.debug.assert_called_once_with(
f"Groups from store: {groups}"
)

@patch("mlflow_oidc_auth.utils.app")
def test_get_user_groups_no_groups_found(self, mock_app):
with self.app.test_request_context():
groups = get_user_groups()
assert groups == []
mock_app.logger.debug.assert_not_called()

@patch("mlflow_oidc_auth.utils.store")
@patch("mlflow_oidc_auth.utils.get_is_admin")
@patch("mlflow_oidc_auth.utils.get_username")
Expand Down
Loading