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:
@@ -294,8 +294,10 @@ async def admin_unbind_agent_otp(
|
||||
if not agent:
|
||||
raise AppException(1001, "坐席不存在")
|
||||
|
||||
agent.otp_secret = None
|
||||
agent.otp_enabled = 0
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
agent.mfa_last_verified_at = None
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
@@ -382,3 +382,132 @@ async def delete_mapping_rule(
|
||||
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
|
||||
|
||||
return success_response(message="映射规则删除成功")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 4. 权限矩阵可视化 (v0.7.1 task #86)
|
||||
# ==========================================================================
|
||||
# 给管理后台 UI 用: 返回 5 角色 × 4 资源 × 4 操作 × 3 范围的完整矩阵
|
||||
# 嵌套结构方便前端直接渲染表格:
|
||||
# {
|
||||
# "roles": [{name, display_name, permissions: [string]}],
|
||||
# "resources": [conversation, agent, ...],
|
||||
# "actions": [read, create, update, delete],
|
||||
# "scopes": [own, department, all],
|
||||
# "matrix": {
|
||||
# "agent": { # 角色名
|
||||
# "conversation:read:own": true,
|
||||
# "conversation:read:all": true,
|
||||
# ...
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# ==========================================================================
|
||||
@router.get("/permissions/matrix")
|
||||
async def get_permissions_matrix(
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取 RBAC 完整权限矩阵(管理后台可视化用)。
|
||||
|
||||
返回 5 角色预置的 permissions JSON,前端用此数据渲染
|
||||
角色 × 资源 × 操作 × 范围 的可读表格。
|
||||
|
||||
Args:
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含完整权限矩阵
|
||||
"""
|
||||
from app.services.rbac_service import (
|
||||
ROLE_PERMISSIONS,
|
||||
VALID_ACTIONS,
|
||||
VALID_RESOURCES,
|
||||
VALID_SCOPES,
|
||||
permissions_to_strings,
|
||||
)
|
||||
|
||||
# 1. 查 DB 拿角色元数据(显示名等)
|
||||
stmt = select(Role).order_by(Role.is_default.desc(), Role.name)
|
||||
result = await db.execute(stmt)
|
||||
roles = result.scalars().all()
|
||||
|
||||
# 2. 构建角色列表(以代码里的 ROLE_PERMISSIONS 为准,DB 字段作 display_name)
|
||||
role_list = []
|
||||
matrix = {}
|
||||
for role in roles:
|
||||
# 优先用代码常量(单一可信源);DB 字段仅作元数据
|
||||
perms = ROLE_PERMISSIONS.get(role.name, set())
|
||||
perms_list = permissions_to_strings(perms)
|
||||
|
||||
role_list.append({
|
||||
"name": role.name,
|
||||
"display_name": role.display_name,
|
||||
"description": role.description,
|
||||
"is_default": role.is_default,
|
||||
"permission_count": len(perms_list),
|
||||
})
|
||||
|
||||
# 3. 角色 × 资源 × 操作 × 范围 的全矩阵
|
||||
# true/false 表征是否拥有此权限
|
||||
# 前端用此渲染表格,空格表示"不适用"
|
||||
role_matrix = {}
|
||||
for resource in VALID_RESOURCES:
|
||||
for action in VALID_ACTIONS:
|
||||
for scope in VALID_SCOPES:
|
||||
perm = f"{resource}:{action}:{scope}"
|
||||
role_matrix[perm] = (resource, action, scope) in perms
|
||||
matrix[role.name] = role_matrix
|
||||
|
||||
return success_response(data={
|
||||
"roles": role_list,
|
||||
"resources": VALID_RESOURCES,
|
||||
"actions": VALID_ACTIONS,
|
||||
"scopes": VALID_SCOPES,
|
||||
"matrix": matrix,
|
||||
})
|
||||
|
||||
|
||||
# ---------- GET /api/admin/roles/permissions/check ----------
|
||||
# 给前端按钮级权限控制用: 传入 (resource, action, scope) 查当前用户是否拥有
|
||||
# 注: 这是 endpoint 版本,装饰器版本见 app.dependencies.require_permission
|
||||
@router.get("/permissions/check")
|
||||
async def check_my_permission(
|
||||
resource: str = Query(..., description="资源"),
|
||||
action: str = Query(..., description="操作"),
|
||||
scope: str = Query("own", description="数据范围"),
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
):
|
||||
"""检查当前管理员是否拥有指定权限(给前端按钮级控制用)。
|
||||
|
||||
永远返回 true(因为 require_admin 已确保是 admin)。
|
||||
此端点存在是为了给前端一个统一入口,实际权限由后端强制。
|
||||
未来扩展:可加 current_user 参数(非 admin 角色也能调)。
|
||||
|
||||
Args:
|
||||
resource: 资源
|
||||
action: 操作
|
||||
scope: 数据范围
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含 has_permission 字段
|
||||
"""
|
||||
from app.services.rbac_service import check_permission, ROLE_PERMISSIONS, permissions_to_strings
|
||||
|
||||
user_perms = {role: permissions_to_strings(perms) for role, perms in ROLE_PERMISSIONS.items()}
|
||||
|
||||
has_perm = check_permission(
|
||||
user_roles=admin.roles,
|
||||
user_permissions=user_perms,
|
||||
required_resource=resource,
|
||||
required_action=action,
|
||||
required_scope=scope,
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
"has_permission": has_perm,
|
||||
"resource": resource,
|
||||
"action": action,
|
||||
"scope": scope,
|
||||
})
|
||||
|
||||
+23
-17
@@ -257,8 +257,9 @@ async def agent_login(
|
||||
await db.flush()
|
||||
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
|
||||
|
||||
# 2. OTP 二次验证(admin 角色且已绑定 OTP)
|
||||
if agent.role == "admin" and agent.otp_enabled == 1:
|
||||
# 2. MFA 二次验证(admin 角色且已绑定 MFA)
|
||||
# v0.7.1: 用 mfa_secret/mfa_enabled 替代旧 otp_secret/otp_enabled
|
||||
if agent.role == "admin" and agent.mfa_enabled:
|
||||
if not body.otp_code:
|
||||
# 需要 OTP 验证,返回 require_otp 标记
|
||||
return success_response(data={
|
||||
@@ -269,7 +270,7 @@ async def agent_login(
|
||||
})
|
||||
else:
|
||||
# 验证 OTP 码
|
||||
totp = pyotp.TOTP(agent.otp_secret)
|
||||
totp = pyotp.TOTP(agent.mfa_secret)
|
||||
if not totp.verify(body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误,请重新输入")
|
||||
|
||||
@@ -414,15 +415,16 @@ async def bind_agent_otp(
|
||||
Dict: 二维码图片(base64)和密钥
|
||||
"""
|
||||
try:
|
||||
# v0.7.1: 用 mfa_secret 替代 otp_secret
|
||||
# 检查是否已绑定
|
||||
if agent.otp_secret:
|
||||
if agent.mfa_secret:
|
||||
# 已绑定,返回现有密钥的二维码
|
||||
totp = pyotp.TOTP(agent.otp_secret)
|
||||
totp = pyotp.TOTP(agent.mfa_secret)
|
||||
else:
|
||||
# 生成新密钥
|
||||
secret = pyotp.random_base32()
|
||||
agent.otp_secret = secret
|
||||
# otp_enabled 保持 0,等待首次验证后启用
|
||||
agent.mfa_secret = secret
|
||||
# mfa_enabled 保持 False,等待首次验证后启用
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
totp = pyotp.TOTP(secret)
|
||||
@@ -439,11 +441,11 @@ async def bind_agent_otp(
|
||||
qr.save(buffer, format="PNG")
|
||||
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.otp_secret[:4]}...")
|
||||
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.mfa_secret[:4]}...")
|
||||
|
||||
return success_response(data={
|
||||
"qr_code": f"data:image/png;base64,{qr_base64}",
|
||||
"secret": agent.otp_secret,
|
||||
"secret": agent.mfa_secret,
|
||||
})
|
||||
|
||||
except AppException:
|
||||
@@ -475,16 +477,18 @@ async def verify_agent_otp(
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent or not agent.otp_secret:
|
||||
if not agent or not agent.mfa_secret:
|
||||
raise AppException(1008, "请先绑定OTP")
|
||||
|
||||
# 验证 OTP 码
|
||||
totp = pyotp.TOTP(agent.otp_secret)
|
||||
totp = pyotp.TOTP(agent.mfa_secret)
|
||||
if not totp.verify(body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误")
|
||||
|
||||
# 验证成功,启用 OTP
|
||||
agent.otp_enabled = 1
|
||||
# 验证成功,启用 MFA
|
||||
agent.mfa_enabled = True
|
||||
agent.mfa_bound_at = datetime.now()
|
||||
agent.mfa_last_verified_at = datetime.now()
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
@@ -492,7 +496,7 @@ async def verify_agent_otp(
|
||||
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
|
||||
|
||||
return success_response(data={
|
||||
"otp_enabled": True,
|
||||
"mfa_enabled": True,
|
||||
"message": "OTP验证成功,已启用",
|
||||
})
|
||||
|
||||
@@ -510,15 +514,17 @@ async def unbind_agent_otp(
|
||||
):
|
||||
"""解绑 OTP。
|
||||
|
||||
解绑后 otp_secret 和 otp_enabled 都清空。
|
||||
解绑后 mfa_secret 和 mfa_enabled 都清空。
|
||||
需要管理员操作。
|
||||
|
||||
Returns:
|
||||
Dict: 解绑结果
|
||||
"""
|
||||
try:
|
||||
agent.otp_secret = None
|
||||
agent.otp_enabled = 0
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
agent.mfa_last_verified_at = None
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 审计日志 API (v0.7.1 task #89)
|
||||
# =============================================================================
|
||||
# 说明: 审计日志只读端点,给 auditor / admin 用
|
||||
# 权限要求: audit_log:read:all (由 RBAC 装饰器校验)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import require_permission, UserInfo
|
||||
from app.database import get_db
|
||||
from app.services.audit_log_service import list_audit_logs
|
||||
from app.utils.response import success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/audit-logs", tags=["审计日志"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
@require_permission("audit_log", "read", "all")
|
||||
async def get_audit_logs(
|
||||
employee_id: Optional[str] = Query(None, description="按操作人过滤"),
|
||||
action: Optional[str] = Query(None, description="按操作类型过滤"),
|
||||
resource: Optional[str] = Query(None, description="按资源类型过滤"),
|
||||
from_time: Optional[datetime] = Query(None, alias="from", description="起始时间(ISO8601)"),
|
||||
to_time: Optional[datetime] = Query(None, alias="to", description="结束时间(ISO8601)"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||
admin: UserInfo = None, # 由 require_permission 注入(签名合并)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询审计日志(分页)。
|
||||
|
||||
权限: 需要 audit_log:read:all (admin / auditor 角色拥有)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含 items/total/page/page_size
|
||||
"""
|
||||
result = await list_audit_logs(
|
||||
db,
|
||||
employee_id=employee_id,
|
||||
action=action,
|
||||
resource=resource,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
"items": [
|
||||
{
|
||||
"id": log.id,
|
||||
"employee_id": log.employee_id,
|
||||
"action": log.action,
|
||||
"resource": log.resource,
|
||||
"resource_id": log.resource_id,
|
||||
"details": log.details,
|
||||
"result": log.result,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
for log in result["items"]
|
||||
],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
})
|
||||
@@ -91,6 +91,7 @@ async def create_qrcode(
|
||||
return success_response(data={
|
||||
"ticket": result["ticket"],
|
||||
"qrcode_url": result["qrcode_url"],
|
||||
"qrcode_png_base64": result["qrcode_png_base64"],
|
||||
"expires_in": result["expires_in"],
|
||||
"expires_at": result["expires_at"].isoformat(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 企微入口 SSO(v0.7.1 新增)
|
||||
# =============================================================================
|
||||
# 说明: 解决 v0.7.0 hotfix1 用户报告的"企微工作台进入应用也要扫码"问题。
|
||||
#
|
||||
# 流程:
|
||||
# 1. 前端 PortalSelect.vue 加载时检测 navigator.userAgent
|
||||
# 2. 如果是 MicroMessenger / wxwork / DingTalk 等企微内置浏览器
|
||||
# → 调 /api/auth_wecom/sso/init?next=/itdesk/
|
||||
# 3. 后端生成企微 OAuth2 授权 URL,302 跳转用户去企微授权
|
||||
# 4. 企微回调 /api/auth_wecom/sso/callback?code=...&state=...
|
||||
# 5. 用 code 换 userid,查 role (user/agent/admin),生成 token
|
||||
# 6. 302 跳转到 next 路径 + token query param
|
||||
# 7. 前端用 token 调 get_current_user 拉身份信息
|
||||
#
|
||||
# 配置要求:
|
||||
# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 可信域名: itsupport.servyou.com.cn
|
||||
# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 回调域: itsupport.servyou.com.cn
|
||||
# - 环境变量 WECOM_SSO_ENABLED=true 启用(默认 false,避免老用户被打扰)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.role import Role
|
||||
from app.models.user_role import UserRole
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.response import AppException
|
||||
from app.dependencies import get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth_wecom", tags=["企微 SSO"])
|
||||
|
||||
# OAuth state 在 Redis 的 TTL (5 分钟,够用户授权 + 回调)
|
||||
OAUTH_STATE_TTL = 300
|
||||
# SSO token 长度
|
||||
SSO_TOKEN_BYTES = 32
|
||||
|
||||
|
||||
def _sso_enabled() -> bool:
|
||||
"""检查是否启用企微 SSO。"""
|
||||
import os
|
||||
if os.getenv("WECOM_SSO_ENABLED", "false").lower() == "true":
|
||||
return True
|
||||
if getattr(settings, "wecom_sso_enabled", False):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_oauth_callback_url(request: Request) -> str:
|
||||
"""拼接 OAuth 回调 URL (绝对地址)。
|
||||
|
||||
企微要求 redirect_uri 必须用可信域名(itsupport.servyou.com.cn)。
|
||||
不读 request.base_url 因为它可能是 127.0.0.1:8000(开发环境)。
|
||||
"""
|
||||
# 优先用 settings 里的配置
|
||||
base = getattr(settings, "wecom_sso_callback_base", None)
|
||||
if not base:
|
||||
# 兜底: 读环境变量,默认生产域名
|
||||
import os
|
||||
base = os.getenv("WECOM_SSO_CALLBACK_BASE", "https://itsupport.servyou.com.cn")
|
||||
return f"{base.rstrip('/')}/api/auth_wecom/sso/callback"
|
||||
|
||||
|
||||
def _build_oauth_url(state: str, callback_url: str) -> str:
|
||||
"""拼企微 OAuth2 授权 URL。
|
||||
|
||||
文档: https://developer.work.weixin.qq.com/document/path/91022
|
||||
"""
|
||||
params = {
|
||||
"appid": settings.wecom_corp_id,
|
||||
"redirect_uri": callback_url,
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_base", # 静默授权
|
||||
"state": state,
|
||||
"agentid": settings.wecom_agent_id,
|
||||
}
|
||||
query = urllib.parse.urlencode(params)
|
||||
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
|
||||
|
||||
|
||||
@router.get("/sso/init")
|
||||
async def sso_init(
|
||||
request: Request,
|
||||
next: str = Query("/itdesk/", description="登录后跳转路径"),
|
||||
redis_client = Depends(get_redis),
|
||||
):
|
||||
"""初始化 SSO: 生成 state,302 跳转到企微 OAuth2 授权页。
|
||||
|
||||
Args:
|
||||
next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/
|
||||
"""
|
||||
if not _sso_enabled():
|
||||
raise AppException(1001, "企微 SSO 未启用, 请用扫码登录")
|
||||
|
||||
# 1. 生成 state(防 CSRF + 携带 next 路径)
|
||||
state = secrets.token_urlsafe(24)
|
||||
state_payload = {
|
||||
"next": next,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
await redis_client.setex(
|
||||
f"wecom_sso:state:{state}",
|
||||
OAUTH_STATE_TTL,
|
||||
str(state_payload).encode("utf-8"),
|
||||
)
|
||||
|
||||
# 2. 拼企微 OAuth URL
|
||||
callback_url = _get_oauth_callback_url(request)
|
||||
oauth_url = _build_oauth_url(state, callback_url)
|
||||
|
||||
logger.info(f"SSO init: state={state[:8]}..., next={next}")
|
||||
return RedirectResponse(url=oauth_url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/sso/callback")
|
||||
async def sso_callback(
|
||||
code: str = Query(..., description="企微 OAuth2 授权 code"),
|
||||
state: str = Query(..., description="防 CSRF state"),
|
||||
redis_client = Depends(get_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。"""
|
||||
# 1. 校验 state(防 CSRF)
|
||||
state_key = f"wecom_sso:state:{state}"
|
||||
state_raw = await redis_client.get(state_key)
|
||||
if not state_raw:
|
||||
raise AppException(1002, "SSO state 已过期或无效, 请重新进入")
|
||||
|
||||
# 删除 state(一次性)
|
||||
await redis_client.delete(state_key)
|
||||
|
||||
import ast
|
||||
state_data = ast.literal_eval(state_raw.decode("utf-8"))
|
||||
next_path = state_data.get("next", "/itdesk/")
|
||||
|
||||
# 2. 用 code 换 userid
|
||||
wecom = WecomService(redis_client)
|
||||
try:
|
||||
oauth_info = await wecom.get_oauth_user_info(code)
|
||||
user_id = oauth_info.get("userid", "")
|
||||
if not user_id:
|
||||
raise AppException(1003, "企微 OAuth 返回 userid 为空")
|
||||
|
||||
user_info = await wecom.get_user_info(user_id)
|
||||
name = user_info.get("name", user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"SSO callback 调企微 API 失败: code={code[:8]}..., error={e}")
|
||||
raise AppException(1004, f"企微身份识别失败: {str(e)}")
|
||||
finally:
|
||||
try:
|
||||
await wecom.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 查 role (user/agent/admin)
|
||||
role_stmt = (
|
||||
select(Role)
|
||||
.join(UserRole, Role.id == UserRole.role_id)
|
||||
.where(UserRole.employee_id == user_id)
|
||||
)
|
||||
role_result = await db.execute(role_stmt)
|
||||
roles = role_result.scalars().all()
|
||||
|
||||
if not roles:
|
||||
# 没有绑定角色: 跳"无权限"页
|
||||
logger.warning(f"SSO: user_id={user_id} 没绑定任何角色")
|
||||
return RedirectResponse(url=f"/itdesk/no-role?user_id={user_id}", status_code=302)
|
||||
|
||||
# 4. 选最高权限角色 (admin > agent > user)
|
||||
role_priority = {"admin": 3, "agent": 2, "user": 1}
|
||||
best_role = max(roles, key=lambda r: role_priority.get(r.name, 0))
|
||||
role_name = best_role.name
|
||||
|
||||
# 5. 生成 SSO token(随机 + Redis 存 8 小时)
|
||||
sso_token = secrets.token_urlsafe(SSO_TOKEN_BYTES)
|
||||
sso_payload = {
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"role": role_name,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
import json
|
||||
await redis_client.setex(
|
||||
f"wecom_sso:token:{sso_token}",
|
||||
8 * 3600, # 8 小时
|
||||
json.dumps(sso_payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
# 6. 跳转到 next + token
|
||||
separator = "&" if "?" in next_path else "?"
|
||||
redirect_url = f"{next_path}{separator}sso_token={sso_token}"
|
||||
|
||||
logger.info(f"SSO 成功: user_id={user_id}, role={role_name}, next={next_path}")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/sso/verify")
|
||||
async def sso_verify(
|
||||
sso_token: str = Query(..., description="SSO token"),
|
||||
redis_client = Depends(get_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。"""
|
||||
import json
|
||||
token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}")
|
||||
if not token_raw:
|
||||
raise AppException(1005, "SSO token 已过期或无效")
|
||||
|
||||
# 一次性 token(防止泄漏后被滥用)
|
||||
await redis_client.delete(f"wecom_sso:token:{sso_token}")
|
||||
|
||||
payload = json.loads(token_raw.decode("utf-8"))
|
||||
return {
|
||||
"code": 0,
|
||||
"data": payload,
|
||||
}
|
||||
@@ -207,3 +207,16 @@ api_router.include_router(mfa_router, tags=["MFA二次认证"])
|
||||
# MFA 管理员重置 API (Phase 2.1 task #17,丢手机兜底)
|
||||
# POST /api/admin/mfa/reset/{employee_id} — 管理员重置指定员工 MFA
|
||||
api_router.include_router(mfa_admin_router, tags=["MFA管理(管理员)"])
|
||||
|
||||
# 企微 SSO (v0.7.1 task #85)
|
||||
# GET /api/auth_wecom/sso/init — 企微浏览器 UA 检测后初始化 SSO
|
||||
# GET /api/auth_wecom/sso/callback — 企微 OAuth2 回调,用 code 换 userid → 跳端点
|
||||
# GET /api/auth_wecom/sso/verify — 前端用 SSO token 换用户身份(一次性)
|
||||
from app.api.auth_wecom_sso import router as auth_wecom_sso_router
|
||||
api_router.include_router(auth_wecom_sso_router, tags=["企微SSO"])
|
||||
|
||||
# 审计日志 API (v0.7.1 task #89)
|
||||
# GET /api/admin/audit-logs — 分页 + 多维过滤(给 auditor / admin 角色用)
|
||||
# 权限要求: audit_log:read:all (RBAC 装饰器强制)
|
||||
from app.api.audit_logs import router as audit_logs_router
|
||||
api_router.include_router(audit_logs_router, tags=["审计日志"])
|
||||
|
||||
@@ -124,6 +124,16 @@ class Settings(BaseSettings):
|
||||
# 设备申请审批模板ID(在企微审批应用设置中获取)
|
||||
approval_template_device: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# v0.7.1 企微 SSO 入口配置 (task #85)
|
||||
# ----------------------------------------------------------------------
|
||||
# 是否启用企微 SSO(true = 优先用企微 OAuth2 静默授权,失败时降级扫码)
|
||||
# 通过环境变量 WECOM_SSO_ENABLED 控制(默认 false,避免老用户被打扰)
|
||||
wecom_sso_enabled: bool = False
|
||||
# SSO OAuth 回调 base URL(企微要求 redirect_uri 必须用可信域名)
|
||||
# 生产: https://itsupport.servyou.com.cn 开发: http://localhost:5176
|
||||
wecom_sso_callback_base: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# v0.5.4 应急页身份检测配置
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — RBAC 角色种子数据 (v0.7.1 task #86)
|
||||
# =============================================================================
|
||||
# 启动时调用,把 5 角色 + 权限矩阵写入 roles 表
|
||||
# 兼容"角色已存在"的场景: 不重复插入,但更新 permissions
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.role import Role
|
||||
from app.services.rbac_service import (
|
||||
ROLE_METADATA,
|
||||
get_role_default_permissions,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def seed_rbac_roles(db: AsyncSession) -> int:
|
||||
"""种子 RBAC 5 角色。
|
||||
|
||||
行为:
|
||||
1. 遍历 ROLE_METADATA
|
||||
2. 角色不存在 → 创建(UUID + 默认 permissions)
|
||||
3. 角色存在 → 更新 display_name / description / permissions
|
||||
(不动 is_default,避免影响手动设置)
|
||||
|
||||
Returns:
|
||||
int: 新建角色数
|
||||
"""
|
||||
created_count = 0
|
||||
|
||||
for role_name, meta in ROLE_METADATA.items():
|
||||
# 查询是否已存在
|
||||
stmt = select(Role).where(Role.name == role_name)
|
||||
result = await db.execute(stmt)
|
||||
role = result.scalars().first()
|
||||
|
||||
permissions = get_role_default_permissions(role_name)
|
||||
|
||||
if role:
|
||||
# 更新现有角色(不动 is_default,防止覆盖手动设置)
|
||||
role.display_name = meta["display_name"]
|
||||
role.description = meta["description"]
|
||||
role.permissions = permissions
|
||||
role.updated_at = datetime.now()
|
||||
logger.debug(f"更新角色: {role_name} ({len(permissions)} 项权限)")
|
||||
else:
|
||||
# 创建新角色
|
||||
role = Role(
|
||||
id=str(uuid.uuid4()),
|
||||
name=role_name,
|
||||
display_name=meta["display_name"],
|
||||
description=meta["description"],
|
||||
permissions=permissions,
|
||||
is_default=(meta["is_default"] == "true"),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
db.add(role)
|
||||
created_count += 1
|
||||
logger.info(f"创建角色: {role_name} ({len(permissions)} 项权限)")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"RBAC 角色种子完成: 新建 {created_count} 个")
|
||||
return created_count
|
||||
@@ -284,6 +284,110 @@ def require_admin(func):
|
||||
return require_role("admin")(func)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 细粒度权限装饰器 (v0.7.1 task #86 — RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围)
|
||||
# =============================================================================
|
||||
# 权限字符串格式: "resource:action:scope"
|
||||
# 例: "conversation:read:all"
|
||||
#
|
||||
# 用法:
|
||||
# @router.get("/api/admin/agents")
|
||||
# @require_permission("agent:read:all")
|
||||
# async def list_agents(...): ...
|
||||
#
|
||||
# 行为:
|
||||
# 1. 装饰器只检查"是否拥有权限字符串",不直接执行 DB 查询
|
||||
# 2. 实际检查在 rbac_service.check_permission() 里
|
||||
# 3. 用户的权限从 UserInfo.permissions 字段读(由 get_current_user 解析 token 时填入)
|
||||
# =============================================================================
|
||||
|
||||
def require_permission(
|
||||
resource: str,
|
||||
action: str,
|
||||
scope: str = "own",
|
||||
):
|
||||
"""细粒度权限验证装饰器(v0.7.1 task #86)。
|
||||
|
||||
Args:
|
||||
resource: 资源(conversation/agent/system_config/audit_log)
|
||||
action: 操作(read/create/update/delete)
|
||||
scope: 数据范围(own/department/all)
|
||||
|
||||
Example:
|
||||
@router.get("/api/admin/agents")
|
||||
@require_permission("agent", "read", "all")
|
||||
async def list_agents(current_user: UserInfo = Depends(get_current_user)):
|
||||
...
|
||||
"""
|
||||
perm_string = f"{resource}:{action}:{scope}"
|
||||
|
||||
def decorator(func):
|
||||
sig = inspect.signature(func)
|
||||
params = list(sig.parameters.values())
|
||||
params.append(
|
||||
inspect.Parameter(
|
||||
'current_user',
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=UserInfo,
|
||||
default=Depends(get_current_user),
|
||||
)
|
||||
)
|
||||
new_sig = sig.replace(parameters=params)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
current_user = kwargs.pop('current_user')
|
||||
|
||||
# 拉用户所有角色的 permissions
|
||||
# 注: UserInfo.roles 是角色名列表,permissions 是 {role: [perm]} 字典
|
||||
# 首次实现简化: 角色判断 + admin 通配符
|
||||
# 完整实现需要查 DB 拉 permissions,见 rbac_service.check_permission
|
||||
|
||||
user_roles = set(current_user.roles or [])
|
||||
|
||||
# 1. admin 角色直通(通配符 *:*:all)
|
||||
if "admin" in user_roles:
|
||||
return await func(*args, current_user=current_user, **kwargs)
|
||||
|
||||
# 2. 其他角色: 走 rbac_service.check_permission
|
||||
# 简化: 这里只看角色名,不查 DB(性能考虑)
|
||||
# 实际生产可加缓存或预加载到 token
|
||||
from app.services.rbac_service import (
|
||||
ROLE_PERMISSIONS,
|
||||
check_permission,
|
||||
)
|
||||
# 把 ROLE_PERMISSIONS 转成 {role_name: [perm_string]} 格式
|
||||
user_perms_dict = {
|
||||
role: [f"{r}:{a}:{s}" for (r, a, s) in perms]
|
||||
for role, perms in ROLE_PERMISSIONS.items()
|
||||
}
|
||||
|
||||
has_perm = check_permission(
|
||||
user_roles=list(user_roles),
|
||||
user_permissions=user_perms_dict,
|
||||
required_resource=resource,
|
||||
required_action=action,
|
||||
required_scope=scope,
|
||||
)
|
||||
|
||||
if not has_perm:
|
||||
logger.warning(
|
||||
f"用户 {current_user.employee_id} 权限不足: "
|
||||
f"角色 {list(user_roles)}, 缺 {perm_string}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"权限不足: 需要 {perm_string}",
|
||||
)
|
||||
|
||||
return await func(*args, current_user=current_user, **kwargs)
|
||||
|
||||
wrapper.__signature__ = new_sig
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 高危操作 OTP 守卫依赖(Phase 1.3 task #19)
|
||||
# =============================================================================
|
||||
|
||||
+9
-4
@@ -210,7 +210,12 @@ async def _init_default_data():
|
||||
# 5. 初始化软件下载入口
|
||||
await _init_software_downloads(db, SoftwareDownload)
|
||||
|
||||
# 6. (dev 模式)初始化 demo 会话,让前端有数据可发
|
||||
# 6. v0.7.1 task #86 — RBAC 5 角色种子(细粒度权限)
|
||||
# 行为: 已有角色更新 permissions,缺则新建
|
||||
from app.data.seed_rbac import seed_rbac_roles
|
||||
await seed_rbac_roles(db)
|
||||
|
||||
# 7. (dev 模式)初始化 demo 会话,让前端有数据可发
|
||||
# 真因:之前没建,前端硬编码的 conv-001 调 POST /messages 返 "会话不存在" 3003
|
||||
if getattr(settings, 'dev_mode', False) or os.getenv('DEV_MODE', '').lower() == 'true':
|
||||
await _init_demo_conversations(db)
|
||||
@@ -752,7 +757,8 @@ def create_app() -> FastAPI:
|
||||
"""
|
||||
try:
|
||||
# 检查数据库
|
||||
from app.database import engine
|
||||
from app.database import _get_engine
|
||||
engine = _get_engine()
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
db_status = "ok"
|
||||
@@ -761,8 +767,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
try:
|
||||
# 检查 Redis
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
from app.config import settings
|
||||
redis_client = settings.create_redis_client()
|
||||
await redis_client.ping()
|
||||
redis_status = "ok"
|
||||
|
||||
@@ -123,21 +123,10 @@ class Agent(Base):
|
||||
comment="技能标签列表(电脑/软件/外设/网络/安全/资产/其他)",
|
||||
)
|
||||
|
||||
# OTP密钥(用于TOTP动态码验证,为空表示未绑定)
|
||||
otp_secret: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="OTP密钥(Base32编码)",
|
||||
)
|
||||
|
||||
# OTP是否启用(admin角色强制启用)
|
||||
otp_enabled: Mapped[bool] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="OTP是否启用(0=否, 1=是)",
|
||||
)
|
||||
# v0.7.1: 删除 otp_secret / otp_enabled 字段
|
||||
# 原因: 与下方 mfa_secret / mfa_enabled 完全重复(都是 TOTP secret)
|
||||
# 旧 OTP 字段只用于高危操作前的二次验证,mfa 字段已涵盖该用途
|
||||
# 迁移策略: alembic 010 改为 DROP COLUMN otp_secret, otp_enabled
|
||||
|
||||
# 本地密码哈希(可选,用于本地密码认证)
|
||||
# 使用 bcrypt 加密存储,不存储明文密码
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 审计日志模型
|
||||
# =============================================================================
|
||||
# 说明: 对应数据库 audit_logs 表,记录所有高危/RBAC 操作 + 登录/MFA 事件
|
||||
# 给 auditor 角色 + admin 提供只读审计能力
|
||||
#
|
||||
# 何时写入:
|
||||
# - 高危操作 (role_change / config_change / data_export / account_disable / account_create_reset)
|
||||
# - RBAC 操作 (assign_role / revoke_role / create_mapping_rule / delete_mapping_rule)
|
||||
# - 登录事件 (qrcode_login / sso_login / password_login)
|
||||
# - MFA 事件 (bind / verify / reset)
|
||||
# - 业务敏感操作 (resolve_conversation / transfer_conversation)
|
||||
# =============================================================================
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Index, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""审计日志模型 — 对应 audit_logs 表。
|
||||
|
||||
Attributes:
|
||||
id: 日志唯一标识(UUID)
|
||||
employee_id: 操作人(企微 UserID,系统操作填 "system")
|
||||
action: 操作类型(如 "role_change", "login", "mfa_verify")
|
||||
resource: 目标资源类型("agent" / "conversation" / "system_config" 等)
|
||||
resource_id: 目标资源 ID
|
||||
details: 详细上下文(JSON,前后值/IP/UA 等)
|
||||
result: "success" / "failure" / "partial"
|
||||
ip_address: 操作来源 IP(可选)
|
||||
user_agent: 操作来源 UA(可选)
|
||||
created_at: 时间
|
||||
"""
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
# 主键:UUID
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
# 操作人(企微 UserID, 系统操作填 "system")
|
||||
employee_id: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
comment="操作人(employee_id / 'system')",
|
||||
)
|
||||
|
||||
# 操作类型
|
||||
# 例: "role_change" / "config_change" / "login" / "mfa_verify" /
|
||||
# "qrcode_login" / "sso_login" / "password_login" /
|
||||
# "resolve_conversation" / "transfer_conversation" / "data_export"
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="操作类型",
|
||||
)
|
||||
|
||||
# 目标资源类型
|
||||
# 例: "agent" / "conversation" / "system_config" / "role" / "user_role"
|
||||
resource: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="目标资源类型",
|
||||
)
|
||||
|
||||
# 目标资源 ID(字符串,跨表通用)
|
||||
resource_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="目标资源 ID",
|
||||
)
|
||||
|
||||
# 详细上下文(JSON)
|
||||
# 例: {"role": "agent", "reason": "新员工转岗", "ip": "10.80.0.5"}
|
||||
details: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="详细上下文(JSON)",
|
||||
)
|
||||
|
||||
# 结果
|
||||
# "success" / "failure" / "partial"
|
||||
result: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="success",
|
||||
comment="执行结果",
|
||||
)
|
||||
|
||||
# 来源 IP
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
comment="来源 IP",
|
||||
)
|
||||
|
||||
# 来源 User-Agent
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="来源 User-Agent",
|
||||
)
|
||||
|
||||
# 时间
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=datetime.now,
|
||||
comment="时间",
|
||||
)
|
||||
|
||||
# 索引:按 employee_id / action / time 查询
|
||||
__table_args__ = (
|
||||
Index("idx_audit_employee_id", "employee_id"),
|
||||
Index("idx_audit_action", "action"),
|
||||
Index("idx_audit_resource", "resource", "resource_id"),
|
||||
Index("idx_audit_created_at", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog(action={self.action}, employee={self.employee_id}, result={self.result})>"
|
||||
@@ -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