B1:RBAC 只读查询接口(4 个)+ docs/05 登记 A035-A038

在此之前,权限只能靠脚本改,平台里**没有任何地方能看"谁能访问什么"**。
B1 先把"看得见"做出来:

- GET /api/v1/admin/roles                            角色清单 + 权限数 + 在用人数
- GET /api/v1/admin/roles/{role_code}                角色详情
- GET /api/v1/admin/roles/{role_code}/permissions    权限清单(按权限码排序,
                                                     便于与各 Service 的 require() 对照)
- GET /api/v1/admin/users/{user_id}/roles            某人**实际解析出来**的角色/权限/数据范围

几个刻意的决定:

1. 权限码复用 `audit:read` 而不新增 `rbac:read`:这份清单本身就是审计材料,且复用是零数据
   改动、立刻可用(新增权限码要先改 sys_permission,而它目前由 seed_test_rbac.py 以
   DELETE 重建语义管理)。将来要细分再加,不冲突。
2. `/users/{id}/roles` 直接复用 IdentityService.resolve,不自己拼 SQL —— 那正是请求进来时
   走的链路(status 检查、assigned_at/expires_at 时间窗、data_scope 取最高、客户分配)。
   自己写一遍必然漂移,而"这里查出的权限"与"实际能用的权限"不一致比没有这个接口更糟。
   测试里加了一条交叉验证:该接口的 roles/data_scope 必须与登录响应完全一致。
3. 停用账号返回**空权限集**而不是 404 —— 用户存在但拿不到权限,如实呈现比 404 更利于排障。
4. 角色详情与权限清单分开:权限为空的角色不该被误判成"角色不存在"。
5. 全部只读、不写审计(它们返回的就是审计材料本身),docs/05 §19 登记时审计列标"否"。

权限变更(提权/降权)仍无接口 —— docs/05 §19 已注明那不是遗漏,而是需要单独评审
(审计留痕 + 禁止自我提权 + 保护内置角色三条红线)。

另:确认了 load_context 对 sys_user_role.expires_at 是有过滤的(assigned_at<=now AND
(expires_at IS NULL OR expires_at>now)),我上一轮只读了半段 SQL 差点误报。

验证:ruff 干净 / mypy 185 文件 0 错 / 文档守卫 38 份无编号冲突 /
unit+contract 1140 passed / integration 98 passed(本批新增 8 个)。
This commit is contained in:
2026-09-11 21:22:56 +08:00
parent edd8c53c3a
commit 6812fbe317
5 changed files with 487 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
"""角色与权限的只读接口(B1:先让管理员看得见)。
前缀与 `admin.py` 相同(`/api/v1/admin`),但**独立成文件**:`admin.py` 是表驱动的
配置面 CRUD 工厂(`RESOURCES` 单表增删改),而角色/权限是**多对多关系查询**,
塞进那个工厂既不自然、也会让"配置管理"和"身份管理"两件事混在一个文件里。
四个接口全部只读,且**不写审计**——它们返回的就是审计材料本身("谁能访问什么",
合规检查要看的正是这份清单)。`docs/05` §19 登记时"审计"列标"否"。
所有响应都走 `docs/05` §3.3 的统一信封(复用 `app/api/views/envelope.py`):
列表的 `data` 是纯数组、分页元数据在 `meta`;单对象的 `meta` 只有 `trace_id`。
"""
from fastapi import APIRouter, Depends, Path
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.api.dependencies.rate_limit import enforce_rate_limit
from app.api.views.envelope import envelope, list_envelope
from app.core.contracts import RequestContext
from app.service.rbac_query_service import RbacQueryService
router = APIRouter(
prefix="/api/v1/admin",
tags=["platform-admin"],
dependencies=[Depends(enforce_rate_limit)],
)
#: 角色码在 `sys_role` 里形如 `customer` / `risk_operator` / `admin`。
ROLE_CODE = Path(min_length=1, max_length=64, pattern=r"^[a-z0-9_]+$")
#: `sys_user.id` 是 BIGINT UNSIGNED。
USER_ID = Path(pattern=r"^[0-9]{1,20}$")
@router.get("/roles")
async def list_roles(
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
) -> dict[str, object]:
"""所有角色 + 每个角色的权限数与在用人数。"""
page = await RbacQueryService(session).list_roles(context)
return list_envelope(page, context)
@router.get("/roles/{role_code}")
async def get_role(
role_code: str = ROLE_CODE,
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
) -> dict[str, object]:
"""单个角色的详情。
与 `/permissions` 分开是必要的:权限为空的角色(例如刚建好还没授权的)在一个合并
接口里很容易被"查不到权限"误判成"角色不存在"。
"""
data = await RbacQueryService(session).get_role_detail(context, role_code)
return envelope(data, context)
@router.get("/roles/{role_code}/permissions")
async def list_role_permissions(
role_code: str = ROLE_CODE,
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
) -> dict[str, object]:
"""该角色的权限清单,按权限码排序,便于与代码里各 Service 的 `require(...)` 对照。"""
page = await RbacQueryService(session).list_role_permissions(context, role_code)
return list_envelope(page, context)
@router.get("/users/{user_id}/roles")
async def get_user_roles(
user_id: str = USER_ID,
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
) -> dict[str, object]:
"""某个用户**实际解析出来**的角色 / 权限 / 数据范围 / 可见客户。
这是四个接口里最有用的一个:它直接回答"这个人为什么 403"——
走的是 `IdentityService.resolve`,与请求期完全同一条链路。
"""
data = await RbacQueryService(session).get_user_identity(context, user_id)
return envelope(data, context)