From 006b6f6318b31fd41ad3bcb49ace1af586983ad4 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:21:27 +0800 Subject: [PATCH] fix(auth): support nested feature permissions (#6310) --- app/schemas/token.py | 4 +++- app/schemas/user.py | 18 +++++++++++++++--- tests/test_api_authorization.py | 4 ++-- tests/test_explicit_response_models.py | 24 ++++++++++++++++++++---- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/app/schemas/token.py b/app/schemas/token.py index a0f779c5b..2dc46cdf1 100644 --- a/app/schemas/token.py +++ b/app/schemas/token.py @@ -2,6 +2,8 @@ from typing import Optional from pydantic import BaseModel, Field +from app.schemas.user import UserPermissions + class MfaChallenge(BaseModel): """密码认证通过后需要继续完成的二次验证信息。""" @@ -28,7 +30,7 @@ class Token(BaseModel): # 权限级别 level: int = 1 # 详细权限 - permissions: Optional[dict[str, bool]] = Field(default_factory=dict) + permissions: Optional[UserPermissions] = Field(default_factory=dict) # 是否显示配置向导 wizard: Optional[bool] = None diff --git a/app/schemas/user.py b/app/schemas/user.py index 96f20c836..6a7fd5edb 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,10 +1,22 @@ from typing import Optional from pydantic import BaseModel, Field, ConfigDict +from typing_extensions import TypedDict from app.schemas.common import JsonData +class UserPermissions(TypedDict, total=False): + """用户分类权限及可动态扩展的功能级权限。""" + + discovery: bool # 发现功能分类权限 + search: bool # 资源搜索分类权限 + subscribe: bool # 订阅管理分类权限 + manage: bool # 系统管理分类权限 + admin: bool # 管理员入口标识,实际授权仍由超级用户身份决定 + features: dict[str, bool] # 功能键到启用状态的映射 + + # Shared properties class UserBase(BaseModel): """用户公共资料、权限和个性化设置。""" @@ -22,7 +34,7 @@ class UserBase(BaseModel): # 是否开启二次验证 is_otp: Optional[bool] = False # 权限 - permissions: Optional[dict[str, bool]] = Field(default_factory=dict) + permissions: Optional[UserPermissions] = Field(default_factory=dict) # 个性化设置 settings: Optional[dict[str, JsonData]] = Field(default_factory=dict) @@ -37,7 +49,7 @@ class UserCreate(UserBase): email: Optional[str] = None password: Optional[str] = None settings: Optional[dict[str, JsonData]] = Field(default_factory=dict) - permissions: Optional[dict[str, bool]] = Field(default_factory=dict) + permissions: Optional[UserPermissions] = Field(default_factory=dict) # Properties to receive via API on update @@ -49,7 +61,7 @@ class UserUpdate(UserBase): email: Optional[str] = None password: Optional[str] = None settings: Optional[dict[str, JsonData]] = Field(default_factory=dict) - permissions: Optional[dict[str, bool]] = Field(default_factory=dict) + permissions: Optional[UserPermissions] = Field(default_factory=dict) class UserInDBBase(UserBase): diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index 0cb8a1eb8..b8d806c2c 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -174,7 +174,7 @@ def test_login_sets_resource_token_cookie(monkeypatch): name=username, is_superuser=False, avatar="", - permissions={"discovery": True}, + permissions={"discovery": True, "features": {}}, ) class FakeSystemConfigOper: @@ -198,7 +198,7 @@ def test_login_sets_resource_token_cookie(monkeypatch): ) assert token.user_id == 1 - assert token.permissions == {"discovery": True} + assert token.permissions == {"discovery": True, "features": {}} assert "set-cookie" in response.headers resource_cookie = response.headers["set-cookie"].split("=", 1)[1].split(";", 1)[0] diff --git a/tests/test_explicit_response_models.py b/tests/test_explicit_response_models.py index bbbd9a26b..7e0c129c4 100644 --- a/tests/test_explicit_response_models.py +++ b/tests/test_explicit_response_models.py @@ -10,7 +10,7 @@ from app.schemas.subscribe import Subscribe from app.schemas.tmdb import TmdbEpisode from app.schemas.token import Token from app.schemas.types import MediaSource -from app.schemas.user import User +from app.schemas.user import User, UserCreate, UserUpdate def _nonnull_branch(schema: dict) -> dict: @@ -80,8 +80,12 @@ def test_site_and_subscribe_legacy_values_remain_compatible(): def test_permissions_and_extension_json_keep_values_but_have_explicit_schemas(): - """权限映射应为布尔值,扩展数据应通过递归 JSON Schema 展示合法类型。""" - permissions = {"manage": True, "search": False} + """权限分类和功能映射应保持原值,扩展数据应展示合法 JSON 类型。""" + permissions = { + "manage": True, + "search": False, + "features": {"search.resource": False}, + } token = Token( access_token="token", token_type="bearer", @@ -95,6 +99,8 @@ def test_permissions_and_extension_json_keep_values_but_have_explicit_schemas(): permissions=permissions, settings={"nickname": "测试", "layout": {"dense": True}}, ) + user_create = UserCreate(name="created", permissions={"features": {}}) + user_update = UserUpdate(id=1, name="updated", permissions=permissions) plugin = Plugin(history={"v1.0.0": "首次发布"}) dashboard = PluginDashboard( attrs={"class": ["pa-2", {"active": True}]}, @@ -103,14 +109,24 @@ def test_permissions_and_extension_json_keep_values_but_have_explicit_schemas(): ) assert token.model_dump()["permissions"] == permissions + assert user.model_dump()["permissions"] == permissions + assert user_create.model_dump()["permissions"] == {"features": {}} + assert user_update.model_dump()["permissions"] == permissions assert user.model_dump()["settings"]["layout"] == {"dense": True} assert plugin.model_dump()["history"] == {"v1.0.0": "首次发布"} assert dashboard.model_dump()["elements"][0]["component"] == "VAlert" user_schema = User.model_json_schema() permission_schema = _nonnull_branch(user_schema["properties"]["permissions"]) + permission_schema = user_schema["$defs"][permission_schema["$ref"].rsplit("/", 1)[-1]] settings_schema = _nonnull_branch(user_schema["properties"]["settings"]) - assert permission_schema["additionalProperties"] == {"type": "boolean"} + assert permission_schema["properties"]["manage"] == { + "title": "Manage", + "type": "boolean", + } + assert permission_schema["properties"]["features"]["additionalProperties"] == { + "type": "boolean" + } assert settings_schema["additionalProperties"]["$ref"].endswith("/JsonData")