546 lines
26 KiB
Python
546 lines
26 KiB
Python
"""本地工作台认证与 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 '未知'})")
|