Files
group_fqcd_jr/app/api/controllers/rbac.py
T
lzf_0626 6812fbe317 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 个)。
2026-09-11 21:22:56 +08:00

85 lines
3.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""角色与权限的只读接口(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)