From 212252bb786ca3b6a1ddc0759fda23bf27766384 Mon Sep 17 00:00:00 2001 From: fatelei Date: Sat, 9 May 2026 17:45:35 +0800 Subject: [PATCH] chore: compatiable result is none --- api/controllers/console/workspace/rbac.py | 16 ++++++++- api/services/enterprise/rbac_service.py | 28 +++++++++++++++ .../console/workspace/test_rbac.py | 36 ++++++------------- .../services/enterprise/test_rbac_service.py | 21 +++++++++++ 4 files changed, 75 insertions(+), 26 deletions(-) diff --git a/api/controllers/console/workspace/rbac.py b/api/controllers/console/workspace/rbac.py index b15eb9e0aa..d1157f36c0 100644 --- a/api/controllers/console/workspace/rbac.py +++ b/api/controllers/console/workspace/rbac.py @@ -6,7 +6,7 @@ from typing import Any from flask import request from flask_restx import Resource -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, field_validator from werkzeug.exceptions import Forbidden, NotFound from configs import dify_config @@ -246,6 +246,13 @@ class _ReplaceBindingsRequest(BaseModel): role_ids: list[str] = [] account_ids: list[str] = [] + @field_validator("role_ids", "account_ids", mode="before") + @classmethod + def _coerce_bindings(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + @console_ns.route("/workspaces/current/rbac/my-permissions") class RBACMyPermissionsApi(Resource): @@ -466,6 +473,13 @@ class RBACWorkspaceDatasetMemberBindingsApi(Resource): class _ReplaceMemberRolesRequest(BaseModel): role_ids: list[str] = [] + @field_validator("role_ids", mode="before") + @classmethod + def _coerce_role_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + @console_ns.route("/workspaces/current/rbac/members//rbac-roles") class RBACMemberRolesApi(Resource): diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index 0f5d7ebdb1..da3f084e33 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -113,6 +113,13 @@ class AccessMatrixItem(_RBACModel): role_ids: list[str] = Field(default_factory=list) account_ids: list[str] = Field(default_factory=list) + @field_validator("role_ids", "account_ids", mode="before") + @classmethod + def _coerce_empty_lists(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + class AppAccessMatrix(_RBACModel): app_id: str = "" @@ -194,15 +201,36 @@ class AccessPolicyUpdate(_RBACModel): class ReplaceRoleBindings(_RBACModel): role_ids: list[str] = Field(default_factory=list) + @field_validator("role_ids", mode="before") + @classmethod + def _coerce_role_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + class ReplaceMemberBindings(_RBACModel): account_ids: list[str] = Field(default_factory=list) + @field_validator("account_ids", mode="before") + @classmethod + def _coerce_account_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + class ReplaceBindings(_RBACModel): role_ids: list[str] = Field(default_factory=list) account_ids: list[str] = Field(default_factory=list) + @field_validator("role_ids", "account_ids", mode="before") + @classmethod + def _coerce_bindings(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + class ListOption(_RBACModel): page_number: int | None = None diff --git a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py index aa35ec7fc2..40aee8e690 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py @@ -2,11 +2,8 @@ The controllers here are thin: almost every non-trivial behaviour lives in ``services.enterprise.rbac_service`` (covered by its own suite). These tests -therefore focus on the three Flask-layer concerns the service layer cannot -exercise: +therefore focus on the Flask-layer concerns the service layer cannot exercise: -* ``enterprise_only`` rejects community-edition calls with 403 (it is the - outermost decorator, so it fires before any auth middleware). * ``_current_ids`` raises 404 when the session has no tenant. * The pydantic request models accept / reject bodies as expected. @@ -25,7 +22,7 @@ from unittest.mock import patch import pytest from flask import Flask from pydantic import ValidationError -from werkzeug.exceptions import Forbidden, NotFound +from werkzeug.exceptions import NotFound from controllers.console.workspace import rbac as rbac_mod @@ -40,26 +37,6 @@ def app(): def _enabled(enabled: bool): return patch("controllers.console.workspace.rbac.dify_config.ENTERPRISE_ENABLED", enabled) - -class TestEnterpriseGate: - """``enterprise_only`` is the outermost decorator on every resource, so we - can exercise it directly — no auth stubs required. - """ - - def test_catalog_forbidden_when_disabled(self, app): - with app.test_request_context("/workspaces/current/rbac/role-permissions/catalog"), _enabled(False): - with pytest.raises(Forbidden): - rbac_mod.RBACWorkspaceCatalogApi().get() - - def test_roles_post_forbidden_when_disabled(self, app): - with ( - app.test_request_context("/workspaces/current/rbac/roles", method="POST", json={}), - _enabled(False), - ): - with pytest.raises(Forbidden): - rbac_mod.RBACRolesApi().post() - - class TestCurrentIds: def test_rejects_missing_tenant(self): with patch("controllers.console.workspace.rbac.current_account_with_tenant") as mock_user: @@ -115,6 +92,15 @@ class TestPydanticModels: assert parsed.role_ids == [] assert parsed.account_ids == [] + def test_replace_bindings_coerce_null_lists(self): + parsed = rbac_mod._ReplaceBindingsRequest.model_validate({"role_ids": None, "account_ids": None}) + assert parsed.role_ids == [] + assert parsed.account_ids == [] + + def test_replace_member_roles_coerce_null_list(self): + parsed = rbac_mod._ReplaceMemberRolesRequest.model_validate({"role_ids": None}) + assert parsed.role_ids == [] + def test_pagination_query_accepts_page_and_limit_aliases(self): parsed = rbac_mod._PaginationQuery.model_validate({"page": 3, "limit": 25, "reverse": True}) assert parsed.page_number == 3 diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py index 9c5a818c1b..677dcc1f79 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -252,6 +252,27 @@ class TestWorkspaceAccess: 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", + }, + "role_ids": None, + "account_ids": None, + } + ], + "pagination": None, + } + + out = svc.RBACService.WorkspaceAccess.app_matrix("tenant-1") + + assert out.items[0].role_ids == [] + assert out.items[0].account_ids == [] + 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"])