整套系统在移植阶段采用共享密钥的 JWT 方案:一个服务签发,所有服务用同一个 JWT_SECRET 本地校验。本页直接描述项目实现;需要先理解 token、签名和 Bearer,可阅读 JWT 是什么

JWT 形态

user_manager 在登录/注册成功后签发 HS256 token,payload 形如:
{ "user_id": 1, "username": "admin", "role": "root", "exp": 1781618346 }
  • 算法:HS256,密钥为环境变量 JWT_SECRET
  • 客户端把它放进 Authorization: Bearer <jwt> 发送。
  • 其余服务本地解码校验,暂不做服务间内省(introspection)。
所有服务必须配置相同JWT_SECRET,否则一个服务签发的 token 在另一个服务会校验失败。scripts/run_all_servers.sh 会自动为六个服务注入同一个密钥。

Token 传播

  • 客户端 → 控制器、客户端 → collection_server:携带同一个 token。
  • 控制器先本地解码出 {id, username, role}在任何下游调用之前就完成可见性 / 归属 / root 校验(不可见返回 404,无权限返回 403)。
  • 控制器 → agent、user_manager:原样转发同一个 Bearer,下游本地解码并信任其角色与入参。
  • 控制器 / collection_server → data_server 的受保护内部接口:除用户 Bearer 外,必须额外携带一张短期服务断言(见下节)。
  • collection_server 本地解码 Bearer 做鉴权与参数校验,但不再拥有数据库;它转发给 data_server,由后者校验断言并按断言里的 actor_user_id 限定每个查询(用户只能看到自己的收藏)。

服务间断言(X-Rakull-Service-Assertion)

data_server 的内部写接口(自适应学习、Scenario/Journey、文章收藏、收藏夹等)采用双凭证:用户 Bearer 选出”以谁的身份”,服务断言证明”请求确实来自我方允许的对等服务、且只申请白名单能力”。两张凭证故意分离,密钥也是两个:Bearer 用 JWT_SECRET,断言用 CONTROLLER_ASSERTION_SECRET 断言是一张 HS256 JWT,放在 X-Rakull-Service-Assertion 头,claims 为:
  • iss / sub:调用方服务标识(immersive_study_servercollection_server),二者必须相等且在白名单内;
  • aud:固定 data_server
  • scope:非空字符串数组,每项都必须在 data_server 的能力白名单内,且该调用方有权签发该 scope;
  • actor_user_id:终端用户 ID(正整数),必须与 Bearer token 的用户一致;
  • iat / exp:短期有效(有效期不超过 120 秒),拒绝未来签发。
收藏域的两个 scope collections:data:read / collections:data:write 只允许 collection_server 签发。断言缺失、过期、scope 越权或 caller 不匹配一律返回 403。详见 collection_server

角色

角色能力
user普通用户:管理自己的文章/收藏,阅读可见文章
root管理员:用户管理、邀请码、注册审批、查看全部文章
管理员(root)在初始化时直接写入数据库user_managerapp/db/seed.py,幂等);其余注册都进入待审批队列,由 root 审批 —— 不再有“第一个注册用户自动成为 root”的捷径。

文章可见性

控制器在读取文章时套用与原单体一致的可见性规则:
情况是否可见
自己是作者(owner)
is_public = 1对已登录用户可见
guest_visible = 1对匿名访客也可见
其他否(返回 404,不泄露存在性)

公开权限与“申请公开”

  • 用户上传的文章默认私有is_public = 0)。
  • 只有管理员(root)可以更改可见性PATCH /api/articles/{id}/visibility 现在要求 root
  • 普通作者只能申请公开POST /api/articles/{id}/publish-request(校验调用方为作者且文章当前私有),请求以 publish_request 类别写入 feedback 表。
  • 管理员在「Publish requests」中审批:通过 POST /api/admin/publish-requests/{id}/approve(置为公开并清除该文章的请求)或 POST /api/admin/publish-requests/{id}/reject(仅清除请求)。

匿名访问

  • 没有 token 时,只允许通过控制器读取 publicguest_visible 的内容。
  • 控制器会以 user_id=None / role='user' 的语义传给 data_server,行为与原单体一致。
  • 收藏始终需要 token

与 Supabase 的关系

user_manager 同时保留了原有的 Supabase OAuth 路由(/v1/*,如 /v1/login/config)。本地账号体系(/api/auth/* + SQLite)与 Supabase 相互独立:即使 Supabase 不可达,本地鉴权仍可离线工作(启动时 Supabase 连接失败只记录告警、不阻断服务)。详见 user_manager