Files
AgentLab/backend/app/auth/__init__.py
ThearchyHelios 19329c873e 访问密钥与服务账号的后端(下一轮第 2 波 W2-d,细案 04 A1–A6、总方案 X8)
外部系统(门户、OA、BI)和个人脚本要用一个身份调接口,以前只能拿个人会话 cookie 去模拟。这次加访问密钥:

- 签发(A1):users.kind 区分人和服务账号(svc- 开头、没有口令、不能登录、角色固定使用者,管理员建);access_keys 只存
  sha256 和开头一截,明文 agl_ 加 32 字节随机串只显示一次;到期必填(最长 365 天、缺省 90 天)、可撤销、可轮换(新钥 + 旧钥
  24 小时后到期);个人访问密钥由管理员开关 auth.personal_keys 管,缺省关,关掉时已有的个人密钥一并不认
- 认人与门禁(A2):Principal 加 via / key_id / key_name / scopes / app_ids(全部带默认值);中间件认 Bearer agl_…,不看
  cookie、不查 CSRF、不看 X-Actor,认不出的各回 401 带各自的 code、绝不退回匿名;WebSocket 带密钥一律拒;关登录时 409
  keys_need_login。KEY_ROUTES(方法 + 路由模板 → scope)一张表,FastAPI(dependencies=[Depends(key_gate)]) 挂一次;不在表里的
  403 key_route_forbidden,缺 scope、限定应用、个人应用和试运行各有各的 403;每把密钥每分钟 120 次(429 + Retry-After)
- 封顶(A3):按密钥认出来的一律是使用者(拥有者现在的组),现有的使用者规则和遮罩全部自动生效,管理员的密钥也一样
- 写操作(A4):发起入口、刷新视图必须带 Idempotency-Key,api_receipts 按(拥有者, 键)去重,轮换后照样认得出;状态机从
  process/hooks.py 抽到 process/receipts.py,钩子和密钥共用(钩子现有测试原样通过),另按请求指纹拒掉同一个键办另一件事;
  有文件控件的入口 422;回复待办只给个人密钥、只限审批 / 补充输入 / 编辑稿件;run.origin、task.responded 记 via=key、哪把密钥
- 视图数据(A5):GET /api/apps/{id}/views/{view}/data(agentlab.viewdata/1),印章、清单哈希、口径卡指标原值和渲染文字,
  遮罩的 value 为 null(和页面同一个判断),按需视图只读缓存、不发起运行,只给正式运行
- 留痕(A6):建 / 撤销 / 轮换密钥、建 / 停用服务账号、开关个人密钥写审计;请求记录进程内缓冲、调度循环一拍里批量写、
  保留 90 天;last_used_at 至少隔 1 分钟写一次;钩子收件记录接口(manage),验签失败只在进程内按钩子计数
- 名录与派单:服务账号在名录里标出,岗位绑定、选人候选、提交审核、改派里排除,应用授权照收;服务账号发起的实例里派给发起人
  的待办交给任何人
- 接口说明(A7):GET /api/access-keys/reference 从 KEY_ROUTES 生成(要登录)

测试:签发与各种 401、门禁合约(遍历全站路由)、CSRF 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
2026-10-16 13:48:09 +08:00

131 lines
5.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""身份的实现(docs/design/app-platform-and-look.md §2.7):登录方式、会话、中间件、OIDC、引导与找回。
api/deps.py 只定接口,这里给实现:
- 登录方式:环境变量 AGENTLAB_AUTH_MODE 优先,没设时读设置(settings 表的 auth 一行,进程内缓存,改设置时刷新);
- middleware.py:按请求认出是谁(会话 cookie),写进 api/deps 的 contextvar;非 GET 请求核对 X-AgentLab-Request 头和 Origin;
- sessions.py / passwords.py:会话(库里只存 token 的 sha256)、口令(hashlib.scrypt)、登录失败退避;
- oidc.py:授权码 + PKCE;access.py:运行、工件的读权限;audit.py:管理类操作的留痕;signing.py:身份断言;
- chat_policy.py:开了问数据的使用者能运行什么样的图(节点、工具的白名单);
- keys.py:访问密钥(服务账号和个人访问密钥)的签发、认人、门禁、限流、幂等、请求记录、接口说明。
关登录(off,默认)时:中间件什么都不做,所有依赖放行,行为和加这一层之前一模一样。
"""
from __future__ import annotations
import logging
import os
from typing import Any
from fastapi import Depends
from app.api.coded import AUTH_REQUIRED, ROLE_REQUIRED, CodedHTTPException
logger = logging.getLogger(__name__)
MODES = ("off", "local", "oidc")
#: settings 表里存登录设置的那一行
SETTING_KEY = "auth"
ROLE_LABEL = {"admin": "管理员", "builder": "搭建者", "user": "使用者"}
#: 设置的进程内缓存。单进程部署:改设置的接口改完就刷新它;启动时、第一个请求到来时读一次
_stored: dict[str, Any] = {}
_loaded = False
def env_mode() -> str | None:
"""环境变量指定的登录方式;没设、或者写的不是三种之一为 None。"""
raw = (os.environ.get("AGENTLAB_AUTH_MODE") or "").strip().lower()
if raw and raw not in MODES:
logger.warning("AGENTLAB_AUTH_MODE=%s 不是 off / local / oidc 之一,按设置页的登录方式处理", raw)
return None
return raw or None
def current_mode() -> str:
"""当前的登录方式:环境变量 AGENTLAB_AUTH_MODE 优先;没设时读设置,默认 off。"""
mode = env_mode() or _stored.get("mode") or "off"
return mode if mode in MODES else "off"
def stored_settings() -> dict[str, Any]:
return dict(_stored)
def user_can_chat() -> bool:
"""开登录后 user 角色能不能问数据(Q2,默认不能,管理员开关)。"""
return bool(_stored.get("user_can_chat"))
#: 登录设置里「个人访问密钥」开关的键(docs/design/next-round/04-对外集成与触达.md A1,界面上在「设置 → 访问密钥」)
PERSONAL_KEYS = "personal_keys"
def personal_keys_enabled() -> bool:
"""个人访问密钥开没开(集成-I2,默认关,管理员开关)。关着时建不了个人密钥,已有的个人密钥也不认(auth/keys.py)。"""
return bool(_stored.get(PERSONAL_KEYS))
async def load_settings(force: bool = False) -> dict[str, Any]:
"""从库里读登录设置进缓存。"""
global _loaded
if _loaded and not force:
return dict(_stored)
from app.db.base import SessionLocal
from app.db.models import Setting
async with SessionLocal() as session:
row = await session.get(Setting, SETTING_KEY)
_stored.clear()
_stored.update((row.value if row else None) or {})
_loaded = True
return dict(_stored)
def remember_settings(value: dict[str, Any]) -> None:
"""改设置的接口写库之后调:缓存换成新值。"""
global _loaded
_stored.clear()
_stored.update(value)
_loaded = True
def refusal(principal: Any, role: str) -> CodedHTTPException | None:
"""principal 够不够 role:够(或关登录)是 None;不够交回该回的错——没登录 401(auth_required),角色不够 403
(role_required)。路由级依赖、接口内部的 ensure_role、中间件拦 /docs 都用它,同一个判断、同一句话。"""
if principal.mode == "off":
return None
if not principal.username:
return CodedHTTPException(401, "请先登录", AUTH_REQUIRED)
if not principal.at_least(role):
return CodedHTTPException(403, f"没有权限:这项操作需要{ROLE_LABEL.get(role, role)}及以上的角色", ROLE_REQUIRED)
return None
def require(role: str) -> None:
"""当前请求至少是 role,否则 401(没登录)/ 403(角色不够),都带 code。关登录时放行。"""
from app.api.deps import current_principal
if (refused := refusal(current_principal(), role)) is not None:
raise refused
def role_dependency(role: str) -> Any:
"""至少是 role 的路由级依赖。关登录时放行。"""
async def _check() -> None:
require(role)
return Depends(_check)
async def run_readable(principal: Any, run: Any) -> bool:
from app.auth.access import run_readable as check
return await check(principal, run)
async def artifact_readable(principal: Any, artifact_id: str) -> bool:
from app.auth.access import artifact_readable as check
return await check(principal, artifact_id)