"""Unit tests for services.enterprise.rbac_service. Most enterprise RBAC methods turn a single ``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response model. Rather than spinning up an HTTP server, these tests monkeypatch that helper and assert on the request arguments and response shape. The legacy fallbacks use SQLite to verify their database reads and committed role updates. """ from __future__ import annotations from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from flask import Flask from sqlalchemy import select from sqlalchemy.orm import Session from models import TenantAccountJoin from services.enterprise import rbac_service as svc MODULE = "services.enterprise.rbac_service" @pytest.fixture def mock_send(): with patch(f"{MODULE}.EnterpriseRequest.send_inner_rbac_request") as send: yield send def _call_args(send: MagicMock) -> SimpleNamespace: """Return the most recent (method, endpoint, kwargs) sent to the mock.""" send.assert_called_once() args, kwargs = send.call_args return SimpleNamespace(method=args[0], endpoint=args[1], **kwargs) class TestCatalog: def test_workspace_catalog(self, mock_send: MagicMock): mock_send.return_value = {"groups": [{"group_key": "workspace", "group_name": "工作空间", "permissions": []}]} out = svc.RBACService.Catalog.workspace("tenant-1", account_id="acct-1") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/role-permissions/catalog" assert call.tenant_id == "tenant-1" assert call.account_id == "acct-1" assert call.json is None assert call.params is None assert len(out.groups) == 1 assert out.groups[0].group_key == "workspace" def test_app_catalog_endpoint(self, mock_send: MagicMock): mock_send.return_value = {"groups": []} svc.RBACService.Catalog.app("tenant-1") assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/app" def test_dataset_catalog_endpoint(self, mock_send: MagicMock): mock_send.return_value = {"groups": []} svc.RBACService.Catalog.dataset("tenant-1") assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/dataset" class TestRoles: def test_list_forwards_pagination_options(self, mock_send: MagicMock): mock_send.return_value = { "data": [ { "id": "role-1", "tenant_id": "tenant-1", "type": "workspace", "category": "global_custom", "name": "Owner", "permission_keys": ["workspace.member.manage"], } ], "pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1}, } out = svc.RBACService.Roles.list( "tenant-1", "acct-1", options=svc.ListOption(page_number=2, results_per_page=50, reverse=True), ) call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/roles" assert call.params == { "dataset_operator_enabled": False, "page_number": 2, "results_per_page": 50, "reverse": "true", } assert out.pagination assert out.pagination.total_count == 1 def test_list_omits_params_when_default(self, mock_send: MagicMock): mock_send.return_value = {"data": [], "pagination": None} svc.RBACService.Roles.list("tenant-1") assert _call_args(mock_send).params is not None def test_list_forwards_include_owner(self, mock_send: MagicMock): mock_send.return_value = {"data": [], "pagination": None} svc.RBACService.Roles.list("tenant-1", include_owner=1) assert _call_args(mock_send).params == {"dataset_operator_enabled": False, "include_owner": 1} def test_list_coerces_null_permission_keys(self, mock_send: MagicMock): mock_send.return_value = { "data": [ { "id": "role-1", "tenant_id": "tenant-1", "type": "workspace", "category": "global_custom", "name": "Owner", "permission_keys": None, } ], "pagination": None, } out = svc.RBACService.Roles.list("tenant-1") assert out.data[0].permission_keys == [] def test_get_passes_id_query_param(self, mock_send: MagicMock): mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"} svc.RBACService.Roles.get("tenant-1", "acct-1", "role-1") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/roles/item" assert call.params == {"id": "role-1"} def test_members_forwards_role_id_and_pagination(self, mock_send: MagicMock): mock_send.return_value = { "role_id": "role-1", "data": [{"account_id": "acct-2", "account_name": "Alice"}], "pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1}, } out = svc.RBACService.Roles.members( "tenant-1", "acct-1", "role-1", options=svc.ListOption(page_number=1, results_per_page=20), ) call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/roles/members" assert call.params == {"page_number": 1, "results_per_page": 20, "role_id": "role-1"} assert out.data[0].account_id == "acct-2" assert out.data[0].account_name == "Alice" assert out.pagination is not None assert out.pagination.total_count == 1 def test_create_sends_body(self, mock_send: MagicMock): mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"} payload = svc.RoleMutation(name="Owner", description="full access", permission_keys=["workspace.member.manage"]) svc.RBACService.Roles.create("tenant-1", "acct-1", payload) call = _call_args(mock_send) assert call.method == "POST" assert call.endpoint == "/rbac/roles" assert call.json == { "name": "Owner", "description": "full access", "permission_keys": ["workspace.member.manage"], "type": "workspace", } def test_update_sends_id_param_and_body(self, mock_send: MagicMock): mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"} payload = svc.RoleMutation(name="Owner", permission_keys=["x"]) svc.RBACService.Roles.update("tenant-1", "acct-1", "role-1", payload) call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/roles/item" assert call.params == {"id": "role-1"} assert call.json == {"name": "Owner", "description": "", "permission_keys": ["x"], "type": "workspace"} def test_delete_uses_delete_method(self, mock_send: MagicMock): mock_send.return_value = {"message": "success"} svc.RBACService.Roles.delete("tenant-1", None, "role-1") call = _call_args(mock_send) assert call.method == "DELETE" assert call.endpoint == "/rbac/roles/item" assert call.params == {"id": "role-1"} assert call.account_id is None def test_copy_sends_post_with_id_param(self, mock_send: MagicMock): mock_send.return_value = {"id": "role-1-copy", "type": "workspace", "name": "Owner copy"} svc.RBACService.Roles.copy("tenant-1", "acct-1", "role-1") call = _call_args(mock_send) assert call.method == "POST" assert call.endpoint == "/rbac/roles/copy" assert call.params == {"id": "role-1"} assert call.account_id == "acct-1" class TestAccessPolicyBindings: def test_lock_sends_put_with_binding_id(self, mock_send: MagicMock): mock_send.return_value = {"binding_id": "binding-1", "is_locked": True} out = svc.RBACService.AccessPolicyBindings.lock("tenant-1", "acct-1", "binding-1") call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/access-policy-bindings/lock" assert call.json == {"binding_id": "binding-1"} assert out.binding_id == "binding-1" assert out.is_locked is True def test_unlock_sends_put_with_binding_id(self, mock_send: MagicMock): mock_send.return_value = {"binding_id": "binding-1", "is_locked": False} out = svc.RBACService.AccessPolicyBindings.unlock("tenant-1", "acct-1", "binding-1") call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/access-policy-bindings/unlock" assert call.json == {"binding_id": "binding-1"} assert out.binding_id == "binding-1" assert out.is_locked is False class TestAccessPolicies: def test_list_filters_by_resource_type(self, mock_send: MagicMock): mock_send.return_value = {"data": [], "pagination": None} svc.RBACService.AccessPolicies.list( "tenant-1", "acct-1", resource_type=svc.RBACResourceType.APP, options=svc.ListOption(page_number=1), ) call = _call_args(mock_send) assert call.endpoint == "/rbac/access-policies" assert call.params == {"page_number": 1, "resource_type": "app"} def test_copy_sends_post_with_id_param(self, mock_send: MagicMock): mock_send.return_value = { "id": "policy-1-copy", "resource_type": "app", "name": "Full access copy", } svc.RBACService.AccessPolicies.copy("tenant-1", "acct-1", "policy-1") call = _call_args(mock_send) assert call.method == "POST" assert call.endpoint == "/rbac/access-policies/copy" assert call.params == {"id": "policy-1"} def test_create_serialises_resource_type_enum(self, mock_send: MagicMock): mock_send.return_value = {"id": "policy-1", "resource_type": "dataset", "name": "KB only"} payload = svc.AccessPolicyCreate( name="KB only", resource_type=svc.RBACResourceType.DATASET, permission_keys=["dataset.acl.readonly"], ) svc.RBACService.AccessPolicies.create("tenant-1", "acct-1", payload) call = _call_args(mock_send) assert call.method == "POST" assert call.json == { "name": "KB only", "resource_type": "dataset", "description": "", "permission_keys": ["dataset.acl.readonly"], } class TestResourceAccess: def test_app_whitelist_resources(self, mock_send: MagicMock): mock_send.return_value = {"unrestricted": True, "resource_ids": ["app-1", "app-2"]} out = svc.RBACService.AppAccess.whitelist_resources("tenant-1", "acct-1") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/apps/whitelist/resources" assert call.params is None assert out.unrestricted is True assert out.resource_ids == ["app-1", "app-2"] def test_dataset_whitelist_resources(self, mock_send: MagicMock): mock_send.return_value = {"resource_ids": ["dataset-1"]} out = svc.RBACService.DatasetAccess.whitelist_resources("tenant-1", "acct-1") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/datasets/whitelist/resources" assert call.params is None assert out.resource_ids == ["dataset-1"] def test_app_user_access_policies(self, mock_send: MagicMock): mock_send.return_value = { "scope": "specific", "data": [ { "account": {"account_id": "acct-1", "account_name": "Alice"}, "roles": [ { "id": "role-1", "type": "workspace", "name": "Editor", "permission_keys": [], } ], "access_policies": [ { "id": "policy-1", "resource_type": "app", "name": "Can edit", } ], } ], } out = svc.RBACService.AppAccess.user_access_policies("tenant-1", "acct-1", "app-1") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/apps/user-access-policies" assert call.params == {"app_id": "app-1"} assert out.data[0].account.account_name == "Alice" assert out.data[0].roles[0].id == "role-1" assert out.data[0].access_policies[0].id == "policy-1" def test_dataset_replace_user_access_policies(self, mock_send: MagicMock): mock_send.return_value = { "access_policies": [{"id": "policy-1", "resource_type": "dataset", "name": "Can edit"}] } payload = svc.ReplaceUserAccessPolicies(access_policy_ids=["policy-1"]) out = svc.RBACService.DatasetAccess.replace_user_access_policies( "tenant-1", "acct-actor", "dataset-1", "acct-target", payload ) call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/datasets/user-access-policies" assert call.params == {"dataset_id": "dataset-1", "account_id": "acct-target"} assert call.json == {"access_policy_ids": ["policy-1"]} assert out.access_policies[0].id == "policy-1" def test_dataset_whitelist(self, mock_send: MagicMock): mock_send.return_value = {"account_ids": ["acct-2"]} out = svc.RBACService.DatasetAccess.whitelist("tenant-1", "acct-1", "dataset-1") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/datasets/whitelist" assert call.params == {"dataset_id": "dataset-1"} assert out.account_ids == ["acct-2"] def test_app_matrix(self, mock_send: MagicMock): mock_send.return_value = {"resource_id": "app-1", "items": []} out = svc.RBACService.AppAccess.matrix("tenant-1", "acct-1", "app-1") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/apps/access-policy" assert call.params == {"app_id": "app-1"} assert out.app_id == "app-1" def test_dataset_matrix(self, mock_send: MagicMock): mock_send.return_value = {"resource_id": "dataset-1", "items": []} out = svc.RBACService.DatasetAccess.matrix("tenant-1", "acct-1", "dataset-1") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/datasets/access-policy" assert call.params == {"dataset_id": "dataset-1"} assert out.dataset_id == "dataset-1" def test_app_role_bindings_preserve_role_name(self, mock_send: MagicMock): mock_send.return_value = { "data": [ { "id": "binding-1", "tenant_id": "tenant-1", "access_policy_id": "policy-1", "resource_type": "app", "resource_id": "app-1", "role_id": "role-1", "role_name": "Owner", } ] } out = svc.RBACService.AppAccess.list_role_bindings("tenant-1", "acct-1", "app-1", "policy-1") assert out.data[0].role_name == "Owner" def test_app_member_bindings_preserve_account_name(self, mock_send: MagicMock): mock_send.return_value = { "data": [ { "id": "binding-1", "tenant_id": "tenant-1", "access_policy_id": "policy-1", "resource_type": "app", "resource_id": "app-1", "account_id": "acct-1", "account_name": "Alice", } ] } out = svc.RBACService.AppAccess.list_member_bindings("tenant-1", "acct-1", "app-1", "policy-1") assert out.data[0].account_name == "Alice" def test_app_delete_member_bindings_uses_delete_method(self, mock_send: MagicMock): mock_send.return_value = None payload = svc.DeleteMemberBindings(account_ids=["acct-2", "acct-3"]) svc.RBACService.AppAccess.delete_member_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload) call = _call_args(mock_send) assert call.method == "DELETE" assert call.endpoint == "/rbac/apps/access-policy/member-bindings" assert call.params == {"app_id": "app-1", "policy_id": "policy-1"} assert call.json == {"account_ids": ["acct-2", "acct-3"]} def test_app_replace_bindings(self, mock_send: MagicMock): mock_send.return_value = {"data": []} payload = svc.ReplaceBindings(role_ids=["workspace.owner"], account_ids=["acct-2"]) svc.RBACService.AppAccess.replace_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload) call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/apps/access-policy/bindings" assert call.params == {"app_id": "app-1", "policy_id": "policy-1"} assert call.json == {"role_ids": ["workspace.owner"], "account_ids": ["acct-2"]} def test_dataset_replace_bindings(self, mock_send: MagicMock): mock_send.return_value = {"data": []} payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"]) svc.RBACService.DatasetAccess.replace_bindings("tenant-1", "acct-1", "ds-1", "policy-1", payload) call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/datasets/access-policy/bindings" assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"} assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]} def test_dataset_delete_member_bindings_uses_delete_method(self, mock_send: MagicMock): mock_send.return_value = None payload = svc.DeleteMemberBindings(account_ids=["acct-2"]) svc.RBACService.DatasetAccess.delete_member_bindings("tenant-1", "acct-1", "ds-1", "policy-1", payload) call = _call_args(mock_send) assert call.method == "DELETE" assert call.endpoint == "/rbac/datasets/access-policy/member-bindings" assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"} assert call.json == {"account_ids": ["acct-2"]} class TestWorkspaceAccess: def test_app_matrix(self, mock_send: MagicMock): mock_send.return_value = { "items": [], "pagination": {"total_count": 1, "per_page": 20, "current_page": 2, "total_pages": 1}, } out = svc.RBACService.WorkspaceAccess.app_matrix( "tenant-1", options=svc.ListOption(page_number=2, results_per_page=20), ) call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/workspace/apps/access-policy" assert call.params == {"page_number": 2, "results_per_page": 20} assert out.pagination assert out.pagination.current_page == 2 def test_dataset_matrix(self, mock_send: MagicMock): mock_send.return_value = {"items": []} svc.RBACService.WorkspaceAccess.dataset_matrix("tenant-1") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/workspace/datasets/access-policy" assert call.params is None def test_workspace_matrix_coerces_null_bindings(self, mock_send: MagicMock): mock_send.return_value = { "items": [ { "policy": { "id": "policy-1", "resource_type": "app", "name": "Workspace App Access", }, "roles": None, "accounts": None, } ], "pagination": None, } out = svc.RBACService.WorkspaceAccess.app_matrix("tenant-1") assert out.items[0].roles == [] assert out.items[0].accounts == [] def test_workspace_app_replace_bindings(self, mock_send: MagicMock): mock_send.return_value = {"data": []} payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"]) svc.RBACService.WorkspaceAccess.replace_app_bindings("tenant-1", "acct-1", "policy-1", payload) call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/workspace/apps/access-policy/bindings" assert call.params == {"policy_id": "policy-1"} assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]} def test_workspace_dataset_replace_bindings(self, mock_send: MagicMock): mock_send.return_value = {"data": []} payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"]) svc.RBACService.WorkspaceAccess.replace_dataset_bindings("tenant-1", "acct-1", "policy-1", payload) call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/workspace/datasets/access-policy/bindings" assert call.params == {"policy_id": "policy-1"} assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]} def test_workspace_app_matrix_forwards_language_query_param(self, mock_send: MagicMock): mock_send.return_value = {"items": [], "pagination": None} app = Flask(__name__) with app.test_request_context("/?language=en"): svc.RBACService.WorkspaceAccess.app_matrix("tenant-1") call = _call_args(mock_send) assert call.params == {"language": "en"} @pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True) class TestMyPermissions: def test_resource_snapshot_maps_defaults_and_overrides(self, sqlite_session: Session): snapshot = svc.ResourcePermissionSnapshot( default_permission_keys=["app.acl.view_layout"], overrides=[ svc.ResourcePermissionKeys( resource_id="app-2", permission_keys=["app.acl.view_layout", "app.acl.edit"], ) ], ) assert snapshot.permission_keys_by_resource_ids(["app-1", "app-2"]) == { "app-1": ["app.acl.view_layout"], "app-2": ["app.acl.view_layout", "app.acl.edit"], } def test_get_without_payload_uses_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "workspace": {"permission_keys": ["workspace.member.manage"]}, "app": {"default_permission_keys": ["app.acl.view_layout", "app.acl.test_and_run"], "overrides": []}, "dataset": {"default_permission_keys": [], "overrides": []}, } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/my-permissions" assert call.json is None assert call.params is None assert out.workspace.permission_keys == ["workspace.member.manage"] @pytest.mark.parametrize( ("role", "workspace_keys", "app_keys", "dataset_keys"), [ ( "owner", svc._LEGACY_WORKSPACE_OWNER_KEYS, svc._LEGACY_APP_OWNER_KEYS, svc._LEGACY_DATASET_OWNER_KEYS, ), ( "admin", svc._LEGACY_WORKSPACE_ADMIN_KEYS, svc._LEGACY_APP_ADMIN_KEYS, svc._LEGACY_DATASET_ADMIN_KEYS, ), ( "editor", svc._LEGACY_WORKSPACE_EDITOR_KEYS, svc._LEGACY_APP_EDITOR_KEYS, svc._LEGACY_DATASET_EDITOR_KEYS, ), ( "normal", svc._LEGACY_WORKSPACE_NORMAL_KEYS, svc._LEGACY_APP_NORMAL_KEYS, [], ), ( "dataset_operator", svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, [], svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, ), ], ) def test_get_uses_legacy_role_permissions_when_rbac_disabled( self, mock_send: MagicMock, role: str, workspace_keys: list[str], app_keys: list[str], dataset_keys: list[str], sqlite_session: Session, ): sqlite_session.add( TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole(role)) ) sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) mock_send.assert_not_called() assert out.workspace.permission_keys == workspace_keys assert len(out.workspace.permission_keys) == len(set(out.workspace.permission_keys)) assert out.app.default_permission_keys == app_keys assert out.dataset.default_permission_keys == dataset_keys assert out.app.overrides == [] assert out.dataset.overrides == [] if role == "owner": assert "snippets.management" in out.workspace.permission_keys assert "app.acl.preview" in out.workspace.permission_keys assert "dataset.acl.preview" in out.workspace.permission_keys assert "app.acl.preview" in out.app.default_permission_keys assert "dataset.acl.preview" in out.dataset.default_permission_keys assert not any(key.startswith("billing.") for key in out.workspace.permission_keys) if role == "editor": assert "app.acl.log_and_annotation" in out.app.default_permission_keys @pytest.mark.parametrize( ("role", "expected_snippet_keys"), [ ("owner", {"snippets.create_and_modify", "snippets.management"}), ("admin", {"snippets.create_and_modify", "snippets.management"}), ("editor", {"snippets.create_and_modify"}), ("normal", set()), ("dataset_operator", set()), ], ) def test_get_uses_legacy_snippet_permissions_when_rbac_disabled( self, mock_send: MagicMock, role: str, expected_snippet_keys: set[str], sqlite_session: Session, ): sqlite_session.add( TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole(role)) ) sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) actual_snippet_keys = { permission_key for permission_key in out.workspace.permission_keys if permission_key.startswith("snippets.") } mock_send.assert_not_called() assert actual_snippet_keys == expected_snippet_keys def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock, sqlite_session: Session): with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) mock_send.assert_not_called() assert out.workspace.permission_keys == [] assert out.app.default_permission_keys == [] assert out.dataset.default_permission_keys == [] def test_get_with_single_resource_filters(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "workspace": {"permission_keys": []}, "app": { "default_permission_keys": [], "overrides": [{"resource_id": "app-1", "permission_keys": ["app.acl.edit"]}], }, "dataset": {"default_permission_keys": [], "overrides": []}, } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1", session=sqlite_session) call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/my-permissions" assert call.params == {"app_id": "app-1"} assert out.app.overrides[0].resource_id == "app-1" @pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True) class TestMemberRoles: def test_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "account_id": "acct-2", "roles": [ { "id": "role-1", "type": "workspace", "name": "Member", } ], } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=sqlite_session) call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/members/rbac-roles" assert call.params == {"account_id": "acct-2"} assert out.account_id == "acct-2" assert out.roles[0].name == "Member" def test_get_legacy_role_includes_permission_keys(self, mock_send: MagicMock, sqlite_session: Session): sqlite_session.add( TenantAccountJoin(tenant_id="tenant-1", account_id="acct-2", role=svc.TenantAccountRole.EDITOR) ) sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=sqlite_session) mock_send.assert_not_called() assert out.account_id == "acct-2" assert out.roles[0].name == "editor" assert out.roles[0].permission_keys == list( dict.fromkeys( [ *svc._LEGACY_WORKSPACE_EDITOR_KEYS, *svc._LEGACY_APP_EDITOR_KEYS, *svc._LEGACY_DATASET_EDITOR_KEYS, ] ) ) assert "snippets.create_and_modify" in out.roles[0].permission_keys assert "app.acl.preview" in out.roles[0].permission_keys assert "dataset.acl.preview" in out.roles[0].permission_keys def test_replace(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = {"account_id": "acct-2", "roles": []} with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): svc.RBACService.MemberRoles.replace( "tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"], session=sqlite_session, ) call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/members/rbac-roles" assert call.params == {"account_id": "acct-2"} assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]} def test_replace_commits_legacy_join_role_when_rbac_disabled(self, mock_send: MagicMock, sqlite_session: Session): target_join = TenantAccountJoin(tenant_id="tenant-1", account_id="acct-2", role=svc.TenantAccountRole.NORMAL) sqlite_session.add(target_join) sqlite_session.commit() target_join_id = target_join.id engine = sqlite_session.get_bind() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MemberRoles.replace( "tenant-1", "acct-1", "acct-2", role_ids=["editor"], session=sqlite_session ) mock_send.assert_not_called() # Closing the writer rolls back any uncommitted update and prevents its identity map # from satisfying the verification query. sqlite_session.close() with Session(engine) as verification_session: persisted_join = verification_session.scalar( select(TenantAccountJoin).where(TenantAccountJoin.id == target_join_id) ) assert persisted_join is not None assert persisted_join.role == svc.TenantAccountRole.EDITOR assert out.account_id == "acct-2" assert out.roles[0].id == "editor" assert "app.acl.preview" in out.roles[0].permission_keys def test_replace_legacy_owner_demotes_current_owner_when_rbac_disabled( self, mock_send: MagicMock, sqlite_session: Session ): target_join = TenantAccountJoin(tenant_id="tenant-1", account_id="acct-2", role=svc.TenantAccountRole.NORMAL) owner_join = TenantAccountJoin(tenant_id="tenant-1", account_id="acct-owner", role=svc.TenantAccountRole.OWNER) sqlite_session.add_all([target_join, owner_join]) sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MemberRoles.replace( "tenant-1", "acct-1", "acct-2", role_ids=["owner"], session=sqlite_session ) mock_send.assert_not_called() persisted_joins = { join.account_id: join.role for join in sqlite_session.scalars( select(TenantAccountJoin).where(TenantAccountJoin.tenant_id == "tenant-1") ) } assert persisted_joins == { "acct-2": svc.TenantAccountRole.OWNER, "acct-owner": svc.TenantAccountRole.ADMIN, } assert out.roles[0].id == "owner" def test_batch_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "acct-2": [ {"id": "role-1", "name": "Admin", "type": "workspace"}, {"id": "role-2", "name": "Editor", "type": "workspace"}, ], "acct-3": [], } out = svc.RBACService.MemberRoles.batch_get("tenant-1", "acct-1", ["acct-2", "acct-3"]) call = _call_args(mock_send) assert call.method == "POST" assert call.endpoint == "/rbac/members/rbac-roles/batch" assert call.json == {"member_ids": ["acct-2", "acct-3"]} assert out[0].account_id == "acct-2" assert len(out[0].roles) == 2 assert out[1].account_id == "acct-3" assert out[1].roles == [] @pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True) class TestResourcePermissions: def test_app_permissions_batch_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "data": [ {"resource_id": "app-1", "permission_keys": ["app.acl.view_layout", "app.acl.edit"]}, {"resource_id": "app-2", "permission_keys": []}, ] } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): out = svc.RBACService.AppPermissions.batch_get( "tenant-1", "acct-1", ["app-1", "app-2"], session=sqlite_session ) call = _call_args(mock_send) assert call.method == "POST" assert call.endpoint == "/rbac/apps/permission-keys/batch" assert call.json == {"app_ids": ["app-1", "app-2"]} assert out == { "app-1": ["app.acl.view_layout", "app.acl.edit"], "app-2": [], } def test_app_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled( self, mock_send: MagicMock, sqlite_session: Session ): sqlite_session.add( TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole.EDITOR) ) sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.AppPermissions.batch_get( "tenant-1", "acct-1", ["app-1", "app-2"], session=sqlite_session ) mock_send.assert_not_called() assert out == { "app-1": svc._LEGACY_APP_EDITOR_KEYS, "app-2": svc._LEGACY_APP_EDITOR_KEYS, } def test_dataset_permissions_batch_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "data": [ {"resource_id": "ds-1", "permission_keys": ["dataset.acl.readonly"]}, {"resource_id": "ds-2", "permission_keys": ["dataset.acl.edit"]}, ] } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): out = svc.RBACService.DatasetPermissions.batch_get( "tenant-1", "acct-1", ["ds-1", "ds-2"], session=sqlite_session ) call = _call_args(mock_send) assert call.method == "POST" assert call.endpoint == "/rbac/datasets/permission-keys/batch" assert call.json == {"dataset_ids": ["ds-1", "ds-2"]} assert out == { "ds-1": ["dataset.acl.readonly"], "ds-2": ["dataset.acl.edit"], } def test_dataset_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled( self, mock_send: MagicMock, sqlite_session: Session ): sqlite_session.add( TenantAccountJoin( tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole.DATASET_OPERATOR, ) ) sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.DatasetPermissions.batch_get( "tenant-1", "acct-1", ["ds-1", "ds-2"], session=sqlite_session ) mock_send.assert_not_called() assert out == { "ds-1": svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, "ds-2": svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, } class TestListOption: def test_empty_produces_empty_params(self): assert svc.ListOption().to_params() == {} def test_reverse_serialises_as_lowercase_bool(self): assert svc.ListOption(reverse=False).to_params()["reverse"] == "false" assert svc.ListOption(reverse=True).to_params()["reverse"] == "true" def test_extra_overrides_merge(self): assert svc.ListOption(page_number=1).to_params({"resource_type": "app", "skip": None}) == { "page_number": 1, "resource_type": "app", }