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:
@@ -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,
|
||||
}
|
||||
@@ -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(供前端生成二维码)。
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user