鉴权模型

QuantumAtlas 的鉴权回答两个问题:

  1. 你是谁?(authentication)

  2. 你能做什么?(authorization)

这两件事在系统里由不同的层处理。理解清楚能避免很多"我有 token 啊为什么 403"的疑惑。

三种身份载体

载体

谁能拥有

寿命

怎么获得

隐式 scope

PocketBase session

浏览器登录用户

默认 14 天,自动续期

GitHub OAuth 登录后,浏览器自动持有(pb.authStore,不需要复制)

*(全权限)

PAT (Personal Access Token)

任何登录用户

1–365 天

在 /pat 页面创建

显式 opt-in,默认空集 = 不能写

System PAT(可选,运维兜底)

ops(写 .env 的人)

永不过期,rotation = 改 env+restart

在 server 的 .env 写 QATLAS_SYSTEM_PAT=<plaintext>

默认 *,可通过 QATLAS_SYSTEM_PAT_SCOPES 限缩

匿名

任何人

—

不带 Authorization 头

仅 health / meta 等无数据端点

三种 token 怎么选?

  • Session:浏览器里自动用(基本无感);没有 UI 入口让你 copy 它——非浏览器用例改用下面两种

  • PAT:CLI / 人工脚本 / 想钉到具体人身份的 ingest 工具——最常见的"我手里有个写权限 token"

  • System PAT:CI / cron / pb_data 出问题时的 breaking glass——env 加载,跟 PocketBase 完全解耦

PAT 明文以 qat_ 开头,只在创建时显示一次,server 只存 bcrypt 哈希。丢了就只能 revoke + 重建。

System PAT 明文格式随意(推荐 openssl rand -base64 32),只活在进程内存 + .env 里,server 不持久化也不哈希。详见 §System PAT。

谁能登录(GitHub 白名单 + Gitea 敞开)

读口已全锁(见 §哪些端点要鉴权)后,"匿名拿 401" 只是第一道墙;第二道墙是 不是任何账号都能换到 session。

internal/auth/oauth.go 在 OnRecordAuthWithOAuth2Request 钩子里做登录校验:拿到 provider 身份、但还没建 users 记录、还没发 token 那一刻:

  • GitHub:比对 Config.IsGitHubLoginAllowed(login)(config.yaml auth.allowed_logins ∪ auth.admin_logins,大小写不敏感),不在名单一律返回 403。

  • Gitea:不做白名单——实例上任何账号都能登录(实例自身的注册/审核策略就是门槛)。auth.gitea_admin_logins 只在启动时播种 is_admin 标志(见「DB 角色」一节),不管登录。

要点:

  • GitHub fail-closed:allowed_logins 和 admin_logins 同时为空 ⇒ GitHub 谁都登不了。这是刻意的"默认上锁"——漏配得到的是"没人能进",而不是"整个互联网都能进"。

  • GitHub 与 Gitea 名单互不相通:GitHub 名单里的 alice 不会让 Gitea 上同名账号获得任何权限(反之亦然)——两者是不同 IdP 里的不同身份。

  • 这道闸既挡新注册、也挡"记录已存在但被移出名单"的老用户(每次登录都校验,纵深防御)。

别把自己锁死:superuser 是逃生通道

这个白名单只管 users 集合的 OAuth 登录,不影响 PocketBase superuser (_superusers 集合,邮箱+密码登 /_/)。误配/漏配把所有人挡在外面时,用 superuser 进后台、改 config.yaml 的 auth: 名单、重启 server 即可恢复。所以"全拒"不会变成 永久砖。

登录冲突匹配与账号绑定

跨 provider 的"同人不同账号"由 internal/auth/oauth_conflict.go + dashboard 绑定面板处理:

  • 邮箱冲突 → 409 提示:PocketBase 默认会把同邮箱的新 OAuth 身份静默挂到现有记录(对邮箱未验证的 provider 这还是接管漏洞)。qatlasd 在建记录/发 token 之前拦下,返回 409(data.code = oauth_conflict),SPA 弹"是否匹配已有账号"。匹配路径:用已有账号登录 → dashboard「账号绑定」→ 点绑定按钮(携带 session 的授权交换,即 PocketBase 原生 link 语义)。

  • 用户名冲突 → 409 提示(可跳过):全新登录的 GitHub/Gitea 用户名与现有 github_login / gitea_login 相同。提示页可选"匹配"或"继续,使用独立账号";后者重走一次授权跳转,服务端内存里有 15 分钟的"已提示"标记,第二次交换放行。

  • 绑定的判定:_externalAuths 表有无该 provider 的关系行(/api/me 的 github_bound / gitea_bound);github_login / gitea_login 字段只是展示用的登录名。绑定流程不会静默切换账号——若身份已属于别的账号,PocketBase 会切换会话,SPA 检测后显示"会话已切换"警告。

DB 角色:is_admin / is_superadmin / disabled

users 记录上的三个数据库字段(迁移 1788100000_add_role_flags_to_users.go)是 所有管理面的唯一运行时鉴权来源——运维面(adminGuard:db schema / usage / mineru / 下载工作节点等)与用户管理面都读它们。config.yaml 的 auth.admin_logins / auth.superadmin_logins(含 gitea_* 同名列表)只是 启动时的播种名单:每次启动 promoteRoleFlags 把名单里的登录名单调晋升为 对应标志(从不下调),此后运行时不再看名单。

字段

谁能改

持有者能做什么

is_admin

superadmin(PATCH /api/admin/users/{id})

看所有用户 + 切换其他账号可用性;对其他人的 is_admin 只读

is_superadmin

仅 bootstrap 播种(auth.superadmin_logins)或 /_/ 后台

在 is_admin 之上,还能管理其他用户的 is_admin

disabled

admin 及以上

账号可用性开关(注意反向极性:PB bool 无默认值,零值必须是常态=未禁用)

接口(均为 userAdminGuard:浏览器 session + 上述任一角色;PAT 一律拒绝):

GET   /api/admin/users          # 全量用户列表(含 is_admin/is_superadmin/disabled)
PATCH /api/admin/users/{id}     # body: {"disabled": bool} 或 {"is_admin": bool}(后者需 superadmin)

要点:

  • 授予 is_admin 只认 is_superadmin 标志——配置名单不再在运行时桥接 superadmin 权限;要新增 superadmin,把它加进 auth.superadmin_logins 后重启播种,或在 /_/ 后台手改。

  • disabled 的生效是即时的:isAuthorized 每个请求都会复查该标志(session 与 PAT 两条路),OAuth 登录钩子也会拒绝禁用账号换新 session。管理员禁用一个账号后,其 14 天 session 与全部 PAT 立即失效。

  • 自我保护:不能禁用自己、不能改自己的 is_admin、admin 不能禁用 superadmin——防止把管理面自己锁死。

  • is_superadmin 不可通过 API 授予(防止一次 admin session 泄漏直接提权):播种源是配置文件的 auth.superadmin_logins(每次启动单调晋升,从不下调)或 /_/ 后台手改。

  • 改名单必须重启才生效:播种发生在启动时,adminGuard 运行时只读 DB 标志。superadmin 通过 PATCH /api/admin/users/{id} 授予/回收 is_admin 则是即时的(下一个请求生效)。

Scope 词表

PAT 携带一组显式 scope(GitHub fine-grained PAT 同款设计)。当前词表:

Scope

覆盖端点

papers:read

POST /api/search GET /api/papers/stats GET /api/papers/needs-mineru;当部署方启用 QATLAS_PAPER_ACCESS_ENABLED 时还覆盖 GET /api/papers/{id}/markdown GET /api/papers/{id}/markdown/status

papers:write

POST /api/papers/{id}/upload-pdf POST /api/papers/{id}/upload-mineru POST /api/v1/papers/{id}/mineru-lease DELETE /api/v1/papers/{id}/mineru-lease/{cid}(隐式含 papers:read)

plugins:read

GET /api/v1/plugins

plugins:write

POST /api/v1/plugins/{id}/enable POST /api/v1/plugins/{id}/disable(隐式含 plugins:read)

theorems:read

GET /api/theorems/list GET /api/theorems/families GET /api/theorems/stats GET /api/theorems/theorem/{fqn} GET /api/theorems/theorem-source/{fqn} GET /api/theorems/sync/status

theorems:write

POST /api/theorems/sync/pull(服务端 git fast-forward + 缓存 reload;隐式含 theorems:read)

Scope 是编译时静态的——加新 scope 需要改代码 + 重新部署。完整词表在 internal/pat/scopes.go。

PAT 默认空集 = 什么都调不了

创建 PAT 时至少要勾一个 scope,否则该 PAT 调任何端点(读或写)都是 403。知识库不再匿名可读——浏览页面、搜索、查图谱都需要 session token,或带相应 *:read scope 的 PAT。仅 health / server-info / 安装脚本 / SPA 外壳 这几个无数据端点保持公开。

端点的三种门禁

服务端有三层门禁,写在 internal/routes/auth.go 和 internal/routes/scope_guard.go:

        flowchart TD
    REQ[HTTP 请求] --> AG{authGuard}
    AG -->|无 Auth header| R401[401 未鉴权]
    AG -->|有效 PAT 或 session| ID{auth source?}
    ID -->|session| SG{sessionGuard 检查}
    ID -->|PAT| SCG{scopeGuard 检查}
    SG -->|是 session| H[handler ✓]
    SG -->|是 PAT| R403S[403:PAT 管理需 session]
    SCG -->|scope 覆盖| H
    SCG -->|scope 不足| R403P[403:缺 scope]
    
  • authGuard — 只要有效凭据就放行;少数无数据公开端点(health / server-info / 安装脚本 / SPA)不挂这层。

  • scopeGuard(obj, act) — 在 authGuard 之上再检查 PAT 是否包含覆盖 (obj, act) 的 scope。session 自动 bypass(它有隐式 *)。所有读口(*:read)和写口(*:write)都挂这层。

  • sessionGuard — 比 authGuard 更严,显式拒绝 PAT auth。只用在 /api/pat 自己(PAT 管理只能浏览器做)—— 防止 leaked PAT 自我复制。

哪些端点要鉴权

完整清单在 REST API 参考。粗略分布:

类别

鉴权

论文搜索 (POST /api/search)

authGuard + papers:read

论文元数据查询 (GET /api/papers/{id}/stats、GET /api/papers/needs-mineru)

authGuard + papers:read

论文 markdown 下载 (GET /api/papers/{id}/markdown、/markdown/status)

authGuard + papers:read(仅 QATLAS_PAPER_ACCESS_ENABLED=true 时注册)

Health / Meta (/api/health、/api/server/info、/api/pat/scopes、/install-qatlasd.sh、/swagger/*、SPA /{path...})

公开(无数据 / bootstrap / 外壳)

论文上传 / mineru 相关

authGuard + papers:write

PAT 管理 (/api/pat)

sessionGuard(拒 PAT)

用户管理 (GET /api/admin/users、PATCH /api/admin/users/{id})

userAdminGuard(session + is_admin / is_superadmin,拒 PAT)

设计原则:凡是返回语料数据的端点(读和写)都要鉴权;只有"无数据"的探活 / 版本 / 安装脚本 / 文档 / SPA 外壳保持公开。知识库不再匿名可读。论文 PDF / Markdown 字节在 quantum-atlas.ai 等公开实例上默认不通过 HTTP API 对外分发(QATLAS_PAPER_ACCESS_ENABLED=false)——客户端只能查询元数据(OpenAlex 同步进来的内容)与论文具备何种资产的开关位(stats / needs-mineru)。Self-hosted 部署可在受控范围内打开该开关,启用后 papers:read 同时覆盖 GET /api/papers/{id}/markdown 类端点,详见 License & Attribution · 论文访问开关。

怎么实操

浏览器用户

GitHub OAuth 登录后浏览器自动持有 session token(pb.authStore,不需要复制)。SPA 内所有调用自动带 Authorization: Bearer <session>,14 天到期会自动续期。

这种 token 隐式拥有全部权限(包括 PAT 管理),但只在浏览器里有用——不暴露 UI 入口去手动 copy。需要非浏览器调用走下面的 PAT。

CLI 长期

上 /pat 创建 PAT,勾上需要的 scope(典型组合:papers:write + theorems:read),设置 30–365 天到期。

qatlas auth login -s quantum-atlas.ai 会跑 OAuth device-code flow,浏览器里 Approve 后 token 自动写进 ~/.config/qatlas/hosts.yml。之后所有 qatlas 命令自动用这个 PAT。

CI / Agent

跟 CLI 长期一样建 PAT,但通过 stdin 注入 hosts.yml 而不是 argv / env:

# 从 secret 拿到 PAT 后,echo 进 qatlas auth login --with-token(从 stdin 读,不进 history / ps)
echo "$QATLAS_TOKEN_FROM_SECRET" | qatlas auth login -s quantum-atlas.ai --with-token
qatlas contrib pdf ...

v0.17.0 起 client 不再读 QATLAS_TOKEN env、不再支持 --token flag—— 都必须经 qatlas auth login --with-token 从 stdin 写到 hosts.yml。理由: 单入口减少认知负担,跟 gh auth login --with-token 同款设计;secret 从 stdin 走也避免被 shell history / ps / CI runner log 抓到。

更详细的操作见 管理凭据。

多边缘节点的坑

QuantumAtlas 可以做多边缘 active-active 部署(多台 edge 各跑独立 qatlasd)。每台边缘有自己独立的 PocketBase——意味着:

  • 同一 GitHub 账号在多台边缘各登一次 → 各创建一条 users 记录(不同 user_id)

  • Edge A 上建的 PAT 不能在 Edge B 用(401)

CI 多线路场景下需要为每条线路分别建 PAT。

反代注入的审计头

可选:反向代理可以注入 X-Token-Subject(具体头名由 QATLAS_USER_HEADER 决定)作为审计元数据。这个头不参与鉴权——只是审计层补充信息,跟 PAT/session 鉴权完全正交。

System PAT — 运维专用 breaking-glass token

# .env / systemd Environment=
QATLAS_SYSTEM_PAT=<你自己生成的强随机串,≥16 字符>
QATLAS_SYSTEM_PAT_SCOPES=*    # 可选,默认 *

未设 / 留空 = 功能完全关闭(默认状态)。设了以后:

  • 任何 HTTP 请求带 Authorization: Bearer <这串> 就过 authGuard

  • 永远不查 PocketBase,永远不绑 user record

  • 启动日志一行 system PAT enabled (length=N scopes=[...]) 确认生效(不打明文)

  • 启动时硬校验长度 ≥16,太短直接 fatal exit 防 placeholder 上 prod

  • 命中时审计来源标 system-pat(user PAT 是 pat,session 是 session),日志能分清

跟 user PAT 的区别

维度

User PAT (qat_...)

System PAT

存哪

pb_data SQLite (pat_tokens 表,bcrypt)

进程内存(只在 QATLAS_SYSTEM_PAT env)

怎么创建

浏览器 OAuth 登录后到 /pat

运维直接挑随机串写进 .env / Environment=

寿命

1–365 天,强制过期

无过期;rotation = 改 env + restart

数量

每个 user 可建多个

全局只有一个

默认 scope

空集 = 不能写

* (master);可在 env 里覆盖

能不能用 *

❌ pat mint 拒绝

✅ 是默认值

能不能调 /api/pat 管 user PAT

❌ sessionGuard 拒绝

❌ 同样被 sessionGuard 拒绝(防 leaked token 自我复制)

pb_data 不在时还能用

❌

✅ 这是设计目的

适用场景

  • 新 edge bootstrap:还没人 OAuth 登过,但 sync / 灌数据已经要跑

  • 灾难恢复:pb_data 损坏 / 被清空,要 API 重建

  • CI / cron:长跑脚本,不想跟某个具体人绑定 + 不想被 365 天过期打扰

  • 临时调试 / 一次性 ops 脚本

安全边界

能读到 QATLAS_SYSTEM_PAT 的人 = superuser-equivalent。但这跟 .env 里早就有的 QATLAS_S3_SECRET_ACCESS_KEY / QATLAS_POSTGRES_DSN / GITHUB_CLIENT_SECRET 同等敏感——读到 .env 的人本来就能干很多事。新增 system PAT 不扩大现有 .env 的攻击面。

要求 .env 文件 mode 600 + 服务用户 owner,跟现有约定一致。不要把 plaintext 贴进 git / commit / issue / chat 等任何持久化位置。