mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:42:01 +00:00
19329c873e
外部系统(门户、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 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
131 lines
5.1 KiB
Python
131 lines
5.1 KiB
Python
"""身份的实现(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)
|