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
@@ -0,0 +1,58 @@
"""drop legacy agent OTP fields
Revision ID: 026_drop_agent_otp_legacy
Revises: 025_messages_id_uuid
Create Date: 2026-06-22
v0.7.1: 清理 v0.5.6 引入的 otp_secret / otp_enabled 双字段
原因: 旧 OTP 字段只用于高危操作前的二次验证,mfa_secret/mfa_enabled(migration 023)
已涵盖该用途。两个字段名不同导致 v0.7.0 生产报错:
column agents.otp_secret does not exist(alembic 010 之前没在生产跑过)
策略: 用 IF EXISTS 兼容"列不存在"情况(因为生产数据库可能从来没建过这列)
DROP COLUMN 不会破坏生产 — mfa_secret 是新的生产字段,otp_secret 只是历史遗留
下游: agents.py / admin_api.py 改用 mfa_secret/mfa_enabled
Agent 模型删 otp_secret/otp_enabled 字段
回退: 此 migration 的 downgrade 重新添加 otp_secret/otp_enabled
如果生产用过 OTP 的话要回退(目前 IT 支持服务未正式上线,无此风险)
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '026_drop_agent_otp_legacy'
down_revision = '025_messages_id_uuid'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""删除 legacy OTP 字段(IF EXISTS 兼容列不存在的场景)。"""
op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS otp_secret")
op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS otp_enabled")
def downgrade() -> None:
"""回退: 重新添加 legacy OTP 字段。"""
op.add_column(
'agents',
sa.Column(
'otp_secret',
sa.String(64),
nullable=True,
comment='TOTP 密钥(base32,绑定时生成)'
)
)
op.add_column(
'agents',
sa.Column(
'otp_enabled',
sa.Boolean(),
nullable=False,
server_default=sa.text('false'),
comment='是否启用 OTP 二次验证'
)
)
@@ -0,0 +1,80 @@
"""audit_logs 表 — 高危操作/登录/MFA 审计日志
Revision ID: 027_audit_logs
Revises: 026_drop_agent_otp_legacy
Create Date: 2026-06-22 (v0.7.1)
v0.7.1 task #89 实施,配合 RBAC 5 角色的 audit_log 资源(给 auditor 角色只读用)
字段:
- id: UUID 主键
- employee_id: 操作人(企微 UserID / 'system')
- action: 操作类型
- resource: 目标资源类型
- resource_id: 目标资源 ID
- details: JSON 详细上下文
- result: success / failure / partial
- ip_address: 来源 IP
- user_agent: 来源 UA
- created_at: 时间
索引:
- idx_audit_employee_id: 按操作人查
- idx_audit_action: 按操作类型查
- idx_audit_resource: 按资源类型+ID 查
- idx_audit_created_at: 按时间范围查(默认倒序)
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '027_audit_logs'
down_revision = '026_drop_agent_otp_legacy'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""建 audit_logs 表 + 索引。"""
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table('audit_logs'):
op.create_table(
'audit_logs',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('employee_id', sa.String(100), nullable=False,
comment='操作人(employee_id / system)'),
sa.Column('action', sa.String(50), nullable=False,
comment='操作类型'),
sa.Column('resource', sa.String(50), nullable=False,
comment='目标资源类型'),
sa.Column('resource_id', sa.String(100), nullable=True,
comment='目标资源 ID'),
sa.Column('details', sa.JSON, nullable=True,
comment='详细上下文(JSON)'),
sa.Column('result', sa.String(20), nullable=False, server_default='success',
comment='执行结果'),
sa.Column('ip_address', sa.String(64), nullable=True,
comment='来源 IP'),
sa.Column('user_agent', sa.Text, nullable=True,
comment='来源 User-Agent'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
comment='时间'),
)
# 4 个索引 (IF NOT EXISTS 兼容)
op.execute("CREATE INDEX IF NOT EXISTS idx_audit_employee_id ON audit_logs (employee_id)")
op.execute("CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs (action)")
op.execute("CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_logs (resource, resource_id)")
op.execute("CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs (created_at)")
def downgrade() -> None:
"""删 audit_logs 表(顺序: 删索引 → 删表)。"""
op.execute("DROP INDEX IF EXISTS idx_audit_created_at")
op.execute("DROP INDEX IF EXISTS idx_audit_resource")
op.execute("DROP INDEX IF EXISTS idx_audit_action")
op.execute("DROP INDEX IF EXISTS idx_audit_employee_id")
op.execute("DROP TABLE IF EXISTS audit_logs")
+4 -2
View File
@@ -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()
+129
View File
@@ -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
View File
@@ -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()
+75
View File
@@ -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"],
})
+1
View File
@@ -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(),
})
+228
View File
@@ -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,
}
+13
View File
@@ -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=["审计日志"])
+10
View File
@@ -124,6 +124,16 @@ class Settings(BaseSettings):
# 设备申请审批模板ID(在企微审批应用设置中获取)
approval_template_device: str = ""
# ----------------------------------------------------------------------
# v0.7.1 企微 SSO 入口配置 (task #85)
# ----------------------------------------------------------------------
# 是否启用企微 SSOtrue = 优先用企微 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 应急页身份检测配置
# ----------------------------------------------------------------------
+71
View File
@@ -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
+104
View File
@@ -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
View File
@@ -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"
+4 -15
View File
@@ -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 加密存储,不存储明文密码
+130
View File
@@ -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})>"
+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
+56
View File
@@ -0,0 +1,56 @@
"""v4 - 最干净的版本,无中文 docstring,纯 ASCII,堡垒机粘贴不会破坏。
"""
import asyncio
import os
import sys
import traceback
os.chdir("/app")
sys.path.insert(0, "/app")
import redis.asyncio as aioredis
print("[DEBUG] REDIS_URL env =", repr(os.environ.get("REDIS_URL")))
try:
from app.config import settings
print("[DEBUG] settings.redis_url =", repr(settings.redis_url))
except Exception as e:
print("[ERROR] import settings:", e)
traceback.print_exc()
sys.exit(1)
REDIS_URL = os.environ.get("REDIS_URL") or settings.redis_url
print("[DEBUG] using REDIS_URL =", repr(REDIS_URL))
async def main():
redis = aioredis.from_url(REDIS_URL, protocol=2, decode_responses=True)
try:
await redis.ping()
print("[DEBUG] redis ping OK")
except Exception as e:
print("[ERROR] redis ping failed:", e)
traceback.print_exc()
await redis.close()
sys.exit(2)
from app.services.token_service import TokenService
svc = TokenService(redis)
token = await svc.create_token(
employee_id="dev-admin-001",
name="admin",
roles=["admin"],
department="IT",
login_source="prod-cli",
)
print("ADMIN_TOKEN=" + token)
await redis.close()
try:
asyncio.run(main())
except Exception as e:
print("[FATAL]", e)
traceback.print_exc()
sys.exit(99)
+89
View File
@@ -0,0 +1,89 @@
"""
准备分段 base64 payload,让 jumpserver 终端拼装并写入 /tmp/xxx.py
策略:
1. 在本机把 2 个 .py 转 base64
2. 按 N=400 字符一段切分(终端粘贴安全长度)
3. 生成一段 shell 脚本,内容是:
cat > /tmp/auth_qrcode.py.b64 << 'B64_EOF'
段1
段2
...
B64_EOF
base64 -d /tmp/auth_qrcode.py.b64 > /tmp/auth_qrcode.py
(同理 qrcode_service.py)
4. 把这个脚本写到 webcli_output 目录,用 jumpserver 终端 cat 出来
"""
import base64
import re
from pathlib import Path
UPLOAD_DIR = Path(r"C:\Users\simon\.workbuddy\skills\jumpserver-automation-shareable\scripts\webcli_output")
files = [
(UPLOAD_DIR / "auth_qrcode.py", "auth_qrcode.py"),
(UPLOAD_DIR / "qrcode_service.py", "qrcode_service.py"),
]
# jumpserver terminal 一次粘贴安全长度: ~500 字符
# 留余量,按 400 字符切
CHUNK_SIZE = 400
def shell_escape(s):
"""shell 单引号字符串转义"""
return s.replace("'", "'\\''")
def make_upload_script(src_path: Path, dest_name: str, chunk_size=CHUNK_SIZE) -> str:
"""生成上传用的 shell 脚本: base64 分段 + 拼装 + 解码"""
content = src_path.read_bytes()
b64 = base64.b64encode(content).decode("ascii")
chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]
lines = []
# 1. 清空
lines.append(f"rm -f /tmp/{dest_name}.b64 /tmp/{dest_name}")
# 2. 写 base64 分段(每段用 echo >> 追加,避免 heredoc 卡住)
for i, chunk in enumerate(chunks):
lines.append(f"echo -n '{chunk}' >> /tmp/{dest_name}.b64")
# 3. base64 -d 还原
lines.append(f"base64 -d /tmp/{dest_name}.b64 > /tmp/{dest_name}")
# 4. 验证大小
lines.append(f"ls -la /tmp/{dest_name} && wc -c /tmp/{dest_name} && head -c 100 /tmp/{dest_name}")
# 5. 清理 b64
lines.append(f"rm -f /tmp/{dest_name}.b64")
return "\n".join(lines)
# 生成每个文件的上传脚本
combined = []
combined.append("#!/bin/bash")
combined.append("# Auto-generated upload script (copy each line to jumpserver terminal)")
combined.append(f"# Generated at: {Path(__file__).name}")
combined.append("")
combined.append("set -e")
combined.append("")
for src, name in files:
if not src.exists():
print(f"{src} not found")
continue
combined.append(f"\n# ===== {name} ({src.stat().st_size} bytes) =====")
script = make_upload_script(src, name)
combined.append(script)
combined.append("")
combined.append('echo ""')
combined.append('echo "=== All files uploaded ==="')
combined.append("ls -la /tmp/auth_qrcode.py /tmp/qrcode_service.py")
output_path = UPLOAD_DIR / "upload_files.sh"
output_path.write_text("\n".join(combined), encoding="utf-8")
print(f"✅ Generated: {output_path}")
print(f" Total lines: {len(combined)}")
print(f" Total bytes: {output_path.stat().st_size}")
print()
print("📋 用法:")
print(" 1. 在 jumpserver 终端跑: cd /tmp/")
print(" 2. 把 upload_files.sh 内容逐行粘贴(用 jumpserver 终端 '粘贴'功能)")
print(" 3. 或者更稳: 复制整个脚本内容到 jumpserver 终端(右键粘贴),回车执行")
+29
View File
@@ -8,6 +8,35 @@
# 4. 测试用数据库会话
# =============================================================================
# ---------------------------------------------------------------------------
# Windows GBK 兼容补丁: 强制 slowapi/starlette 用 UTF-8 读 .env
# 原因: slowapi 0.1.9 内部用 starlette.config.Config 读 .env,默认 encoding
# 走 locale.getpreferredencoding() (Windows=GBK)。backend/.env 是 UTF-8
# 含中文,GBK 解码失败 → UnicodeDecodeError,pytest 卡死。
# 修法: 替换 _read_file 强制 utf-8。生产 Linux 不受影响。
# 详见 [[conftest-gbk-env-patch]]
# ---------------------------------------------------------------------------
import starlette.config as _starlette_config
import io as _io
def _patched_read_file(self, env_file):
"""强制 utf-8 编码读 .env,绕开 Windows GBK 默认值。"""
if not env_file:
return {}
try:
with open(env_file, encoding="utf-8") as f:
return {
line.split("=", 1)[0].strip(): line.split("=", 1)[1].strip()
for line in f.readlines()
if line.strip() and not line.startswith("#")
}
except FileNotFoundError:
return {}
_starlette_config.Config._read_file = _patched_read_file
import asyncio
import uuid
from datetime import datetime