Files
yawan-workbench/server/app/auth.py
T

546 lines
26 KiB
Python
Raw Normal View History

"""本地工作台认证与 RBAC:不透明令牌、可撤销会话、最小权限映射。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import re
import secrets
from datetime import datetime, timedelta, timezone
from sqlmodel import Session, select
from .models import AppUser, AuthSession, RoleDefinition, now
SESSION_HOURS = 12
ROLE_PERMISSIONS = {
"admin": {"*"},
"editor": {"read", "feature:tasks", "feature:runs", "feature:audit", "feature:agents", "feature:digital-humans", "feature:flows", "feature:knowledge", "feature:usage", "feature:system-logs", "feature:models", "feature:ops", "tasks:read", "tasks:write", "tasks:review", "tasks:deliver", "runs:read", "audit:read",
"kb:read", "kb:write", "agents:read", "agents:write", "digital_humans:read", "flows:read",
"memories:read", "memories:write", "memories:review", "logs:read", "usage:read", "system_logs:read"},
"reviewer": {"read", "feature:tasks", "feature:runs", "feature:knowledge", "feature:agents", "tasks:read", "tasks:review", "tasks:deliver", "runs:read", "kb:read", "memories:read", "memories:review"},
"viewer": {"read", "feature:knowledge", "kb:read"},
}
VALID_ROLES = set(ROLE_PERMISSIONS)
ROLE_LABELS = {"admin": "管理员", "editor": "编辑", "reviewer": "审查", "viewer": "只读"}
# P0 细化:按资源域拆分权限组,四个功能域 + 一组系统管理。
PERMISSION_LABELS = {
# 通用
"read": "查看工作台",
# 兼容旧接口映射:错误提示也必须显示具体中文权限,不能退回“其他权限”。
"config:read": "系统配置:查看",
"config:write": "系统配置:修改",
"content:write": "内容生产:编辑",
"review": "内容审查",
"admin:manage": "系统管理",
# 内容生产
"tasks:write": "任务:创建与运行",
"tasks:review": "任务:审查(通过/打回/跳过)",
"tasks:deliver": "任务:交付与下载成稿",
"feature:tasks": "功能栏:内容任务",
"feature:runs": "功能栏:运行记录",
"feature:audit": "功能栏:操作日志",
# 资产与知识
"kb:read": "知识库:查看与检索",
"kb:write": "知识库:导入/删除/分类",
"agents:write": "智能体:Agent/画布/技能/模板管理",
"feature:agents": "功能栏:Agent 管理",
"feature:digital-humans": "功能栏:数字人",
"feature:flows": "功能栏:画布编排",
"feature:knowledge": "功能栏:知识库",
"memories:write": "Agent 记忆:管理与沉淀",
"memories:review": "Agent 记忆:审批沉淀申请",
# 数据与洞察
"logs:read": "日志:运行追踪与用量查看",
"feature:usage": "功能栏:用量分析",
"feature:system-logs": "功能栏:系统日志",
"ops:manage": "运营:看板/备份/死信处理",
# 系统管理
"providers:write": "模型:Provider 与密钥管理",
"feature:models": "功能栏:模型管理",
"feature:access": "功能栏:访问控制",
"feature:flags": "功能栏:功能开关",
"feature:ops": "功能栏:运营看板",
"billing:manage": "计费:点数与用量配额",
"users:manage": "成员:账号、启停与删除",
"roles:manage": "角色:权限矩阵管理",
"audit:manage": "授权:资源授权与导出",
# 左侧功能栏独立查看权限(功能之间互不串门)
"tasks:read": "内容任务:查看",
"runs:read": "运行记录:查看",
"audit:read": "操作日志:查看",
"agents:read": "Agent 与技能:查看",
"digital_humans:read": "数字人:查看",
"flows:read": "画布编排:查看",
"providers:read": "模型管理:查看",
"usage:read": "用量分析:查看",
"system_logs:read": "系统日志:查看",
"access:read": "访问控制:查看",
"flags:read": "功能开关:查看",
"memories:read": "Agent 记忆:查看",
"ops:read": "运营数据:查看",
"*": "全部权限",
}
# 权限目录分组元数据,供访问控制页按「功能栏 → 抽屉 → 具体操作」展示。
# 每个权限仍以原有的扁平 key 存储,分组只负责展示与解释,保持现有角色数据和接口兼容。
PERMISSION_GROUPS = (
{
"key": "common",
"title": "基础访问",
"hint": "登录后进入工作台并查看可见内容",
"items": (
("feature:tasks", "内容任务功能", "显示并进入内容任务及训练导图"),
("read", "查看工作台", "登录并查看当前账号有权限访问的页面和资源"),
),
},
{
"key": "content",
"title": "内容生产",
"hint": "从建任务到交付成稿的主链路",
"items": (
("feature:agents", "Agent 功能", "显示 Agent 工作区"),
("memories:read", "查看 Agent 记忆", "查看 Agent 长期记忆及其来源"),
("memories:write", "写入 Agent 记忆", "新增或修改 Agent 记忆"),
("memories:review", "审批 Agent 记忆", "审批记忆沉淀申请(批准或拒绝)"),
("feature:digital-humans", "数字人功能", "显示数字人管理"),
("feature:flows", "画布编排功能", "显示画布编排"),
("feature:knowledge", "知识库功能", "显示知识库"),
("tasks:write", "创建与运行任务", "新建任务、上传导图、触发拆解和 Agent 生成"),
("tasks:read", "查看内容任务", "查看任务列表、任务详情和生成进度"),
("tasks:review", "审查内容", "对生成内容通过、打回或跳过,并审批运行中的工具请求"),
("tasks:deliver", "交付成稿", "审查最终稿、下载导出并确认交付"),
),
},
{
"key": "asset",
"title": "资产与知识",
"hint": "知识库和 Agent 配置的日常维护",
"items": (
("feature:runs", "运行记录功能", "显示运行记录和通知"),
("feature:audit", "操作日志功能", "显示操作日志"),
("feature:usage", "用量分析功能", "显示用量与余额"),
("feature:system-logs", "系统日志功能", "显示系统日志"),
("feature:ops", "运营看板功能", "显示运营看板"),
("kb:read", "查看知识库", "浏览知识库、文件与检索测试"),
("kb:write", "管理知识库", "建库、导入删除文件、重建索引和分类整理"),
("agents:write", "管理智能体", "新建编辑 Agent、画布、技能、提示模板和标签"),
("agents:read", "查看 Agent 与技能", "查看 Agent、技能库及其运行配置"),
("digital_humans:read", "查看数字人", "查看数字人档案、版本和绑定状态"),
("flows:read", "查看画布编排", "查看画布流程及节点配置"),
),
},
{
"key": "insight",
"title": "数据与洞察",
"hint": "看得到系统在发生什么",
"items": (
("feature:models", "模型管理功能", "显示模型管理"),
("feature:access", "访问控制功能", "显示成员和角色管理"),
("feature:flags", "功能开关功能", "显示功能开关"),
("logs:read", "查看日志与用量", "查看 LLM 调用、Trace、通知、用量和运营数据"),
("runs:read", "查看运行记录", "查看任务运行状态、事件和通知"),
("audit:read", "查看操作日志", "查看用户对系统执行的操作记录"),
("usage:read", "查看用量分析", "查看个人或组织的用量与余额"),
("ops:read", "查看运营数据", "查看运营看板、指标和告警"),
("ops:manage", "运营操作", "巡检告警、备份、恢复演练和死信重放"),
),
},
{
"key": "system",
"title": "系统管理",
"hint": "给管理员和平台主管的高权限",
"items": (
("providers:write", "管理模型与密钥", "新增删除 Provider、切换模型和设置默认模型"),
("providers:read", "查看模型管理", "查看已配置模型和连接状态"),
("billing:manage", "管理计费", "发放点数和调整用量配额"),
("users:manage", "管理成员", "添加、停用或永久删除成员并重置密码"),
("roles:manage", "管理角色", "调整角色权限矩阵以及新建、删除角色"),
("audit:manage", "资源授权", "把知识库、画布授权给成员,发布全局或导出资源"),
("access:read", "查看访问控制", "查看成员、角色和资源授权页面"),
("system_logs:read", "查看系统日志", "查看服务日志和错误详情"),
("flags:read", "查看功能开关", "查看当前系统功能开关状态"),
),
},
)
def permission_catalog_grouped() -> list[dict]:
"""返回 JSON 安全的权限分组目录,避免把内部 tuple 直接暴露给 API。"""
return [
{
"key": group["key"],
"title": group["title"],
"hint": group["hint"],
"items": [
{"key": key, "label": label, "description": description}
for key, label, description in group["items"]
],
}
for group in PERMISSION_GROUPS
]
# 细化前的旧权限名 → 新权限集(旧自定义角色自动升级,行为不回退)。
_LEGACY_PERMISSION_MAP: dict[str, set[str]] = {
"read": {"read"},
"content:write": {"tasks:read", "tasks:write", "tasks:deliver", "kb:read", "kb:write",
"agents:read", "agents:write", "digital_humans:read", "flows:read", "memories:read", "memories:write"},
"review": {"tasks:review", "memories:review"},
"audit:read": {"logs:read", "audit:read"},
"config:read": {"logs:read", "runs:read", "audit:read", "usage:read", "system_logs:read", "ops:read", "ops:manage"},
"config:write": {"providers:write", "ops:manage"},
"users:manage": {"users:manage"},
"admin:manage": {"roles:manage", "audit:manage", "users:manage", "providers:write",
"ops:manage", "billing:manage"},
# 旧版本把记忆库作为独立侧栏;现已并入 Agent 与技能。
"feature:memories": {"feature:agents"},
# 旧版本曾把技能库、标签管理作为独立侧栏;现已分别并入 Agent、知识库。
# 读取旧角色定义时自动迁移,避免升级后突然失去已有访问权限。
"feature:skills": {"feature:agents"},
"feature:tags": {"feature:knowledge"},
"tags:read": {"kb:read"},
}
KNOWN_PERMISSIONS = sorted(PERMISSION_LABELS.keys() - {"*"})
ROLE_KEY_PATTERN = re.compile(r"^custom_[a-f0-9]{12}$")
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def validate_username(value: object) -> str:
username = str(value or "").strip().lower()
if not 3 <= len(username) <= 48:
raise ValueError("用户名长度需为 3 到 48 个字符")
if not all(char.isalnum() or char in "._-" for char in username):
raise ValueError("用户名只能包含字母、数字、点、下划线或连字符")
return username
def validate_password(value: object) -> str:
password = str(value or "")
if len(password) < 10:
raise ValueError("密码至少需要 10 个字符")
if len(password) > 256:
raise ValueError("密码长度不能超过 256 个字符")
return password
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
rounds = 240_000
derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, rounds)
return f"pbkdf2_sha256${rounds}${_encode(salt)}${_encode(derived)}"
def verify_password(password: str, stored: str) -> bool:
try:
algorithm, rounds_raw, salt_raw, digest_raw = stored.split("$", 3)
if algorithm != "pbkdf2_sha256":
return False
derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), _decode(salt_raw), int(rounds_raw))
return hmac.compare_digest(_encode(derived), digest_raw)
except (TypeError, ValueError):
return False
def _expand_legacy(permissions: set[str]) -> set[str]:
"""旧自定义角色里的粗权限名自动展开成新细分权限,行为不回退。"""
expanded: set[str] = set()
for item in permissions:
if item == "*":
return {"*"}
expanded |= _LEGACY_PERMISSION_MAP.get(item, {item})
return expanded
def permissions_for_user(db: Session | None, user: AppUser) -> set[str]:
if db is not None:
row = db.exec(select(RoleDefinition).where(
RoleDefinition.role == user.role,
RoleDefinition.organization_id.in_([0, int(user.organization_id or 1)]),
)).first()
if row:
return _expand_legacy(set(row.permissions or []))
return ROLE_PERMISSIONS.get(user.role, set())
def role_definition(db: Session | None, role: str, organization_id: int = 1) -> RoleDefinition | None:
if db is None:
return None
return db.exec(select(RoleDefinition).where(
RoleDefinition.role == str(role),
RoleDefinition.organization_id.in_([0, int(organization_id or 1)]),
)).first()
def role_exists(db: Session, role: str, organization_id: int = 1) -> bool:
return str(role) in VALID_ROLES or role_definition(db, role, organization_id) is not None
def role_label(db: Session | None, role: str, organization_id: int = 1) -> str:
row = role_definition(db, role, organization_id)
return (row.label if row and row.label else ROLE_LABELS.get(str(role), "自定义角色"))
def user_payload(user: AppUser, db: Session | None = None) -> dict:
permissions = permissions_for_user(db, user)
return {"id": user.id, "username": user.username, "display_name": user.display_name or user.username,
"role": user.role, "role_label": role_label(db, user.role, user.organization_id), "enabled": user.enabled, "organization_id": user.organization_id,
"permissions": sorted(permissions),
# 自定义角色只要拿到管理侧权限(角色/授权/计费/成员),也必须进入独立管理端入口。
"portal": "admin" if user.role == "admin" or "*" in permissions
or permissions & {"roles:manage", "audit:manage", "billing:manage",
"users:manage", "providers:write", "ops:manage"} else "user",
"created_at": user.created_at}
def issue_session(db: Session, user: AppUser) -> tuple[str, AuthSession]:
token = secrets.token_urlsafe(40)
expires_at = (_utc_now() + timedelta(hours=SESSION_HOURS)).isoformat()
row = AuthSession(user_id=user.id or 0, token_hash=_token_hash(token), expires_at=expires_at,
last_seen_at=now())
db.add(row)
db.commit()
db.refresh(row)
return token, row
def _expired(value: str) -> bool:
try:
return datetime.fromisoformat(value).astimezone(timezone.utc) <= _utc_now()
except ValueError:
return True
def authenticate_token(db: Session, authorization: str | None, *, touch: bool = False) -> AppUser | None:
"""Validate a bearer token without turning every API read into a SQLite write.
``last_seen_at`` used to be updated and committed on every request. With
background indexing/AI jobs that created a write-lock queue, making the
public auth check hang behind a long transaction. Callers that explicitly
need activity tracking may opt in with ``touch=True``; normal middleware
and ``/auth/status`` remain read-only.
"""
if not authorization or not authorization.startswith("Bearer "):
return None
token = authorization.removeprefix("Bearer ").strip()
if not token:
return None
auth_session = db.exec(select(AuthSession).where(AuthSession.token_hash == _token_hash(token))).first()
if not auth_session or auth_session.revoked_at or _expired(auth_session.expires_at):
return None
user = db.get(AppUser, auth_session.user_id)
if not user or not user.enabled:
return None
if touch:
auth_session.last_seen_at = now()
db.add(auth_session)
db.commit()
return user
def revoke_token(db: Session, authorization: str | None) -> None:
if not authorization or not authorization.startswith("Bearer "):
return
token = authorization.removeprefix("Bearer ").strip()
if not token:
return
auth_session = db.exec(select(AuthSession).where(AuthSession.token_hash == _token_hash(token))).first()
if auth_session and not auth_session.revoked_at:
auth_session.revoked_at = now()
db.add(auth_session)
db.commit()
def has_permission(user: AppUser, permission: str, db: Session | None = None) -> bool:
allowed = permissions_for_user(db, user)
return "*" in allowed or permission in allowed
def feature_permissions(method: str, path: str) -> tuple[str, ...]:
"""返回侧栏功能对应的入口权限(读写请求均检查)。
``required_permission`` 保持原有单一权限返回值以兼容旧客户端;该函数由
HTTP 中间件作为第二道检查使用,避免拥有通用 ``read`` 的角色绕过功能栏
权限访问接口。一个功能允许多个操作权限(例如审查员可以查看任务);写请求
仍由 ``required_permission`` 决定,资源级授权继续在业务层处理。
"""
# 登录、健康检查等基础接口不属于侧栏功能。
if path.startswith(("/api/auth/me", "/api/auth/status", "/api/system/status", "/api/health")):
return ()
# 任务进度流/轮询只读取当前登录用户自己的事件。它不是系统配置接口,
# 不能落入末尾的 config:write 默认分支,否则普通用户会持续收到 403,
# 前端会反复重连并表现为生成卡顿。
if path.startswith("/api/events/progress"):
return ("read", "feature:tasks", "feature:runs")
if path.startswith("/api/system-logs"):
return ("feature:system-logs", "system_logs:read", "logs:read")
if path.startswith("/api/settings/feature-flags"):
return ("feature:flags", "flags:read", "ops:manage")
if path.startswith("/api/auth/users"):
return ("feature:access", "access:read", "users:manage")
if path.startswith("/api/auth/roles"):
return ("feature:access", "access:read", "roles:manage")
if path.startswith('/api/admin/points'):
return ('feature:usage', 'billing:manage')
if path.startswith(("/api/admin/usage", "/api/admin/billing", "/api/admin/quotas")):
return ("feature:usage", "billing:manage", "logs:read")
if path.startswith("/api/admin/") or path.startswith("/api/audit-logs"):
return ("feature:audit", "audit:read", "audit:manage")
if path.startswith("/api/models"):
return ("feature:models", "providers:read", "providers:write")
if path.startswith(("/api/providers", "/api/settings/model-default")):
return ("feature:models", "providers:read", "providers:write")
if path.startswith('/api/settings/feature-flags'):
return ('feature:flags', 'flags:read', 'ops:manage')
if path.startswith("/api/ops/"):
return ("feature:ops", "ops:read", "ops:manage")
if path.startswith(("/api/usage", "/api/quota", "/api/billing")):
return ("feature:usage", "usage:read", "logs:read", "billing:manage")
if path.startswith(("/api/llm_calls", "/api/traces")):
return ("feature:runs", "runs:read", "logs:read")
if path.startswith("/api/runs"):
return ("feature:runs", "runs:read", "logs:read")
if path.startswith("/api/notifications"):
return ("feature:runs", "runs:read", "logs:read")
if path.startswith("/api/wechat/bot/accounts"):
return ("feature:digital-humans", "digital_humans:read", "agents:write")
# 回调请求不带登录态,由路由内的账号密钥校验负责隔离。
if path == "/api/wechat/bot/reply":
return ()
# 技能库已并入 Agent 工作区,提示模板同属 Agent 配置。
if path.startswith(("/api/skills", "/api/prompt-templates")):
return ("feature:agents", "agents:read", "agents:write")
if path.startswith("/api/agents"):
return ("feature:agents", "agents:read", "agents:write")
if path.startswith("/api/digital-humans"):
return ("feature:digital-humans", "digital_humans:read", "agents:write")
if path.startswith("/api/canvases"):
return ("feature:flows", "flows:read", "agents:write")
# 标签管理已并入知识库,沿用知识库的查看/管理权限。
if path.startswith("/api/tags"):
return ("feature:knowledge", "kb:read", "kb:write")
if path.startswith("/api/memories"):
return ("feature:agents", "memories:read", "memories:write", "memories:review")
if path.startswith("/api/pending_memories"):
return ("feature:agents", "memories:read", "memories:write", "memories:review")
if path.startswith("/api/kbs"):
return ("feature:knowledge", "kb:read")
if path.startswith(("/api/campaigns", "/api/segments", "/api/training-maps", "/api/agent-sessions", "/api/ocr")):
if path.endswith(("/export", "/download", "/finalize")):
return ('feature:tasks', 'tasks:deliver')
return ("feature:tasks", "tasks:read", "tasks:write", "tasks:review", "tasks:deliver")
return ()
def required_permission(method: str, path: str) -> str:
"""按资源域细分权限;未知写操作仍收敛为 config:write,杜绝新接口漏保护。"""
read_only = method in {"GET", "HEAD"}
# 微信 Bot 连接属于数字人工作区;Bot 回调本身走独立账号密钥认证。
if path.startswith("/api/wechat/bot/accounts"):
return "digital_humans:read" if read_only else "agents:write"
if path == "/api/wechat/bot/reply":
return "read"
if path.startswith("/api/events/progress") and read_only:
return "read"
if path.startswith('/api/admin/points'):
return 'admin:manage'
if path.startswith(("/api/admin/usage", "/api/admin/billing", "/api/admin/quotas")):
return "billing:manage"
if path.startswith("/api/system-logs"):
return "logs:read"
if path == '/api/points/me' and read_only:
return 'read'
if path.startswith('/api/system-logs'):
return 'logs:read'
if path.startswith('/api/settings/feature-flags'):
return 'logs:read' if read_only else 'ops:manage'
if path.startswith("/api/admin/") or path.startswith("/api/audit-logs"):
return "audit:manage"
# 模型与密钥:读写分权
if path.startswith("/api/providers") or path.startswith("/api/settings/model-default"):
return "read" if read_only else "providers:write"
# 运营:看板/巡检/备份/死信
if path.startswith("/api/ops/"):
return "logs:read" if read_only else "ops:manage"
# 成员与角色
if path.startswith("/api/auth/users"):
return "users:manage"
if path.startswith("/api/auth/roles"):
return "roles:manage"
if path == "/api/auth/logout":
return "read"
# 点数/用量/计费
if path.startswith(("/api/usage", "/api/points", "/api/quota", "/api/billing")):
return "logs:read" if read_only else "billing:manage"
# 运行追踪
if path.startswith(("/api/llm_calls", "/api/traces")):
return "logs:read"
if path.startswith("/api/system-logs"):
return "logs:read"
# 可选模型清单(建 Agent/画布时的下拉数据):登录即可读
if path.startswith("/api/models"):
return "read"
# 智能体资产:Agent/画布/技能/模板(用户端可管自己的私有项,全局项由资源层保护)
if path.startswith(("/api/agents", "/api/canvases", "/api/skills", "/api/prompt-templates")):
return "agents:read" if read_only else "agents:write"
if path.startswith("/api/tags"):
return "kb:read" if read_only else "kb:write"
# 记忆
if path.startswith("/api/pending_memories"):
return "memories:review" if method == "POST" else "memories:write"
if path.startswith("/api/memories"):
return "memories:write"
# 工具审批与内容审查
if path.startswith("/api/agent-sessions") and path.endswith("/tool-approval"):
return "memories:review"
if "/review" in path:
return "tasks:review"
# 知识库
if path.startswith("/api/kbs"):
return "kb:read" if read_only else "kb:write"
# 内容任务/段/运行/OCR
if path.startswith(("/api/campaigns", "/api/segments", "/api/runs", "/api/agent-sessions", "/api/ocr")):
if path.startswith("/api/runs") and read_only:
return "logs:read"
if path.endswith(("/export", "/download")) or path.endswith("/finalize"):
return "tasks:deliver"
if path.startswith(("/api/notifications",)):
return "read"
# 审查员可查看任务和段落,但只有编辑才可创建、拆解和运行。
return "tasks:review" if read_only else "tasks:write"
if path.startswith("/api/notifications"):
return "read"
# 登录即可读的辅助端点(系统状态、当前用户、健康检查、工具/技能目录)
if path.startswith(("/api/system/status", "/api/auth/me", "/api/health", "/api/agent-tools", "/api/agent-skills")):
return "read"
# AI 辅助创建/导图:跟随内容生产权限(建任务、建 Agent 都会用)
if path.startswith("/api/training-maps"):
if path.endswith('/export'):
return "tasks:deliver"
return "tasks:review" if read_only else "tasks:write"
if path.startswith("/api/ai-assist/"):
return "tasks:write"
# 数字人:读=登录即可(可见性在资源层过滤);写(蒸馏/授权/删除/审核)=内容生产权限
if path.startswith("/api/digital-humans"):
return "read" if method in {"GET", "HEAD"} else "agents:write"
return "config:write"
def permission_label(permission: str) -> str:
key = str(permission or "").strip()
# 即使未来新增权限尚未加入目录,也把原始 key 带出来,方便管理员定位,
# 不再只显示无法理解的“其他权限”。
return PERMISSION_LABELS.get(key, f"未命名权限({key or '未知'})")