mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
# 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 已 revoked,bootstrap 都永久关闭。后续 owner 增删、全部 owner 丢失和账户恢复必须走受 Policy、Approval 和 Audit 约束的独立管理/恢复协议,不能重新打开 bootstrap 旁路。
|
||||
|
||||
本 ADR 不定义恢复码。恢复码需要单独决定生成、展示一次、离线保存、轮换、吊销、多 owner 门槛和丢失处置;把它混入首 owner challenge 会让一次性建权入口长期存在。
|
||||
|
||||
### 6. 当前切片保持 production unreachable
|
||||
|
||||
本切片实现 `AuthenticatedPrincipal` contract、CSPRNG challenge service、`0018` migration、SQLite 原子 repository 和 schema ownership;ADR-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 不得导入或装配本切片。
|
||||
Reference in New Issue
Block a user