This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 72e27157974 Add declarative configuration for FAB custom roles (#72820)
72e27157974 is described below

commit 72e27157974a1531ad4b3cba09c56389987ad27f
Author: ucaeon <[email protected]>
AuthorDate: Sat Sep 12 00:23:50 2026 +0900

    Add declarative configuration for FAB custom roles (#72820)
    
    Allow deployments to provision custom roles from configuration while 
preserving permissions maintained through the UI and CLI. Role creation must 
not leave partial permissions or alter a role created concurrently.
---
 providers/fab/docs/auth-manager/access-control.rst |  58 +++
 providers/fab/provider.yaml                        |  13 +
 .../fab/auth_manager/security_manager/override.py  | 101 ++++-
 .../src/airflow/providers/fab/get_provider_info.py |   7 +
 .../security_manager/test_custom_roles.py          | 439 +++++++++++++++++++++
 .../tests/unit/fab/auth_manager/test_security.py   |  54 +++
 6 files changed, 671 insertions(+), 1 deletion(-)

diff --git a/providers/fab/docs/auth-manager/access-control.rst 
b/providers/fab/docs/auth-manager/access-control.rst
index 761becef418..1e0a6051b3f 100644
--- a/providers/fab/docs/auth-manager/access-control.rst
+++ b/providers/fab/docs/auth-manager/access-control.rst
@@ -82,6 +82,64 @@ other users. ``Admin`` users have ``Op`` permission plus 
additional permissions:
 Custom Roles
 '''''''''''''
 
+Declarative custom roles
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+Use ``[fab] custom_roles`` to create custom roles and their permissions when 
the API server starts:
+
+.. code-block:: ini
+
+    [fab]
+    custom_roles = {"PythonTester": [{"action": "can_read", "resource": 
"DAGs"}], "Analyst": []}
+
+The equivalent environment variable is:
+
+.. code-block:: bash
+
+    export AIRFLOW__FAB__CUSTOM_ROLES='{"PythonTester": [{"action": 
"can_read", "resource": "DAGs"}], "Analyst": []}'
+
+The default is ``{}``, which creates no roles. Each role maps to a list of 
permission objects
+containing exactly ``action`` and ``resource``. Use actual FAB names such as 
``can_edit`` and
+``DAGs``, not Python constant names such as ``ACTION_CAN_EDIT`` or a 
``permission`` key.
+Names must be non-empty strings: role names support up to 64 characters, 
actions up to 100,
+and resources up to 250. Repeated permission pairs are applied once.
+
+Action and resource names must already be registered in the FAB database when 
this
+configuration is applied. They are validated independently: a new permission 
pairing an
+existing action with an existing resource is allowed. This configuration does 
not create
+actions or resources. Unknown names raise a configuration error before any 
configured
+role is created.
+
+Default-role permissions are synchronized before this validation. Permissions 
supplied by
+plugins or individual Dags must have their action and resource names 
registered beforehand;
+names registered later during startup are not available to this initialization 
step.
+
+All entries are validated before any configured role is created, including 
entries for existing
+and built-in roles. Invalid JSON or an invalid structure raises a 
configuration error.
+After validation, built-in roles (``Admin``, ``Viewer``, ``User``, ``Op``, and 
``Public``)
+are skipped with a warning.
+
+Only missing roles are created. Like ``airflow roles import``, an existing 
role name is skipped.
+Changing the configuration does not update existing permissions, so UI and CLI 
edits are preserved.
+Removing a role from the configuration does not delete it from the database. 
Deleting a configured
+role from the database allows it to be created again at the next 
initialization.
+
+Each new role and its declared permissions are committed together. If creation 
fails, that role's
+transaction is rolled back; roles successfully created earlier are retained. 
If another process
+creates the same role first, its role is left unchanged by this configuration.
+
+An empty list, such as ``"Analyst": []``, declares no permissions. Normal FAB 
initialization still
+adds ``can_read`` on ``Website`` to custom roles. The existing default-role 
and permission
+maintenance behavior is unchanged.
+
+Startup initialization follows ``[fab] update_fab_perms``. When it is 
disabled, configuration is
+not applied on startup. Running ``airflow sync-perm`` explicitly applies it 
regardless of that flag.
+This configuration does not create users or assign roles to them.
+
+Registered names do not guarantee that every action-resource combination 
grants access to
+an existing feature. For access to individual Dags, prefer the Dag's 
``DAG(access_control=...)``
+configuration rather than managing those permissions here.
+
 Dag Level Role
 ^^^^^^^^^^^^^^
 ``Admin`` can create a set of roles which are only allowed to view a certain 
set of Dags. This is called Dag level access. Each Dag defined in the Dag model 
table
diff --git a/providers/fab/provider.yaml b/providers/fab/provider.yaml
index 31586edde8e..4ee49559ea5 100644
--- a/providers/fab/provider.yaml
+++ b/providers/fab/provider.yaml
@@ -173,6 +173,19 @@ config:
         type: string
         example: ~
         default: "True"
+      custom_roles:
+        description: |
+          JSON object mapping custom role names to lists of objects with 
``action`` and
+          ``resource`` keys, using FAB permission names such as ``can_read`` 
and ``DAGs``.
+          Missing roles are created with their permissions during role 
initialization.
+          Existing roles are skipped, preserving changes made through the UI 
or CLI.
+          Built-in roles are ignored. Startup initialization requires 
``update_fab_perms``.
+          An empty list declares no permissions; normal FAB initialization 
still grants
+          custom roles ``can_read`` on ``Website``.
+        version_added: ~
+        type: string
+        example: '{"PythonTester": [{"action": "can_read", "resource": 
"DAGs"}], "Analyst": []}'
+        default: "{{}}"
       auth_backends:
         description: |
           Comma separated list of auth backends to authenticate users of the 
API.
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 5823fa19f08..0ac69dab596 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
@@ -72,7 +72,7 @@ from sqlalchemy.exc import IntegrityError, 
MultipleResultsFound
 from sqlalchemy.orm import joinedload
 from werkzeug.security import check_password_hash, generate_password_hash
 
-from airflow.providers.common.compat.sdk import conf
+from airflow.providers.common.compat.sdk import AirflowConfigException, conf
 from airflow.providers.fab.auth_manager.models import (
     Action,
     Group,
@@ -1241,11 +1241,110 @@ class 
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
         # Sync the default roles (Admin, Viewer, User, Op, public) with 
related permissions
         self.bulk_sync_roles(self.ROLE_CONFIGS)
 
+        self.create_roles_from_config()
+
         self.add_homepage_access_to_custom_roles()
         # init existing roles, the rest role could be created through UI.
         self.update_admin_permission()
         self.clean_perms()
 
+    def _get_custom_roles_config(self) -> dict[str, list[tuple[str, str]]]:
+        config = conf.getjson("fab", "custom_roles", fallback={})
+        if not isinstance(config, dict):
+            raise AirflowConfigException("[fab] custom_roles must be a JSON 
object")
+
+        roles: dict[str, list[tuple[str, str]]] = {}
+        for name, items in config.items():
+            if not isinstance(name, str) or not name.strip() or len(name) > 64:
+                raise AirflowConfigException("[fab] custom_roles role names 
must contain 1 to 64 characters")
+            if not isinstance(items, list):
+                raise AirflowConfigException(f"[fab] custom_roles[{name!r}] 
must be a list")
+            perms: set[tuple[str, str]] = set()
+            for index, item in enumerate(items):
+                location = f"[fab] custom_roles[{name!r}][{index}]"
+                if not isinstance(item, dict) or item.keys() != {"action", 
"resource"}:
+                    raise AirflowConfigException(f"{location} must contain 
only 'action' and 'resource'")
+                for key, limit in (("action", 100), ("resource", 250)):
+                    value = item[key]
+                    if not isinstance(value, str) or not value.strip() or 
len(value) > limit:
+                        raise AirflowConfigException(
+                            f"{location}.{key} must be a non-empty string of 
at most {limit} characters"
+                        )
+                perms.add((item["action"], item["resource"]))
+            roles[name] = sorted(perms)
+        return roles
+
+    def create_roles_from_config(self) -> None:
+        """Create missing configured roles, preserving permissions on existing 
roles."""
+        roles = self._get_custom_roles_config()
+        for name, perms in roles.items():
+            for action_name, resource_name in perms:
+                self._get_configured_action_and_resource(name, action_name, 
resource_name)
+        for name, perms in roles.items():
+            if name in EXISTING_ROLES:
+                log.warning("Skipping built-in role '%s' in [fab] 
custom_roles", name)
+                continue
+            self._create_role_from_config(name, perms)
+
+    def _get_configured_action_and_resource(
+        self, role_name: str, action_name: str, resource_name: str
+    ) -> tuple[Action, Resource]:
+        action = self.get_action(action_name)
+        if action is None:
+            raise AirflowConfigException(
+                f"Unknown action {action_name!r} in [fab] 
custom_roles[{role_name!r}]"
+            )
+        resource = self.get_resource(resource_name)
+        if resource is None:
+            raise AirflowConfigException(
+                f"Unknown resource {resource_name!r} in [fab] 
custom_roles[{role_name!r}]"
+            )
+        return action, resource
+
+    def _create_role_from_config(self, name: str, perms: list[tuple[str, 
str]]) -> None:
+        # FAB's public creation helpers commit individually; a configured role 
must
+        # become visible only after all its declared permissions have been 
attached.
+        for attempt in range(3):
+            conflict: tuple[str, str] | None = None
+            try:
+                if self.find_role(name) is not None:
+                    return
+                role = self.role_model()
+                role.name = name
+                role.permissions = []
+                self.session.add(role)
+                self.session.flush()
+
+                for action_name, resource_name in perms:
+                    perm = self.get_permission(action_name, resource_name)
+                    if perm is None:
+                        action, resource = 
self._get_configured_action_and_resource(
+                            name, action_name, resource_name
+                        )
+                        conflict = (action_name, resource_name)
+                        perm = self.permission_model()
+                        perm.action = action
+                        perm.resource = resource
+                        self.session.add(perm)
+                        self.session.flush()
+                    conflict = None
+                    if perm not in role.permissions:
+                        role.permissions.append(perm)
+
+                self.session.commit()
+                return
+            except IntegrityError:
+                self.session.rollback()
+                if self.find_role(name) is not None:
+                    return
+                if conflict is None or attempt == 2:
+                    raise
+                if self.get_permission(*conflict) is None:
+                    raise
+            except Exception:
+                self.session.rollback()
+                raise
+
     def create_perm_vm_for_all_dag(self) -> None:
         """Create perm-vm if not exist and insert into FAB security model for 
all-dags."""
         # create perm for global logical dag
diff --git a/providers/fab/src/airflow/providers/fab/get_provider_info.py 
b/providers/fab/src/airflow/providers/fab/get_provider_info.py
index 332774bca93..ae6b9fe73c9 100644
--- a/providers/fab/src/airflow/providers/fab/get_provider_info.py
+++ b/providers/fab/src/airflow/providers/fab/get_provider_info.py
@@ -107,6 +107,13 @@ def get_provider_info():
                         "example": None,
                         "default": "True",
                     },
+                    "custom_roles": {
+                        "description": "JSON object mapping custom role names 
to lists of objects with ``action`` and\n``resource`` keys, using FAB 
permission names such as ``can_read`` and ``DAGs``.\nMissing roles are created 
with their permissions during role initialization.\nExisting roles are skipped, 
preserving changes made through the UI or CLI.\nBuilt-in roles are ignored. 
Startup initialization requires ``update_fab_perms``.\nAn empty list declares 
no permissions; normal FAB init [...]
+                        "version_added": None,
+                        "type": "string",
+                        "example": '{"PythonTester": [{"action": "can_read", 
"resource": "DAGs"}], "Analyst": []}',
+                        "default": "{{}}",
+                    },
                     "auth_backends": {
                         "description": "Comma separated list of auth backends 
to authenticate users of the API.\n",
                         "version_added": "2.0.0",
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_custom_roles.py
 
b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_custom_roles.py
new file mode 100644
index 00000000000..c74c507bd70
--- /dev/null
+++ 
b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_custom_roles.py
@@ -0,0 +1,439 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+from concurrent.futures import ThreadPoolExecutor
+from threading import Barrier
+from types import SimpleNamespace
+from unittest import mock
+from uuid import uuid4
+
+import pytest
+from sqlalchemy import create_engine, event, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from airflow.providers.common.compat.sdk import AirflowConfigException, conf
+from airflow.providers.fab.auth_manager.models import (
+    Action,
+    Permission,
+    Resource,
+    Role,
+    assoc_permission_role,
+)
+from airflow.providers.fab.auth_manager.security_manager.override import 
FabAirflowSecurityManagerOverride
+
+from tests_common.test_utils.config import conf_vars
+
+
+def make_security_manager(session):
+    manager = object.__new__(FabAirflowSecurityManagerOverride)
+    manager.appbuilder = SimpleNamespace(session=session)
+    return manager
+
+
[email protected]
+def role_engine(tmp_path):
+    configured_engine = create_engine(conf.get("database", "sql_alchemy_conn"))
+    database = f"custom_roles_{uuid4().hex}"
+    if configured_engine.dialect.name == "sqlite":
+        engine = create_engine(f"sqlite:///{tmp_path / 'roles.db'}")
+    else:
+        with 
configured_engine.connect().execution_options(isolation_level="AUTOCOMMIT") as 
connection:
+            connection.exec_driver_sql(f"CREATE DATABASE {database}")
+        engine = create_engine(configured_engine.url.set(database=database))
+    try:
+        Role.metadata.create_all(
+            engine,
+            tables=[
+                Action.__table__,
+                Resource.__table__,
+                Permission.__table__,
+                Role.__table__,
+                assoc_permission_role,
+            ],
+        )
+        yield engine
+    finally:
+        engine.dispose()
+        if configured_engine.dialect.name != "sqlite":
+            with 
configured_engine.connect().execution_options(isolation_level="AUTOCOMMIT") as 
connection:
+                connection.exec_driver_sql(f"DROP DATABASE {database}")
+        configured_engine.dispose()
+
+
[email protected]
+def manager(role_engine):
+    with Session(role_engine) as session:
+        yield make_security_manager(session)
+
+
+def get_role_permissions(role):
+    return {(permission.action.name, permission.resource.name) for permission 
in role.permissions}
+
+
+class TestCustomRoleValidation:
+    @pytest.mark.parametrize(
+        "config",
+        [
+            [],
+            None,
+            False,
+            1,
+            "roles",
+            {"": []},
+            {" ": []},
+            {"x" * 65: []},
+            {"Analyst": {}},
+            {"Admin": "oops"},
+            {"Analyst": [None]},
+            {"Analyst": [{"permission": "can_read", "resource": "DAGs"}]},
+            {"Analyst": [{"action": "can_read"}]},
+            {"Analyst": [{"action": "can_read", "resource": "DAGs", "extra": 
True}]},
+            {"Analyst": [{"action": None, "resource": "DAGs"}]},
+            {"Analyst": [{"action": " ", "resource": "DAGs"}]},
+            {"Analyst": [{"action": "x" * 101, "resource": "DAGs"}]},
+            {"Analyst": [{"action": "can_read", "resource": 42}]},
+            {"Analyst": [{"action": "can_read", "resource": ""}]},
+            {"Analyst": [{"action": "can_read", "resource": "x" * 251}]},
+        ],
+    )
+    def test_invalid_config_does_not_start_creation(self, config):
+        manager = make_security_manager(mock.Mock(spec=Session))
+        with conf_vars({("fab", "custom_roles"): json.dumps(config)}):
+            with pytest.raises(AirflowConfigException, match="custom_roles"):
+                manager.create_roles_from_config()
+        assert manager.session.mock_calls == []
+
+    @conf_vars({("fab", "custom_roles"): "{"})
+    def test_invalid_json_does_not_start_creation(self):
+        manager = make_security_manager(mock.Mock(spec=Session))
+        with pytest.raises(AirflowConfigException, match="custom_roles"):
+            manager.create_roles_from_config()
+        assert manager.session.mock_calls == []
+
+    @conf_vars({("fab", "custom_roles"): '{"Valid": [], "Invalid": "oops"}'})
+    def test_validates_all_roles_before_creation(self):
+        manager = make_security_manager(mock.Mock(spec=Session))
+        with pytest.raises(AirflowConfigException, match="Invalid"):
+            manager.create_roles_from_config()
+        assert manager.session.mock_calls == []
+
+    @conf_vars({("fab", "custom_roles"): "{}"})
+    def test_empty_config_does_not_access_database(self):
+        manager = make_security_manager(mock.Mock(spec=Session))
+        manager.create_roles_from_config()
+        assert manager.session.mock_calls == []
+
+    
@mock.patch("airflow.providers.fab.auth_manager.security_manager.override.log", 
autospec=True)
+    @conf_vars({("fab", "custom_roles"): '{"Admin": [], "Viewer": [], "User": 
[], "Op": [], "Public": []}'})
+    def test_builtin_roles_are_skipped(self, mock_log):
+        manager = make_security_manager(mock.Mock(spec=Session))
+        manager.create_roles_from_config()
+        assert manager.session.mock_calls == []
+        assert mock_log.warning.call_count == 5
+
+
[email protected]_test
+class TestCustomRolePersistence:
+    @pytest.mark.parametrize("role_name", ["Analyst", "Admin", "Existing"])
+    @pytest.mark.parametrize("unknown", ["action", "resource"])
+    def test_rejects_unknown_names_before_creating_any_role(self, manager, 
role_name, unknown):
+        manager.create_permission("can_read", "DAGs")
+        manager.add_role("Existing")
+        item = {"action": "can_read", "resource": "DAGs"}
+        item[unknown] = "typo"
+        config = {"First": [], role_name: [item]}
+        with conf_vars({("fab", "custom_roles"): json.dumps(config)}):
+            with pytest.raises(AirflowConfigException, match=f"Unknown 
{unknown} 'typo'"):
+                manager.create_roles_from_config()
+        assert {role.name for role in manager.get_all_roles()} == {"Existing"}
+        assert manager.get_action("typo") is None
+        assert manager.get_resource("typo") is None
+        assert len(manager.session.scalars(select(Permission)).all()) == 1
+
+    @mock.patch.object(FabAirflowSecurityManagerOverride, "get_resource", 
autospec=True)
+    @mock.patch.object(FabAirflowSecurityManagerOverride, "get_permission", 
autospec=True)
+    @conf_vars(
+        {
+            (
+                "fab",
+                "custom_roles",
+            ): '{"Analyst": [{"action": "can_read", "resource": "DAGs"}, 
{"action": "can_read", "resource": "dags"}]}'
+        }
+    )
+    def test_deduplicates_names_resolving_to_same_permission(
+        self, mock_get_permission, mock_get_resource, manager
+    ):
+        permission = Permission(action=Action(name="can_read"), 
resource=Resource(name="DAGs"))
+        manager.session.add(permission)
+        manager.session.commit()
+        mock_get_permission.return_value = permission
+        mock_get_resource.return_value = permission.resource
+        manager.create_roles_from_config()
+        assert get_role_permissions(manager.find_role("Analyst")) == 
{("can_read", "DAGs")}
+        assert 
len(manager.session.execute(select(assoc_permission_role)).all()) == 1
+
+    @pytest.mark.parametrize("preexisting_permission", [False, True])
+    @conf_vars(
+        {
+            (
+                "fab",
+                "custom_roles",
+            ): '{"Analyst": [{"action": "can_read", "resource": "DAGs"}, 
{"action": "can_read", "resource": "DAGs"}], "Empty": []}'
+        }
+    )
+    def test_creates_roles_and_reuses_permissions(self, manager, 
preexisting_permission):
+        manager.session.add_all([Action(name="can_read"), 
Resource(name="DAGs")])
+        manager.session.commit()
+        if preexisting_permission:
+            manager.create_permission("can_read", "DAGs")
+        else:
+            assert manager.get_permission("can_read", "DAGs") is None
+        manager.create_roles_from_config()
+        manager.create_roles_from_config()
+        assert get_role_permissions(manager.find_role("Analyst")) == 
{("can_read", "DAGs")}
+        assert manager.find_role("Empty").permissions == []
+        assert len(manager.session.scalars(select(Permission)).all()) == 1
+        assert manager.session.scalars(select(Action.name)).all() == 
["can_read"]
+        assert manager.session.scalars(select(Resource.name)).all() == ["DAGs"]
+        assert len(manager.get_all_roles()) == 2
+
+    @conf_vars({("fab", "custom_roles"): '{"Analyst": [{"action": "can_read", 
"resource": "DAGs"}]}'})
+    def test_preserves_manual_permission_changes(self, manager):
+        manager.create_permission("can_read", "DAGs")
+        manager.create_roles_from_config()
+        role = manager.find_role("Analyst")
+        replacement = manager.create_permission("can_edit", "Connections")
+        role.permissions = [replacement]
+        manager.session.commit()
+        manager.create_roles_from_config()
+        assert get_role_permissions(manager.find_role("Analyst")) == 
{("can_edit", "Connections")}
+
+    @conf_vars({("fab", "custom_roles"): '{"Analyst": "oops"}'})
+    def test_validates_existing_role(self, manager):
+        manager.add_role("Analyst")
+        with pytest.raises(AirflowConfigException, match="Analyst"):
+            manager.create_roles_from_config()
+        assert manager.find_role("Analyst").permissions == []
+
+    @pytest.mark.parametrize("failure_stage", ["permission", "commit"])
+    @conf_vars(
+        {
+            (
+                "fab",
+                "custom_roles",
+            ): '{"Completed": [], "Analyst": [{"action": "custom_action", 
"resource": "Custom resource"}]}'
+        }
+    )
+    def test_rolls_back_failed_role_and_new_permissions(self, manager, 
role_engine, failure_stage):
+        manager.session.add_all([Action(name="custom_action"), 
Resource(name="Custom resource")])
+        manager.session.commit()
+
+        def fail_permission(session, flush_context, instances):
+            if any(isinstance(item, Permission) for item in session.new):
+                raise RuntimeError("Permission storage failed")
+
+        def fail_commit(session):
+            if manager.find_role("Analyst") is not None:
+                raise RuntimeError("Permission storage failed")
+
+        event_name, listener = (
+            ("before_flush", fail_permission)
+            if failure_stage == "permission"
+            else ("before_commit", fail_commit)
+        )
+        event.listen(manager.session, event_name, listener)
+        try:
+            with pytest.raises(RuntimeError, match="Permission storage 
failed"):
+                manager.create_roles_from_config()
+        finally:
+            event.remove(manager.session, event_name, listener)
+        with Session(role_engine) as observer:
+            assert observer.scalars(select(Role.name)).all() == ["Completed"]
+            assert observer.scalars(select(Action.name)).all() == 
["custom_action"]
+            assert observer.scalars(select(Resource.name)).all() == ["Custom 
resource"]
+            assert observer.scalars(select(Permission)).all() == []
+            assert observer.execute(select(assoc_permission_role)).all() == []
+        manager.create_roles_from_config()
+        assert get_role_permissions(manager.find_role("Analyst")) == 
{("custom_action", "Custom resource")}
+
+    @conf_vars({("fab", "custom_roles"): '{"Analyst": [{"action": "can_read", 
"resource": "DAGs"}]}'})
+    def test_rollback_preserves_preexisting_permission(self, manager, 
role_engine):
+        manager.create_permission("can_read", "DAGs")
+
+        def fail_commit(session):
+            raise RuntimeError("Connection storage failed")
+
+        event.listen(manager.session, "before_commit", fail_commit)
+        try:
+            with pytest.raises(RuntimeError, match="Connection storage 
failed"):
+                manager.create_roles_from_config()
+        finally:
+            event.remove(manager.session, "before_commit", fail_commit)
+        with Session(role_engine) as observer:
+            assert observer.scalars(select(Role)).all() == []
+            assert len(observer.scalars(select(Permission)).all()) == 1
+
+    @pytest.mark.parametrize("iteration", range(5))
+    def test_concurrent_role_creation_preserves_winner_permissions(self, 
role_engine, iteration):
+        with Session(role_engine) as session:
+            session.add_all([Action(name="can_read"), Action(name="can_edit"), 
Resource(name="DAGs")])
+            session.commit()
+        barrier = Barrier(2, timeout=10)
+        original_find_role = FabAirflowSecurityManagerOverride.find_role
+
+        def create_role(action):
+            with Session(role_engine) as session:
+                manager = make_security_manager(session)
+                first_lookup = True
+
+                def find_role(name):
+                    nonlocal first_lookup
+                    role = original_find_role(manager, name)
+                    if first_lookup:
+                        first_lookup = False
+                        assert role is None
+                        barrier.wait()
+                    return role
+
+                with mock.patch.object(manager, "find_role", autospec=True, 
side_effect=find_role):
+                    manager._create_role_from_config("Analyst", [(action, 
"DAGs")])
+
+        with ThreadPoolExecutor(max_workers=2) as executor:
+            futures = [executor.submit(create_role, action) for action in 
("can_read", "can_edit")]
+            for future in futures:
+                future.result(timeout=20)
+        with Session(role_engine) as observer:
+            roles = observer.scalars(select(Role)).unique().all()
+            assert len(roles) == 1
+            assert get_role_permissions(roles[0]) in ({("can_read", "DAGs")}, 
{("can_edit", "DAGs")})
+
+    def test_concurrent_roles_reuse_shared_permission(self, role_engine):
+        if role_engine.dialect.name == "sqlite":
+            pytest.skip("SQLite serializes writers before they can race on 
shared permissions")
+        with Session(role_engine) as session:
+            session.add_all([Action(name="can_read"), Resource(name="DAGs")])
+            session.commit()
+        barrier = Barrier(2, timeout=10)
+        method = "get_permission"
+        original_lookup = getattr(FabAirflowSecurityManagerOverride, method)
+
+        def create_role(name):
+            with Session(role_engine) as session:
+                manager = make_security_manager(session)
+                first_lookup = True
+
+                def lookup(*args):
+                    nonlocal first_lookup
+                    result = original_lookup(manager, *args)
+                    if first_lookup:
+                        first_lookup = False
+                        assert result is None
+                        barrier.wait()
+                    return result
+
+                with mock.patch.object(manager, method, autospec=True, 
side_effect=lookup):
+                    manager._create_role_from_config(name, [("can_read", 
"DAGs")])
+
+        with ThreadPoolExecutor(max_workers=2) as executor:
+            futures = [executor.submit(create_role, name) for name in 
("Analyst", "Tester")]
+            for future in futures:
+                future.result(timeout=20)
+        with Session(role_engine) as observer:
+            roles = observer.scalars(select(Role)).unique().all()
+            assert {role.name for role in roles} == {"Analyst", "Tester"}
+            assert all(get_role_permissions(role) == {("can_read", "DAGs")} 
for role in roles)
+            assert len(observer.scalars(select(Permission)).all()) == 1
+
+
+class TestCustomRoleRetries:
+    def test_retries_shared_permission_conflict(self):
+        session = mock.Mock(spec=Session)
+        manager = make_security_manager(session)
+        error = IntegrityError("insert", {}, Exception("duplicate"))
+        session.flush.side_effect = [None, error, None]
+        action = Action(id=1, name="can_read")
+        resource = Resource(id=2, name="DAGs")
+        permission = Permission(id=3, action=action, resource=resource)
+        with (
+            mock.patch.object(manager, "find_role", autospec=True, 
return_value=None),
+            mock.patch.object(
+                manager,
+                "get_action",
+                autospec=True,
+                return_value=action,
+            ),
+            mock.patch.object(
+                manager,
+                "get_resource",
+                autospec=True,
+                return_value=resource,
+            ),
+            mock.patch.object(
+                manager,
+                "get_permission",
+                autospec=True,
+                side_effect=[None, permission, permission],
+            ),
+        ):
+            manager._create_role_from_config("Analyst", [("can_read", "DAGs")])
+        session.rollback.assert_called_once()
+        session.commit.assert_called_once()
+        roles = [call.args[0] for call in session.add.call_args_list if 
isinstance(call.args[0], Role)]
+        assert len(roles) == 2
+        assert roles[0] is not roles[1]
+        assert roles[1].permissions == [permission]
+
+    @pytest.mark.parametrize("shared_object_appeared", [False, True])
+    def test_propagates_unexplained_or_exhausted_conflicts(self, 
shared_object_appeared):
+        session = mock.Mock(spec=Session)
+        manager = make_security_manager(session)
+        error = IntegrityError("insert", {}, Exception("failure"))
+        session.flush.side_effect = [None, error] * 3
+        with (
+            mock.patch.object(manager, "find_role", autospec=True, 
return_value=None),
+            mock.patch.object(
+                manager,
+                "get_permission",
+                autospec=True,
+                side_effect=[None, Permission() if shared_object_appeared else 
None] * 3,
+            ),
+            mock.patch.object(
+                manager,
+                "get_action",
+                autospec=True,
+                return_value=Action(name="can_read"),
+            ),
+            mock.patch.object(manager, "get_resource", autospec=True, 
return_value=Resource(name="DAGs")),
+        ):
+            with pytest.raises(IntegrityError) as raised:
+                manager._create_role_from_config("Analyst", [("can_read", 
"DAGs")])
+        assert raised.value is error
+        assert session.rollback.call_count == (3 if shared_object_appeared 
else 1)
+        session.commit.assert_not_called()
+
+    def test_does_not_retry_unrelated_role_insert_error(self):
+        session = mock.Mock(spec=Session)
+        session.flush.side_effect = IntegrityError("insert", {}, 
Exception("failure"))
+        manager = make_security_manager(session)
+        with mock.patch.object(manager, "find_role", autospec=True, 
return_value=None):
+            with pytest.raises(IntegrityError):
+                manager._create_role_from_config("Analyst", [])
+        session.rollback.assert_called_once()
+        session.commit.assert_not_called()
diff --git a/providers/fab/tests/unit/fab/auth_manager/test_security.py 
b/providers/fab/tests/unit/fab/auth_manager/test_security.py
index f2520fbd884..18d855ce61a 100644
--- a/providers/fab/tests/unit/fab/auth_manager/test_security.py
+++ b/providers/fab/tests/unit/fab/auth_manager/test_security.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 import contextlib
 import datetime
+import json
 import logging
 from typing import TYPE_CHECKING
 from unittest import mock
@@ -264,6 +265,59 @@ def session(app_builder):
     return app_builder.session
 
 
+class TestDeclarativeCustomRoles:
+    @pytest.fixture(autouse=True)
+    def cleanup_configured_role(self, security_manager):
+        yield
+        security_manager.session.rollback()
+        if security_manager.find_role("ConfigAnalyst") is not None:
+            security_manager.delete_role("ConfigAnalyst")
+
+    @pytest.mark.parametrize("update_permissions", [True, False])
+    @mock.patch.object(FabAuthManager, "security_manager", 
new_callable=mock.PropertyMock)
+    def test_startup_creates_role_only_when_permission_updates_enabled(
+        self, mock_security_manager, security_manager, update_permissions
+    ):
+        mock_security_manager.return_value = security_manager
+        with conf_vars(
+            {
+                ("fab", "update_fab_perms"): str(update_permissions),
+                ("fab", "custom_roles"): json.dumps(
+                    {"ConfigAnalyst": [{"action": "can_read", "resource": 
"DAGs"}]}
+                ),
+            }
+        ):
+            FabAuthManager()._sync_appbuilder_roles()
+        role = security_manager.find_role("ConfigAnalyst")
+        if update_permissions:
+            assert {(perm.action.name, perm.resource.name) for perm in 
role.permissions} == {
+                ("can_read", "DAGs"),
+                ("can_read", "Website"),
+            }
+        else:
+            assert role is None
+
+    @conf_vars({("fab", "update_fab_perms"): "False", ("fab", "custom_roles"): 
'{"ConfigAnalyst": []}'})
+    def test_explicit_sync_creates_role_and_adds_homepage_permission(self, 
security_manager):
+        security_manager.sync_roles()
+        role = security_manager.find_role("ConfigAnalyst")
+        assert {(perm.action.name, perm.resource.name) for perm in 
role.permissions} == {
+            ("can_read", "Website")
+        }
+
+    @conf_vars({("fab", "custom_roles"): '{"ConfigAnalyst": [{"action": 
"can_read", "resource": "DAGs"}]}'})
+    def 
test_sync_preserves_existing_role_without_adding_configured_permissions(self, 
security_manager):
+        role = security_manager.add_role("ConfigAnalyst")
+        permission = security_manager.create_permission("can_edit", "DAGs")
+        security_manager.add_permission_to_role(role, permission)
+        security_manager.sync_roles()
+        role = security_manager.find_role("ConfigAnalyst")
+        assert {(perm.action.name, perm.resource.name) for perm in 
role.permissions} == {
+            ("can_edit", "DAGs"),
+            ("can_read", "Website"),
+        }
+
+
 @pytest.fixture
 def role(request, app, security_manager):
     params = request.param

Reply via email to