diff --git a/providers/fab/docs/auth-manager/sso.rst b/providers/fab/docs/auth-manager/sso.rst index 2989123deabeb..925f27927f700 100644 --- a/providers/fab/docs/auth-manager/sso.rst +++ b/providers/fab/docs/auth-manager/sso.rst @@ -204,6 +204,65 @@ Provider Examples For Azure app registration and OAuth setup, see :doc:`apache-airflow-providers-microsoft-azure:connections/azure` and the `Azure OAuth2 documentation `_. +**Azure AD with Group-Based Authorization** + +.. code-block:: python + + from flask_appbuilder.security.manager import AUTH_OAUTH + + AUTH_TYPE = AUTH_OAUTH + + AUTH_OAUTH_ROLE_KEYS = { + "azure": "groups", + } + + OAUTH_PROVIDERS = [ + { + "name": "azure", + "token_key": "access_token", + "icon": "fa-windows", + "remote_app": { + "client_id": "your-client-id", + "client_secret": "your-client-secret", + "api_base_url": "https://login.microsoftonline.com//v2.0", + "client_kwargs": { + "scope": "openid email profile groups", + "resource": "your-client-id", + "verify_signature": True, + }, + "request_token_url": None, + "access_token_url": "https://login.microsoftonline.com//oauth2/v2.0/token", + "authorize_url": "https://login.microsoftonline.com//oauth2/v2.0/authorize", + }, + } + ] + + AUTH_ROLES_MAPPING = { + "airflow-admin-group": ["Admin"], + "airflow-op-group": ["Op"], + "airflow-user-group": ["User"], + "airflow-viewer-group": ["Viewer"], + } + + AUTH_ROLES_SYNC_AT_LOGIN = True + + AUTH_USER_REGISTRATION = True + AUTH_USER_REGISTRATION_ROLE = "Viewer" + +.. note:: + When using Azure AD groups: + + - Ensure the ``groups`` scope is included in ``client_kwargs`` + - Configure group claims in your Azure app registration + - The ``AUTH_OAUTH_ROLE_KEYS`` setting allows you to specify which claim field + contains the authorization information (``roles`` or ``groups``) + - Group names from Azure AD will be matched against ``AUTH_ROLES_MAPPING`` + +.. important:: + The ``AUTH_OAUTH_ROLE_KEYS`` configuration is provider-specific. For Azure, + you can set it to ``"roles"`` (default) or ``"groups"`` depending on your + Azure AD setup. Other OAuth providers may use different field names. + **Google OAuth2** .. code-block:: bash diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py index 4c7897093ca1d..593b17c7c24b9 100644 --- a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py +++ b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py @@ -2145,12 +2145,13 @@ def get_oauth_user_info(self, provider: str, resp: dict[str, Any]) -> dict[str, me = self._decode_and_validate_azure_jwt(resp["id_token"]) log.debug("User info from Azure: %s", me) # https://learn.microsoft.com/en-us/azure/active-directory/develop/id-token-claims-reference#payload-claims + role_key = current_app.config.get("AUTH_OAUTH_ROLE_KEYS", {}).get("azure", "roles") return { "email": me["email"] if "email" in me else me["upn"], "first_name": me.get("given_name", ""), "last_name": me.get("family_name", ""), "username": me["oid"], - "role_keys": me.get("roles", []), + "role_keys": me.get(role_key, []), } # for OpenShift if provider == "openshift": diff --git a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py index ae9ab7be8aab9..f72a093938a84 100644 --- a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py +++ b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py @@ -135,6 +135,23 @@ def test_check_password_not_match(self, check_password): "role_keys": ["admin"], }, ), + ( + "azure", + { + "oid": "test", + "given_name": "John", + "family_name": "Doe", + "email": "test@example.com", + "groups": ["group1", "group2"], + }, + { + "username": "test", + "first_name": "John", + "last_name": "Doe", + "email": "test@example.com", + "role_keys": [], + }, + ), ("openshift", {"metadata": {"name": "test"}}, {"username": "openshift_test"}), ( "okta", @@ -234,13 +251,43 @@ def test_check_password_not_match(self, check_password): ], ) def test_get_oauth_user_info(self, provider, resp, user_info): - sm = EmptySecurityManager() - sm.appbuilder = Mock(sm=sm) - sm.oauth_remotes = {} - sm.oauth_remotes[provider] = Mock( - get=Mock(return_value=Mock(json=Mock(return_value=resp))), - userinfo=Mock(return_value=resp), - ) - sm._decode_and_validate_azure_jwt = Mock(return_value=resp) - sm._get_authentik_token_info = Mock(return_value=resp) - assert sm.get_oauth_user_info(provider, {"id_token": None}) == user_info + from flask import Flask + + app = Flask(__name__) + with app.app_context(): + sm = EmptySecurityManager() + sm.appbuilder = Mock(sm=sm) + sm.oauth_remotes = {} + sm.oauth_remotes[provider] = Mock( + get=Mock(return_value=Mock(json=Mock(return_value=resp))), + userinfo=Mock(return_value=resp), + ) + sm._decode_and_validate_azure_jwt = Mock(return_value=resp) + sm._get_authentik_token_info = Mock(return_value=resp) + assert sm.get_oauth_user_info(provider, {"id_token": None}) == user_info + + def test_get_oauth_user_info_azure_with_groups_config(self): + from flask import Flask + + app = Flask(__name__) + app.config["AUTH_OAUTH_ROLE_KEYS"] = {"azure": "groups"} + + azure_response = { + "oid": "user-123", + "given_name": "Jane", + "family_name": "Smith", + "email": "jane.smith@example.com", + "groups": ["admin-group", "viewer-group"], + } + + with app.app_context(): + sm = EmptySecurityManager() + sm.appbuilder = Mock(sm=sm) + sm.oauth_remotes = {} + sm._decode_and_validate_azure_jwt = Mock(return_value=azure_response) + + user_info = sm.get_oauth_user_info("azure", {"id_token": "test-token"}) + + assert user_info["username"] == "user-123" + assert user_info["email"] == "jane.smith@example.com" + assert user_info["role_keys"] == ["admin-group", "viewer-group"]