Files
group_fqcd_jr/app/service/rbac_query_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

203 lines
8.5 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:先让管理员**看得见**)。
## 为什么先做只读
权限变更(提权 / 降权)必须带三条红线:**审计留痕**、**禁止自我提权**、
**保护内置角色**(否则可能把自己锁在门外)。那是一条需要单独评审的写路径。
而运维的大半诉求其实是"这个角色到底有哪些权限""这个人为什么 403"——只读就能回答。
## 权限码为什么复用 `audit:read`,而不新增 `rbac:read`
1. `audit:read` 的 `data_scope` 已经是 `all`,且只发给管理员;
2. "谁能访问什么"本身就是**审计材料**——合规检查要看的正是这份清单;
3. 复用是**零数据改动、立刻可用**:新增权限码要先往 `sys_permission` 插行再授权,
而 `sys_role_permission` 目前由 `seed_test_rbac.py` 以 **DELETE 重建**语义管理
(见该脚本 95-98 行),为一个只读接口去动权限表不划算。
将来要细分时再加 `rbac:read`,与现在不冲突。
## 为什么 `user_identity()` 复用 `IdentityService.resolve`
那是请求进来时走的**同一条链路**:含 `status` 检查、`assigned_at` / `expires_at`
时间窗、`data_scope` 取最高、客户分配(`identity_repository.load_context`)。
自己再拼一遍 SQL 必然与它漂移,而"这里查出来的权限"与"实际能用的权限"不一致,
比没有这个接口更糟——排障时会被引到错的方向。
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError, UnauthorizedAgentError
from app.service.authorization_service import AuthorizationService
from app.service.identity_service import IdentityService
#: 这三个接口共用的权限闸门,理由见模块文档。
READ_PERMISSION = "audit:read"
class RbacQueryService:
"""RBAC 只读查询。不写任何表,也不改任何状态。"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_roles(self, context: RequestContext) -> dict[str, Any]:
"""列出所有角色,带权限数与在用人数。
角色数量是个位数量级,不分页;返回 `{items, next_cursor, has_more}` 是为了
与其它列表接口共用 `list_envelope`(`docs/05` §3.3),而不是真会翻页。
"""
await AuthorizationService.require(context, READ_PERMISSION)
rows = (
await self.session.execute(
text(
"""
SELECT r.id, r.role_code, r.role_name, r.status,
(SELECT COUNT(*) FROM sys_role_permission rp
WHERE rp.role_id = r.id) AS permission_count,
(SELECT COUNT(*) FROM sys_user_role ur
WHERE ur.role_id = r.id) AS user_count
FROM sys_role r
ORDER BY r.id
"""
)
)
).mappings().all()
return {
"items": [
{
"role_id": str(row["id"]),
"role_code": str(row["role_code"]),
"role_name": str(row["role_name"]),
"status": str(row["status"]),
"permission_count": int(row["permission_count"]),
"user_count": int(row["user_count"]),
}
for row in rows
],
"next_cursor": None,
"has_more": False,
}
async def list_role_permissions(
self, context: RequestContext, role_code: str
) -> dict[str, Any]:
"""某个角色拥有的全部权限(按权限码排序,便于与代码里 `require(...)` 对照)。"""
await AuthorizationService.require(context, READ_PERMISSION)
role = (
await self.session.execute(
text(
"SELECT id, role_code, role_name, status FROM sys_role "
"WHERE role_code = :code"
),
{"code": role_code},
)
).mappings().first()
if role is None:
raise GenericResourceNotFoundError("角色不存在")
rows = (
await self.session.execute(
text(
"""
SELECT p.permission_code, p.resource, p.action, p.data_scope
FROM sys_role_permission rp
JOIN sys_permission p ON p.id = rp.permission_id
WHERE rp.role_id = :role_id
ORDER BY p.permission_code
"""
),
{"role_id": int(role["id"])},
)
).mappings().all()
return {
"items": [
{
"permission_code": str(row["permission_code"]),
"resource": str(row["resource"]),
"action": str(row["action"]),
"data_scope": str(row["data_scope"]),
}
for row in rows
],
"next_cursor": None,
"has_more": False,
}
async def get_role_detail(
self, context: RequestContext, role_code: str
) -> dict[str, Any]:
"""角色本身的信息(与权限清单分开:详情接口不应因权限为空就 404)。"""
await AuthorizationService.require(context, READ_PERMISSION)
row = (
await self.session.execute(
text(
"""
SELECT r.id, r.role_code, r.role_name, r.status, r.created_at, r.updated_at,
(SELECT COUNT(*) FROM sys_role_permission rp
WHERE rp.role_id = r.id) AS permission_count,
(SELECT COUNT(*) FROM sys_user_role ur
WHERE ur.role_id = r.id) AS user_count
FROM sys_role r WHERE r.role_code = :code
"""
),
{"code": role_code},
)
).mappings().first()
if row is None:
raise GenericResourceNotFoundError("角色不存在")
return {
"role_id": str(row["id"]),
"role_code": str(row["role_code"]),
"role_name": str(row["role_name"]),
"status": str(row["status"]),
"permission_count": int(row["permission_count"]),
"user_count": int(row["user_count"]),
}
async def get_user_identity(
self, context: RequestContext, user_id: str
) -> dict[str, Any]:
"""某个用户**实际解析出来**的身份:角色、权限、数据范围、可见客户。
直接复用 `IdentityService.resolve`,见模块文档。它抛 `UnauthorizedAgentError`
表示"账号不存在或未启用",对这个只读接口来说是**查不到**(404),
不是调用方鉴权失败——所以在这里转成 404,避免排障时误以为是权限问题。
"""
await AuthorizationService.require(context, READ_PERMISSION)
row = (
await self.session.execute(
text(
"SELECT id, user_no, username, user_type, status "
"FROM sys_user WHERE id = :user_id"
),
{"user_id": int(user_id)},
)
).mappings().first()
if row is None:
raise GenericResourceNotFoundError("用户不存在")
try:
resolved = await IdentityService().resolve(
RequestContext(user_id=user_id, trace_id=context.trace_id)
)
except UnauthorizedAgentError:
# 账号被停用(status != '正常')——用户存在但拿不到任何权限,
# 如实返回空权限集,比 404 更有助于排障。
resolved = None
return {
"user_id": str(row["id"]),
"user_no": str(row["user_no"]),
"username": str(row["username"]),
"user_type": str(row["user_type"]),
"status": str(row["status"]),
"roles": list(resolved.roles) if resolved is not None else [],
"permissions": list(resolved.permissions) if resolved is not None else [],
"data_scope": resolved.data_scope if resolved is not None else None,
"customer_ids": list(resolved.customer_ids) if resolved is not None else [],
}