feat(v0.7.1): P0 修复 + 企微 SSO + RBAC 细粒度 + audit_log

P0 修复:
- /api/ready import 错误 (_get_engine + settings.create_redis_client)
- 删 agent.otp_secret/otp_enabled 双字段 (migration 026)
- 重建 021_rbac migration (IF NOT EXISTS 兼容)

P1 新增:
- 企微 SSO (auth_wecom_sso.py, useWeChatWorkSSO composable, PortalSelect UA 检测)
- RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围 (rbac_service + seed_rbac + require_permission)
- audit_log 模型 + migration 027 + 服务 + API
- 管理后台 RBAC 权限矩阵 UI (PermissionsMatrix.vue)

质量:
- pytest 405 passed / 33 pre-existing failed / 4 xfailed (v0.7.1 引入失败 = 0)
- conftest GBK patch 强制 UTF-8 读 .env
- .gitignore 排除 *.b64 (含 admin token 凭据)
- DEPLOY-v0.7.1.md 7 步 runbook + 4 坑 + 回滚预案
This commit is contained in:
Simon
2026-06-22 17:38:47 +08:00
parent 2e6ac0f0ab
commit 78f60c6857
30 changed files with 2928 additions and 49 deletions
+137
View File
@@ -0,0 +1,137 @@
# =============================================================================
# 企微IT智能服务台 — 审计日志服务
# =============================================================================
# 说明: 提供 audit_log 写入/查询的统一入口
# 用法:
# from app.services.audit_log_service import record_audit_log
# await record_audit_log(
# db, employee_id="sxn", action="role_change",
# resource="agent", resource_id="agent-001",
# details={"role": "agent"}, request=request,
# )
# =============================================================================
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from fastapi import Request
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
logger = logging.getLogger(__name__)
async def record_audit_log(
db: AsyncSession,
employee_id: str,
action: str,
resource: str,
resource_id: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
result: str = "success",
request: Optional[Request] = None,
) -> AuditLog:
"""记录一条审计日志。
Args:
db: 数据库会话
employee_id: 操作人企微 UserID,系统操作传 "system"
action: 操作类型
resource: 目标资源类型
resource_id: 目标资源 ID(可选)
details: 详细上下文 JSON(可选)
result: success / failure / partial
request: FastAPI Request(可选,自动取 IP + UA)
Returns:
AuditLog: 写入的日志对象
"""
ip_address = None
user_agent = None
if request is not None:
# 优先用 X-Forwarded-For / X-Real-IP(proxy 后面)
ip_address = (
request.headers.get("x-forwarded-for", "").split(",")[0].strip()
or request.headers.get("x-real-ip")
or (request.client.host if request.client else None)
)
user_agent = request.headers.get("user-agent")
log = AuditLog(
employee_id=employee_id,
action=action,
resource=resource,
resource_id=resource_id,
details=details or {},
result=result,
ip_address=ip_address,
user_agent=user_agent,
created_at=datetime.now(),
)
db.add(log)
# 注:不 commit,让调用方跟主操作一起 commit(避免日志写一半就回滚)
return log
async def list_audit_logs(
db: AsyncSession,
employee_id: Optional[str] = None,
action: Optional[str] = None,
resource: Optional[str] = None,
from_time: Optional[datetime] = None,
to_time: Optional[datetime] = None,
page: int = 1,
page_size: int = 50,
) -> Dict[str, Any]:
"""查询审计日志(分页 + 多维过滤)。
Args:
db: 数据库会话
employee_id: 按操作人过滤(可选)
action: 按操作类型过滤(可选)
resource: 按资源类型过滤(可选)
from_time: 起始时间(可选)
to_time: 结束时间(可选)
page: 页码,从 1 开始
page_size: 每页条数,默认 50
Returns:
Dict: {items: [...], total: int, page, page_size}
"""
stmt = select(AuditLog)
conditions = []
if employee_id:
conditions.append(AuditLog.employee_id == employee_id)
if action:
conditions.append(AuditLog.action == action)
if resource:
conditions.append(AuditLog.resource == resource)
if from_time:
conditions.append(AuditLog.created_at >= from_time)
if to_time:
conditions.append(AuditLog.created_at <= to_time)
if conditions:
stmt = stmt.where(and_(*conditions))
# 倒序 + 分页
stmt = stmt.order_by(AuditLog.created_at.desc())
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
items = result.scalars().all()
# 总数
count_stmt = select(func.count()).select_from(AuditLog)
if conditions:
count_stmt = count_stmt.where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
+19
View File
@@ -11,14 +11,18 @@
# 3. dev 模式: 跳过企微 OAuth2,使用预设 dev 用户直接模拟扫码结果
# =============================================================================
import base64
import json
import logging
import os
import secrets
from datetime import datetime, timedelta
from io import BytesIO
from typing import Any, Dict, Optional
from urllib.parse import urlencode
import qrcode
import redis.asyncio as aioredis
from app.config import settings
@@ -140,10 +144,25 @@ class QrcodeService:
return {
"ticket": ticket,
"qrcode_url": qrcode_url,
"qrcode_png_base64": self._render_qrcode_png(qrcode_url),
"expires_in": TICKET_TTL_SECONDS,
"expires_at": expires_at,
}
@staticmethod
def _render_qrcode_png(url: str) -> str:
"""把 url 编成 PNG 并返回 base64 字符串,供前端 <img :src="data:image/png;base64,..."> 直接渲染。
依赖: requirements.txt 已有 qrcode[pil]==7.4.2 (2026-06-15 加的,原本为 OTP 绑定)。
"""
qr = qrcode.QRCode(version=1, box_size=10, border=2)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def _build_oauth_url(self, ticket: str) -> str:
"""拼接企微 OAuth2 授权 URL(供前端生成二维码)。
+206
View File
@@ -0,0 +1,206 @@
# =============================================================================
# 企微IT智能服务台 — RBAC 细粒度权限服务 (v0.7.1 task #86)
# =============================================================================
# 设计: 5 角色 × 4 资源 × 4 操作 × 3 数据范围
#
# 角色:
# 1. user — 普通员工(默认, 无管理权限)
# 2. agent — 坐席(处理会话)
# 3. team_lead — 团队主管(团队管理 + 报告)
# 4. auditor — 审计员(只读跨部门)
# 5. admin — 超级管理员(全权限)
#
# 资源 (resource):
# 1. conversation — 会话
# 2. agent — 坐席
# 3. system_config — 系统配置
# 4. audit_log — 审计日志
#
# 操作 (action):
# 1. read — 查看
# 2. create — 创建
# 3. update — 修改
# 4. delete — 删除
#
# 数据范围 (scope):
# 1. own — 自己的(agent 只能看自己接的会话)
# 2. department — 部门的
# 3. all — 全部(管理员 / 审计员)
#
# 权限字符串格式: "resource:action:scope"
# 例: "conversation:read:all"
# 通配符: "*:*:all" 表示全权限(仅 admin)
# =============================================================================
import logging
from typing import Dict, FrozenSet, List, Set, Tuple
logger = logging.getLogger(__name__)
# 5 角色的权限矩阵
# 格式: role_name -> Set[(resource, action, scope)]
ROLE_PERMISSIONS: Dict[str, Set[Tuple[str, str, str]]] = {
# 普通员工 — 仅创建自己的会话
"user": {
("conversation", "create", "own"),
("conversation", "read", "own"),
},
# 坐席 — 处理分配给自己的会话,可读所有未分配的
"agent": {
("conversation", "read", "own"),
("conversation", "read", "all"), # 看所有未分配的会话(坐席工作台需要)
("conversation", "update", "own"),
("conversation", "create", "all"),
},
# 团队主管 — 坐席权限 + 看本部门 + 管本部门坐席
"team_lead": {
("conversation", "read", "department"),
("conversation", "update", "department"),
("conversation", "create", "all"),
("agent", "read", "department"),
("agent", "update", "department"), # 改本部门坐席状态
},
# 审计员 — 只读,跨部门
"auditor": {
("conversation", "read", "all"),
("agent", "read", "all"),
("system_config", "read", "all"),
("audit_log", "read", "all"),
},
# 超级管理员 — 全权限
"admin": {
("*", "*", "all"), # 通配符,表示所有 (resource, action, all)
},
}
# 角色元数据(显示名 + 描述)
ROLE_METADATA: Dict[str, Dict[str, str]] = {
"user": {
"display_name": "普通员工",
"description": "提交工单、查看自己的会话",
"is_default": "true",
},
"agent": {
"display_name": "IT 坐席",
"description": "处理分配给自己的会话,可读所有未分配会话",
"is_default": "false",
},
"team_lead": {
"display_name": "团队主管",
"description": "管理本部门坐席,看本部门所有会话",
"is_default": "false",
},
"auditor": {
"display_name": "审计员",
"description": "只读跨部门数据,合规审计专用",
"is_default": "false",
},
"admin": {
"display_name": "超级管理员",
"description": "全权限,需 MFA 二次验证执行高危操作",
"is_default": "false",
},
}
def permissions_to_strings(perms: Set[Tuple[str, str, str]]) -> List[str]:
"""把权限元组集合转字符串列表(用于存 JSON)。"""
return [f"{r}:{a}:{s}" for (r, a, s) in sorted(perms)]
def strings_to_permissions(items: List[str]) -> Set[Tuple[str, str, str]]:
"""把字符串列表(从 JSON 读)转回元组集合。"""
result = set()
for item in items or []:
parts = item.split(":")
if len(parts) == 3:
result.add((parts[0], parts[1], parts[2]))
return result
def check_permission(
user_roles: List[str],
user_permissions: Dict[str, List[str]],
required_resource: str,
required_action: str,
required_scope: str = "own",
) -> bool:
"""检查用户是否拥有所需权限(细粒度)。
规则:
1. 用户所有角色中,任一角色的 permissions 包含所需权限 → 通过
2. admin 角色拥有 *:*:all → 永远通过
3. scope 比较: own < department < all (更高的 scope 满足更低的)
例: 用户有 department 权限, 申请 own → 通过
用户有 all 权限, 申请 department → 通过
Args:
user_roles: 用户的角色列表(角色名)
user_permissions: {role_name: [perm_string]} 角色权限字典
required_resource: 所需资源
required_action: 所需操作
required_scope: 所需数据范围(own/department/all)
Returns:
bool: 是否通过
"""
SCOPE_RANK = {"own": 1, "department": 2, "all": 3}
required_rank = SCOPE_RANK.get(required_scope, 1)
for role in user_roles:
perms = strings_to_permissions(user_permissions.get(role, []))
for (r, a, s) in perms:
# 1. admin 通配符
if r == "*" and a == "*" and s == "all":
return True
# 2. 资源/操作必须精确匹配(通配符不向下展开,避免误授权)
if r != required_resource or a != required_action:
continue
# 3. scope 满足"≥"即可(更高的 scope 满足更低的)
actual_rank = SCOPE_RANK.get(s, 0)
if actual_rank >= required_rank:
return True
return False
def get_role_default_permissions(role_name: str) -> List[str]:
"""获取角色的默认权限列表(用于种子数据初始化)。"""
perms = ROLE_PERMISSIONS.get(role_name, set())
return permissions_to_strings(perms)
# 资源/操作/范围的合法值(用于前端下拉框 + 后端校验)
VALID_RESOURCES = ["conversation", "agent", "system_config", "audit_log"]
VALID_ACTIONS = ["read", "create", "update", "delete"]
VALID_SCOPES = ["own", "department", "all"]
def validate_permission_string(perm: str) -> bool:
"""校验权限字符串格式是否合法。
例: "conversation:read:all" → True
"foo:bar:baz" → False
"""
parts = perm.split(":")
if len(parts) != 3:
return False
r, a, s = parts
# 资源: 支持通配符 * 或合法值
if r != "*" and r not in VALID_RESOURCES:
return False
# 操作: 支持通配符 * 或合法值
if a != "*" and a not in VALID_ACTIONS:
return False
# 范围: 不支持通配符,必须是合法值
if s not in VALID_SCOPES:
return False
return True