- Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status; stream auto-follow only while the user stays near the bottom (abort stays reachable) - settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript) - download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of time-window engine run matching and path matching; new business-task tab with cancel/retry - workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons - preference tag revision history; subscription search by title/kind; identity/alias/subscription lists show entity titles; slot cards show current asset summary; policy preview sample titles - DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary / OrganizePlanArtifactContent taken from contracts - docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys Co-Authored-By: Claude Opus 5.5 <[email protected]>
9.1 KiB
认证门禁 / 设置 / 实例 / 密钥 / 引擎面板 / SSE 总线(W2-A)
实现:apps/server/src/http/auth.ts(中间件)、apps/server/src/http/sse*.ts(总线)、apps/server/src/modules/auth-settings/(路由与服务)、packages/contracts/src/auth.ts、迁移 0015_admin_auth、0016_sse_event。做法照 docs/design/auth-reference.md(uhd_doc)。
首次启动 / 初始化管理员
- api 进程启动时若
admin_user还没有密码,会生成一次性 setup token(库里只存 sha256),明文打印到日志一次:管理员尚未初始化。一次性 setup token(...):<token>。容器里:docker compose logs app | grep 'setup token'。 - 控制台登录页(
GET /api/v1/auth/session返回initialized:false)进入初始化模式,提交POST /auth/setup {setupToken, username, password}(密码 ≥10 字符)。成功即登录。 - 重启会重新生成 token(旧的作废);多个 api 进程同时启动时以最后打印的为准。初始化用条件 UPDATE(
where password_hash is null and setup_token_hash = …),并发提交只有一个成功,其余 409/401。
忘记密码(需要数据库权限,刻意不走 Web):
update admin_user set username = null, password_hash = null, totp_secret_ref = null, totp_enabled_at = null;
update admin_session set revoked_at = now(), revoke_reason = 'reset' where revoked_at is null;
然后重启 api 进程,按日志里新的 setup token 重新初始化。
登录与会话
| 端点 | 鉴权 | 说明 |
|---|---|---|
GET /auth/session |
免 | {initialized, authenticated, actor, expiresAt, totpEnabled};有会话时顺带补发 CSRF cookie |
POST /auth/setup |
免 | 见上 |
POST /auth/login |
免 | {username, password, totpCode?} → {status:"ok", session} 或 {status:"totp_required"}(密码对但缺验证码,不计失败) |
POST /auth/logout |
登录 | 软删除吊销当前会话并清 cookie |
POST /auth/password |
仅 cookie 会话 | 校验旧密码 → 改密 → 吊销其它所有会话(保留当前) |
GET/POST /auth/totp |
仅 cookie 会话 | {action:"begin"} 返回 otpauth URI;{action:"enable", code};{action:"disable", password, code} |
- 会话:32 字节随机 token 进
umt_sessioncookie(HttpOnly、SameSite=Lax、Path=/,HTTPS 时 Secure),库里只存 sha256;固定 12 小时,不续期;last_seen_at60 秒节流更新。 - CSRF:
umt_csrfcookie(非 HttpOnly)=HMAC-SHA256(key=会话 token, "umt/csrf/v1");cookie 会话的 POST/PUT/PATCH/DELETE 必须带x-csrf-token头等于该值,否则 403。Bearer 调用不校验 CSRF。 - 限速(落
login_attempt表,多进程一致):15 分钟窗口内同 IP 失败 ≥5 次、或同账号失败 ≥15 次 → 429(账号维度取 3 倍,和 uhd_doc 一致,否则单个 IP 就能把管理员锁死)。窗口从最近一次成功之后算起;被拦下的请求不落表、不写审计。setup 失败也计入 IP 维度。 - 客户端 IP:直连对端是回环/私网时才信
X-Real-IP/X-Forwarded-For最后一跳,否则用对端地址;部署在公网反代后面时反代必须覆盖这两个头。 - 密码:argon2id(m=19456 KiB, t=2, p=1),用
hash-wasm(WASM)。没选@node-rs/argon2/@denosaurs/argon2:都是原生库,需要--allow-ffi,而编译后的二进制只授予 net/env/sys/read。输出标准 PHC 串,与 uhd_doc 的 Bun.password 互认。 - TOTP:RFC 6238(SHA1/6 位/30 秒),密钥存 secret(purpose=other),
totp_last_counter防重放。Passkey 未做。 - 清理:worker 每小时删 7 天前的
login_attempt、过期/吊销超过 30 天的admin_session。
鉴权中间件(其他模块怎么用)
/api/v1/*(health 与上面三个免鉴权端点除外)统一经过 apiKeyAuth:
Authorization: Bearer <key>→ API Key(开发模式另认DEV_ADMIN_KEY,生产忽略);- 否则
umt_sessioncookie → 管理员会话(scopes 视为["admin"])。
处理器里:c.get("actor") → { type: "admin" | "api_key", id, name, scopes }(contracts Actor);c.get("session") 仅 cookie 会话有值。路由照旧挂 requireScope("read" | "organize.read" | "organize.execute" | "admin"),两种调用者一视同仁;只允许控制台会话的操作挂 requireAdminSession()。旧的 c.get("principal") 仍可用(kind 多了 admin_session)。写审计时 actor_type:admin → user,API Key → api_key。
密钥服务 ctx.secrets
create({purpose, plaintext, label?}) → secretRef、resolve(ref) → 明文、replace(ref, plaintext)(轮换)、remove(ref)。- AES-256-GCM;每条 secret 随机 16 字节盐经 HKDF-SHA256 从
SECRET_MASTER_KEY派生独立密钥(info=umt/secret/v1),随机 12 字节 nonce,AAD 绑定(id, purpose)(密文挪行/改用途即解密失败)。 - 其它表只存
secret_ref;需要明文时现场resolve,不缓存、不写日志、不进模型上下文。失败抛SecretError(消息只含 ref)。 SECRET_MASTER_KEY与数据库备份分开保管;丢失即所有凭证不可解密(需要重新录入)。主密钥轮换(重新加密全表)尚未实现,key_version已预留。- 管理面:
GET/POST /secrets、PATCH /secrets/{id}(轮换)、DELETE /secrets/{id}(仍被引用 → 409)。只返回元数据。
设置 ctx.settings
get(key)/all():settings 表按分组合并DEFAULT_SETTINGS后校验,缓存 30 秒;PATCH /settings写库后广播settings.changed(经 PG 总线,所有进程立即失效)。需要订阅变化:(ctx.settings as CachedSettingsReader).onChange(keys => …)(modules/auth-settings/provided.ts)。GET /settings(read)、PATCH /settings(admin):按分组部分更新 →mergeSettings→GlobalSettings整体校验(不合法 422)。- 自动化总开关
automation.enabled、恢复后只读对账automation.readOnlyReconcile就在这里改;各处理器执行自动写入前读await ctx.settings.get("automation")自行判断。 - Pi 上游密钥:
pi.apiKey只写,存为 secret(purpose=llm_api_key)回填pi.apiKeySecretRef;传null清除并删 secret。审计里只记(set)/(cleared)。
API Key / 实例 / 审计
GET/POST /api-keys、PATCH /api-keys/{id}、DELETE /api-keys/{id}(撤销)。创建时明文umt_…只返回一次,库里只存 sha256。library-roots、downloader-instances、media-server-instances、path-mappings:CRUD(GET列表/详情 read,写 admin)+DELETE。- 库根登记/改路径时写哨兵文件
.uhd-root(writeRootSentinel),目录不存在或不可写 → 412;POST /library-roots/{id}/test查哨兵在线。 - 实例凭证
credential只写 → secret;POST /downloader-instances/{id}/test(qB:有用户名走登录,否则当 WebUI API Key)、POST /media-server-instances/{id}/test(Emby/Jellyfin ping),结果写回 health。 POST /path-mappings/{id}/verify:本地前缀是否为可达目录。
- 库根登记/改路径时写哨兵文件
PUT /sites/{id}/credentials {label?, authKind, credential, username?}(兼容别名):等价于按 (site, label) 新建或更新一条 site_account,凭证换新 secret(旧的删除),响应不回显。规范接口是/site-accounts资源 CRUD,新客户端请用POST /site-accounts/PATCH /site-accounts/{id}。GET /audit-events?actorType&actorId&action&targetType&targetId&cursor&limit:新→旧,action=admin.*前缀匹配。动作枚举见 contractsAuditAction。
引擎面板(仅 admin,只读直查 openworkflow schema)
GET /engine/runs?status&workflowName&from&to&cursor&limit、GET /engine/runs/{id}(含 stepAttempts、signals)、GET /engine/stats(按状态计数、最老 pending、持有租约的 worker 快照——非权威注册表)、POST /engine/runs/{id}/cancel(走 TaskEngine.cancel,先写业务侧取消标志)。不返回 config/context;input/output/error/signal data 按敏感字段名脱敏。
SSE 多进程总线
PgEventBus(http/sse-pg.ts):publish在事务里持咨询锁插sse_event(seq = bigserial,提交顺序即 seq 顺序)并pg_notify('umt_events', seq);每个进程一条 LISTEN 连接,收到通知按 seq 从表里补读并分发给本进程订阅者;断线自动重连并补读。GET /events?after=<seq>(或Last-Event-ID)从表里补读;表只保留最近 10000 条(每 200 次发布裁剪),起点已被裁剪时先发system.resync。- 谁用 SSE,谁用 JSON 轮询(W4-A 决定):浏览器
EventSource带不了Authorization头,所以 SSE 只给控制台用(cookie 会话umt_session,同源,withCredentials)。外部程序 / API Key 调用者用同一个端点取 JSON 页:GET /events?after=<seq>&limit=<1-1000>(带limit,或只发Accept: application/json时返回 JSON,缺省 100 条),响应{ items: EventEnvelope[], nextAfter, hasMore, latestSeq, gap }。下次用after=nextAfter续读;hasMore=true时立即再取,否则按自己的节奏轮询;gap=true表示起点已被裁剪,先重拉业务快照再从latestSeq续读。数据来自同一张sse_event表,和 SSE 看到的 seq 一致。合同:Api.events.list(EventReplayQuery/EventReplayPage)。 ctx.events.publish(type, payload)返回Promise<EventEnvelope | null>,永不 reject(失败记日志),可以不 await。- 测试/无库场景用进程内
EventBus(publishSync/sinceSync便于断言)。