Files
qinglong/docs/adr/ADR-0029-first-owner-bootstrap-and-authenticated-principal.md
T

9.5 KiB
Raw Blame History

ADR-0029:首 Owner 一次性 Bootstrap 与认证主体边界

  • 状态:Proposed
  • 日期:2026-07-19
  • 关联:QL-RFC-0001、ADR-0028

上下文

ADR-0028 故意让 0017-project-policy 创建 ownerless default Project,以免把可变 username、默认密码、legacy system App 或任意旧 token 静默提升为 3.0 owner。但如果没有一个明确且可恢复的首次建权协议,部署者只能修改数据库或让生产 API 临时绕过 Policy,两种做法都会破坏默认拒绝和审计边界。

首 owner 建立还跨越三个不同事实:credential 已被某个 Authentication adapter 验证、一次性 challenge 未过期且未被使用、Project 仍没有任何 RoleBinding。只在 application service 中依次执行“消费 challenge”和“写 owner”会留下崩溃窗口;只用内存 token 又无法跨控制面重启。edge 设备不能为这一步常驻 sidecar、timer 或全量缓存,cluster 多副本则不能共享 SQLite 文件锁语义。

决策

1. Authentication adapter 只交付严格认证主体

认证边界输出:

interface AuthenticatedPrincipal {
  subject: PolicySubject;
  authenticationId: string;
  authenticatedAtMs: number;
  expiresAtMs: number;
  assurance:
    | 'single_factor'
    | 'multi_factor'
    | 'service'
    | 'hardware'
    | 'local_console';
}

对象拒绝未知字段,authenticationId 是有界、非 Secret 的认证相关标识,时间必须为安全整数且 expiresAtMs > authenticatedAtMs。调用发生时还必须满足 authenticatedAtMs <= now < expiresAtMs

该对象证明“谁在何种认证上下文中发起本次请求”,不携带 role、scope 或 permission。Policy 和 bootstrap 不读取 cookie、Authorization header、legacy username 或请求体自报 subject;具体 session/token/mTLS/本机控制台适配器必须在边界外验证 credential,再构造该对象。

2. Issue 只接受本机显式 bootstrap authority

首版 issue 只接受以下仍有效的认证主体:

{
  subject: { type: 'system', id: 'owner-bootstrap' },
  assurance: 'local_console'
}

这不是一个可由 HTTP 请求自行声明的角色。未来 CLI 或本机安装向导必须通过独立的本地控制台证明创建它;当前切片不提供该 production adapter,因此不会因为 service 存在就开放远程 bootstrap。

每次 issue 使用 CSPRNG 生成 16-byte challenge ID 和 32-byte token,分别编码为 canonical base64url。token 仅向本机调用者返回一次;原始随机字节编码后清零,数据库只保存带 domain、Project ID 和 challenge ID 隔离的 SHA-256 digest。默认 TTL 为 10 分钟,调用方只能在 1~30 分钟内选择。错误、日志、审计字段和数据库均不得包含明文 token。

同一 Project 有未过期 challenge 时拒绝重新 issue,不重新显示旧 token;过期后可以追加新版本,旧 challenge 不删除。Project 不存在、已归档或已有任意 RoleBinding 时拒绝 issue。

3. Challenge 版本化持久化,不依赖 timer

0018-project-owner-bootstrap 新增 ProjectOwnerBootstrapChallenges

  • (project_id, version) 为复合主键,challenge_id 全局唯一;
  • 保存 token digest、签发时间和到期时间;
  • claim 后保存消费时间和稳定 subject;
  • (project_id, version DESC) 支持读取当前 challenge
  • 历史行不覆盖、不后台扫描,也不为每个 challenge 创建 timer。

状态由字段和当前时间派生:未消费且 now < expiresAt 为 pending;未消费且已到期为 expired;消费三元组完整时为 claimed。损坏、半空 claimed tuple、非法 digest 或 identity 一律 unavailable 并 fail closed。

该模型在 edge 上只增加一次 issue 写、一次 claim 事务和有界点查。cluster-control 必须提供保持相同 contract 的 PostgreSQL row-lock/CAS adapter;禁止多个控制面副本共享 SQLite adapter。

4. Claim 与首 owner RoleBinding 在同一事务提交

claim 只接受当前仍有效且 subject.type === 'user'AuthenticatedPrincipal,不接受额外 raw subject。SQLite adapter 使用短 IMMEDIATE transaction,并在事务内依次复验:

  1. Project 存在且 active
  2. 指定 challenge 是该 Project 最新版本;
  3. digest 以 constant-time 比较完全匹配;
  4. issuedAt <= now < expiresAt
  5. Project 仍没有任何 RoleBinding
  6. challenge consume UPDATE 恰好影响一行;
  7. 插入 version 1、role owner 的 RoleBinding。

challenge 的 consumed_at/claimed_subject 更新与 owner binding insert 必须在同一事务。任意 constraint、trigger、连接或插入失败都整体回滚;不能留下“challenge 已消费但无 owner”,也不能留下“有 owner 但 challenge 未消费”。RoleBinding mutation ID 为 owner-bootstrap:{challengeId}changedBy 固定为 system/owner-bootstrap,不伪造最终用户自授予。

同 token、同 challenge、同稳定 subject 的重放返回 existing;不同 token、旧 challenge、不同 subject、过期或已被他人消费统一拒绝 claim,不暴露哪一项不匹配。两个 SQLite 连接并发 claim 时只允许一个 subject 成为 owner。

5. Bootstrap 只适用于完全 pristine 的 Project

只要 Project 已存在任意 RoleBinding,即使该 binding 已 revokedbootstrap 都永久关闭。后续 owner 增删、全部 owner 丢失和账户恢复必须走受 Policy、Approval 和 Audit 约束的独立管理/恢复协议,不能重新打开 bootstrap 旁路。

本 ADR 不定义恢复码。恢复码需要单独决定生成、展示一次、离线保存、轮换、吊销、多 owner 门槛和丢失处置;把它混入首 owner challenge 会让一次性建权入口长期存在。

6. 当前切片保持 production unreachable

本切片实现 AuthenticatedPrincipal contract、CSPRNG challenge service、0018 migration、SQLite 原子 repository 和 schema ownershipADR-0030 另已实现默认不可达的稳定 legacy User 映射和 session authentication core。但没有:

  • 本机控制台 credential/安装向导到 local_console principal 的 adapter
  • legacy session authentication core 到 shareStore/Express 的生产装配;
  • CLI、HTTP、MCP 或 UI issue/claim 入口;
  • rate limit、失败审计、安全显示和恢复码;
  • PostgreSQL adapter 与跨副本 contract suite
  • typedi/loader/startup 装配。

因此 ownerless default Project 在现有产品中仍保持 ownerless,3.0 Policy 仍默认拒绝。任何 Express 路由不得直接构造 system/owner-bootstrap,也不得把请求体 username 当成 claim subject。

崩溃与重放语义

崩溃点 恢复结果
token 已生成但 challenge 未提交 无持久事实;该 token 不可 claim,可重新 issue
challenge 已提交但 token 尚未显示 challenge 保持 pending;不会重新显示 token,需到期后重新 issue
consume UPDATE 后、owner insert 前 同一事务回滚,challenge 仍未消费且无 owner
owner insert 后、事务提交前 同一事务回滚,challenge 仍未消费且无 owner
事务已提交但响应丢失 同 token、同 subject 重放返回 existing

影响

正面影响:

  • 首次建权不依赖默认密码、显示用户名或直接改库;
  • challenge 与 RoleBinding 形成可审计的一一关系;
  • 明文 token 不落库,错误不回显 token;
  • edge 无常驻后台成本,cluster adapter 边界明确;
  • 崩溃、重放和多连接竞争有确定结果。

代价与风险:

  • token 显示前崩溃时不能恢复原 token,只能等待到期或由未来本机运维入口显式作废;
  • 当前没有生产 authentication/console adapter,所以该核心不会立即解决用户升级交互;
  • challenge 历史持续增长,需要未来定义保留策略,但不得在 claim 热路径同步清理;
  • 单 owner 丢失后的恢复仍未解决,不能复用本入口绕过正常 Policy。

未选择的方案

  1. 首次登录用户自动成为 owner:登录主体尚未稳定且会把默认密码风险升级为 Project owner,拒绝。
  2. 把明文 bootstrap token 存入数据库:备份或只读泄漏即可直接建权,拒绝。
  3. challenge 仅保存在进程内存:重启后无法判定消费和重放,拒绝。
  4. 先消费 challenge,再通过普通 Role API 建 owner:存在永久 ownerless 的崩溃窗口,拒绝。
  5. 已有 binding 时允许重新 bootstrap:会成为长期权限恢复旁路,拒绝。
  6. 通过远程 HTTP header 声明 local console:认证等级可伪造,拒绝。
  7. edge 为过期 challenge 启动清理 timer:增加常驻成本且过期可在读取时判定,拒绝。

验证要求

  • principal、issue/claim request、challenge record 的非 canonical 输入在副作用前拒绝;
  • 只有 active system/owner-bootstrap + local_console 可以 issue,只有 active user principal 可以 claim
  • challenge/token 长度、TTL 边界、digest domain separation 和明文不落库可验证;
  • active challenge 不可替换,过期后追加版本且旧 token 不可 claim
  • 错 token、过期、旧版本、不同 subject 和非 pristine Project fail closed
  • 同主体重放 existing,双 SQLite 连接不同主体竞争仅一个成功;
  • owner insert 失败或 challenge UPDATE 零行时事务整体回滚;
  • archived/missing Project、损坏记录和非 SQLite adapter fail closed
  • Node 22/24 全量测试、类型检查、schema audit 和 GitNexus reachability 通过;
  • app/loaders/api/services/shared/data 不得导入或装配本切片。