fix(auth): support nested feature permissions (#6310)

This commit is contained in:
InfinityPacer
2026-08-14 10:21:27 +08:00
committed by GitHub
parent 666e110693
commit 006b6f6318
4 changed files with 40 additions and 10 deletions
+3 -1
View File
@@ -2,6 +2,8 @@ from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.schemas.user import UserPermissions
class MfaChallenge(BaseModel): class MfaChallenge(BaseModel):
"""密码认证通过后需要继续完成的二次验证信息。""" """密码认证通过后需要继续完成的二次验证信息。"""
@@ -28,7 +30,7 @@ class Token(BaseModel):
# 权限级别 # 权限级别
level: int = 1 level: int = 1
# 详细权限 # 详细权限
permissions: Optional[dict[str, bool]] = Field(default_factory=dict) permissions: Optional[UserPermissions] = Field(default_factory=dict)
# 是否显示配置向导 # 是否显示配置向导
wizard: Optional[bool] = None wizard: Optional[bool] = None
+15 -3
View File
@@ -1,10 +1,22 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from typing_extensions import TypedDict
from app.schemas.common import JsonData 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 # Shared properties
class UserBase(BaseModel): class UserBase(BaseModel):
"""用户公共资料、权限和个性化设置。""" """用户公共资料、权限和个性化设置。"""
@@ -22,7 +34,7 @@ class UserBase(BaseModel):
# 是否开启二次验证 # 是否开启二次验证
is_otp: Optional[bool] = False 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) settings: Optional[dict[str, JsonData]] = Field(default_factory=dict)
@@ -37,7 +49,7 @@ class UserCreate(UserBase):
email: Optional[str] = None email: Optional[str] = None
password: Optional[str] = None password: Optional[str] = None
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict) 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 # Properties to receive via API on update
@@ -49,7 +61,7 @@ class UserUpdate(UserBase):
email: Optional[str] = None email: Optional[str] = None
password: Optional[str] = None password: Optional[str] = None
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict) 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): class UserInDBBase(UserBase):
+2 -2
View File
@@ -174,7 +174,7 @@ def test_login_sets_resource_token_cookie(monkeypatch):
name=username, name=username,
is_superuser=False, is_superuser=False,
avatar="", avatar="",
permissions={"discovery": True}, permissions={"discovery": True, "features": {}},
) )
class FakeSystemConfigOper: class FakeSystemConfigOper:
@@ -198,7 +198,7 @@ def test_login_sets_resource_token_cookie(monkeypatch):
) )
assert token.user_id == 1 assert token.user_id == 1
assert token.permissions == {"discovery": True} assert token.permissions == {"discovery": True, "features": {}}
assert "set-cookie" in response.headers assert "set-cookie" in response.headers
resource_cookie = response.headers["set-cookie"].split("=", 1)[1].split(";", 1)[0] resource_cookie = response.headers["set-cookie"].split("=", 1)[1].split(";", 1)[0]
+20 -4
View File
@@ -10,7 +10,7 @@ from app.schemas.subscribe import Subscribe
from app.schemas.tmdb import TmdbEpisode from app.schemas.tmdb import TmdbEpisode
from app.schemas.token import Token from app.schemas.token import Token
from app.schemas.types import MediaSource 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: 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(): def test_permissions_and_extension_json_keep_values_but_have_explicit_schemas():
"""权限映射应为布尔值,扩展数据应通过递归 JSON Schema 展示合法类型。""" """权限分类和功能映射应保持原值,扩展数据应展示合法 JSON 类型。"""
permissions = {"manage": True, "search": False} permissions = {
"manage": True,
"search": False,
"features": {"search.resource": False},
}
token = Token( token = Token(
access_token="token", access_token="token",
token_type="bearer", token_type="bearer",
@@ -95,6 +99,8 @@ def test_permissions_and_extension_json_keep_values_but_have_explicit_schemas():
permissions=permissions, permissions=permissions,
settings={"nickname": "测试", "layout": {"dense": True}}, 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": "首次发布"}) plugin = Plugin(history={"v1.0.0": "首次发布"})
dashboard = PluginDashboard( dashboard = PluginDashboard(
attrs={"class": ["pa-2", {"active": True}]}, 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 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 user.model_dump()["settings"]["layout"] == {"dense": True}
assert plugin.model_dump()["history"] == {"v1.0.0": "首次发布"} assert plugin.model_dump()["history"] == {"v1.0.0": "首次发布"}
assert dashboard.model_dump()["elements"][0]["component"] == "VAlert" assert dashboard.model_dump()["elements"][0]["component"] == "VAlert"
user_schema = User.model_json_schema() user_schema = User.model_json_schema()
permission_schema = _nonnull_branch(user_schema["properties"]["permissions"]) 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"]) 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") assert settings_schema["additionalProperties"]["$ref"].endswith("/JsonData")