- Capabilities: all 14 registered (AO: inventory.query, download.plan/submit, organize.plan/execute,
policy.preview/apply; agent: task.inspect/retry/cancel); startup asserts count == 14.
Registry injects server-side authorize (automation_grant by action/scope/policy version/budget,
one-shot approval bound to plan id + payload hash + revision + expiry; model claims ignored;
automation switch), audit_event, server idempotency keys and a capability_invocation op ledger
(migration 0021) used for crash reconciliation. download.plan creates draft plans only;
download.submit promotes draft -> reserved after authorization.
- Agent session API: POST/GET /agent/runs, GET /agent/runs/{id} (events by seq, messages, report),
POST .../messages (wake), POST .../abort; automation-grants, approvals, capabilities invoke, tasks.
- runAgent task: Pi or fake provider from settings (apiKey via secrets), events persisted to
agent_run_event then published, resume + ledger reconciliation when pendingOperations > 0,
waits for goal-linked downloads/imports and reports from business tables (submitted != imported).
- Background triggers: decision.created -> bounded plan_only investigation (dedupe per decision);
decision.updated(resolved) -> resolveDecision executes chosen option.
- W2 closure: all OpenAPI routes registered in contracts api.ts (+ subset test); TMDB key from
settings secret_ref with env fallback; PUT /sites/{id}/credentials documented as alias of
/site-accounts; completion events documented as existing status events.
- e2e (real PG + OpenWorkflow + testkit mocks): RSS->library, agent delegation with grant/deny,
background continuation, worker kill -9 recovery (single qB add), auto investigation dedupe.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
8.3 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。ctx.events.publish(type, payload)返回Promise<EventEnvelope | null>,永不 reject(失败记日志),可以不 await。- 测试/无库场景用进程内
EventBus(publishSync/sinceSync便于断言)。