dify/api/tests/unit_tests/services/enterprise/test_rbac_service.py

936 lines
38 KiB
Python

"""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",
}