dify/api/tests/unit_tests/services/enterprise/test_rbac_service.py
Asuka Minato 68d8328b9c
chore: clean Db session from service (#38227)
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-07-08 03:07:27 +00:00

903 lines
37 KiB
Python

"""Unit tests for services.enterprise.rbac_service.
The enterprise RBAC client is almost pure glue: each method turns a single
``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response
model. Rather than spinning up an HTTP server we monkeypatch that helper and
assert on the arguments it received; that catches both routing regressions
(wrong method / wrong path / wrong params) and model-shape regressions in
one place.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
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 == {"billing_enabled": svc.dify_config.BILLING_ENABLED}
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"}
class TestMyPermissions:
def test_resource_snapshot_maps_defaults_and_overrides(self):
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):
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=MagicMock())
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],
):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = role
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=mock_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 "billing.view" in out.workspace.permission_keys
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
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],
):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = role
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=mock_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):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = None
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=mock_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):
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=MagicMock())
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"
class TestMemberRoles:
def test_get(self, mock_send: MagicMock):
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=MagicMock())
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):
session = MagicMock()
session.scalar.return_value = svc.TenantAccountRole.EDITOR
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False):
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=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):
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=MagicMock(),
)
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_updates_legacy_join_role_when_rbac_disabled(self, mock_send: MagicMock):
session = MagicMock()
session.__enter__.return_value = session
target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2")
session.scalar.return_value = target_join
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False):
out = svc.RBACService.MemberRoles.replace(
"tenant-1", "acct-1", "acct-2", role_ids=["editor"], session=session
)
mock_send.assert_not_called()
session.commit.assert_called_once()
assert target_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):
session = MagicMock()
session.__enter__.return_value = session
target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2")
owner_join = SimpleNamespace(role=svc.TenantAccountRole.OWNER, account_id="acct-owner")
session.scalar.side_effect = [target_join, owner_join]
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False):
out = svc.RBACService.MemberRoles.replace(
"tenant-1", "acct-1", "acct-2", role_ids=["owner"], session=session
)
mock_send.assert_not_called()
session.commit.assert_called_once()
assert target_join.role == svc.TenantAccountRole.OWNER
assert owner_join.role == svc.TenantAccountRole.ADMIN
assert out.roles[0].id == "owner"
def test_batch_get(self, mock_send: MagicMock):
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 == []
class TestResourcePermissions:
def test_app_permissions_batch_get(self, mock_send: MagicMock):
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=MagicMock()
)
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):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = "editor"
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False):
out = svc.RBACService.AppPermissions.batch_get(
"tenant-1", "acct-1", ["app-1", "app-2"], session=mock_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):
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=MagicMock()
)
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):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = "dataset_operator"
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False):
out = svc.RBACService.DatasetPermissions.batch_get(
"tenant-1", "acct-1", ["ds-1", "ds-2"], session=mock_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",
}