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

153 lines
9.5 KiB
Markdown
Raw 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.
# 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 只交付严格认证主体
认证边界输出:
```ts
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 只接受以下仍有效的认证主体:
```ts
{
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 不得导入或装配本切片。