fix(security): W4-E1 approvals admin-session only, shared redact table, TMDB key redaction, prod mock gate

- POST /approvals and PATCH /approvals/{id} require a console admin session
  (EndpointDef auth:"session"); API keys incl. admin scope get 403, so an
  organize.execute key can no longer approve its own plan (S1)
- tests for single-use approval checks: expired, revision/payload hash
  mismatch, consumed, revoked, pending/rejected, plan mismatch; re-approve
  after plan change succeeds once and cannot be replayed (S4)
- one redact key table in @umt/contracts shared by server logger and agent
  redactForModel: rsskey, torrent_pass, authkey, downhash, c_secure_pass,
  passkey variants, x-api-key, cookie headers, nested objects/arrays (S2)
- TMDB network errors: message and cause redacted via redactUrl (S3)
- web Agent ?mock=1 only in DEV builds or when pi.provider is fake (S7)
- EndpointDef auth annotations for /auth/*, asserted against
  PUBLIC_AUTH_PATHS (S10); doc drift fixes (S11)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 22:24:44 +08:00
1 parent 79cdd26457
commit f48e1aac96
25 files changed
+825 -235

No files matched your search

+8 -12
View File
@@ -1,16 +1,12 @@
import { isSensitiveKey, REDACTED, redactString } from "@umt/contracts";
import pino, { type Logger } from "pino";
export const REDACTED = "[REDACTED]";
// 键表与 URL query / 请求头 / Bearer 规则与 Agent 的 redactForModel 共用一份(@umt/contracts redact.ts):
// 键名规范化后命中 passkey / rsskey / torrent_pass / authkey / downhash / c_secure_pass / cookie / x-api-key / *token …
// 即整体替换(数值型如 token 计数除外);其余字符串按 redactString 处理。
export { REDACTED, redactString };
// 字段名包含这些词即视为敏感(数值型如 token 计数除外)
const SENSITIVE_KEY = /passkey|token|cookie|authorization|password|secret|api[-_]?key/i;
// URL query 中的敏感参数
const SENSITIVE_QUERY = /([?&](?:passkey|token|access_token|api_key|apikey|auth|sign)=)[^&#\s"'<>]*/gi;
/** 脱敏字符串中的 URL query 参数 */
export function redactString(s: string): string {
return s.replace(SENSITIVE_QUERY, `$1${REDACTED}`);
}
const sensitive = (k: string, v: unknown) => isSensitiveKey(k) && typeof v !== "number" && v != null;
/** 递归脱敏:敏感字段整体替换,其余字符串做 URL query 脱敏 */
export function redact(value: unknown, depth = 0): unknown {
@@ -21,7 +17,7 @@ export function redact(value: unknown, depth = 0): unknown {
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null) out[k] = REDACTED;
if (sensitive(k, v)) out[k] = REDACTED;
else out[k] = redact(v, depth + 1);
}
return out;
@@ -37,7 +33,7 @@ function redactError(err: Error, depth: number): Record<string, unknown> {
const out: Record<string, unknown> = { type: err.name, message: redactString(err.message) };
if (err.stack) out.stack = redactString(err.stack);
for (const [k, v] of Object.entries(err)) {
out[k] = SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null ? REDACTED : redact(v, depth + 1);
out[k] = sensitive(k, v) ? REDACTED : redact(v, depth + 1);
}
if (err.cause !== undefined) out.cause = redact(err.cause, depth + 1);
return out;
+6 -2
View File
@@ -2,7 +2,7 @@ import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import { type Actor, type ApiScope, apiResponse, type EndpointDef } from "@umt/contracts";
import type { Context } from "hono";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth.ts";
import type { ModuleApp } from "../types.ts";
// 本模块路由封装:吃 contracts 的 EndpointDef(Api.xxx),createRoute 进 OpenAPI + requireScope + 入参校验,
@@ -44,7 +44,11 @@ export function route<D extends EndpointDef>(
path: def.path,
summary: def.summary,
tags: [opts.tag ?? "agent"],
middleware: [requireScope(def.scope as ApiScope)] as never,
// auth: "session" 的端点(审批申请/决定)在 scope 之外再要求控制台管理员会话,API Key 一律 403
middleware: [
requireScope(def.scope as ApiScope),
...(def.auth === "session" ? [requireAdminSession()] : []),
] as never,
request,
responses: {
[status]: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
+1 -1
View File
@@ -290,7 +290,7 @@ export function registerAgentRoutes(
},
{ status: 201 },
);
route(app, Api.approvals.update!, async ({ params, body, actorId }) => {
route(app, Api.approvals.update, async ({ params, body, actorId }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
@@ -1,20 +1,7 @@
import {
AdminSetupRequest,
CSRF_COOKIE,
LoginRequest,
LoginResult,
PasswordChangeRequest,
PasswordChangeResult,
SESSION_COOKIE,
SessionInfo,
TotpAction,
TotpActionResult,
TotpStatus,
} from "@umt/contracts";
import { Api, CSRF_COOKIE, SESSION_COOKIE } from "@umt/contracts";
import type { Context } from "hono";
import { deleteCookie, setCookie } from "hono/cookie";
import { z } from "zod";
import { type AuthEnv, deriveCsrfToken, requireAdminSession } from "../../http/auth.ts";
import { type AuthEnv, deriveCsrfToken } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import {
@@ -38,56 +25,18 @@ import { audit, type Def, endpoint } from "./endpoint.ts";
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
const tags = ["auth"];
const OkResult = z.object({ ok: z.literal(true) });
// 端点定义直接取 contracts 注册表(scope 与 auth 标注只维护一份):setup/login/session 为 auth:"public",
// password/totp 为 auth:"session"(endpoint() 据此跳过 requireScope 或追加 requireAdminSession)。
const D = {
setup: {
method: "post",
path: "/auth/setup",
scope: "read",
summary: "首次初始化管理员",
body: AdminSetupRequest,
response: SessionInfo,
tags,
},
login: {
method: "post",
path: "/auth/login",
scope: "read",
summary: "管理员登录",
body: LoginRequest,
response: LoginResult,
tags,
},
logout: { method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult, tags },
session: {
method: "get",
path: "/auth/session",
scope: "read",
summary: "当前登录态(免鉴权)",
response: SessionInfo,
tags,
},
password: {
method: "post",
path: "/auth/password",
scope: "admin",
summary: "修改密码(吊销其它会话)",
body: PasswordChangeRequest,
response: PasswordChangeResult,
tags,
},
totpGet: { method: "get", path: "/auth/totp", scope: "admin", summary: "TOTP 状态", response: TotpStatus, tags },
totpPost: {
method: "post",
path: "/auth/totp",
scope: "admin",
summary: "TOTP 绑定/启用/停用",
body: TotpAction,
response: TotpActionResult,
tags,
},
} as const satisfies Record<string, Def>;
setup: { ...Api.auth.setup, tags },
login: { ...Api.auth.login, tags },
logout: { ...Api.auth.logout, tags },
session: { ...Api.auth.session, tags },
password: { ...Api.auth.password, tags },
totpGet: { ...Api.auth.totpStatus, tags },
totpPost: { ...Api.auth.totp, tags },
} satisfies Record<string, Def>;
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
@@ -142,53 +91,38 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
return s;
};
endpoint(
app,
D.setup,
async ({ body, c }) => {
const admin = await setupAdmin(deps(), body, clientIp(c));
const s = await startSession(c, admin.id);
const actor = adminActor(admin);
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
ctx.logger.info({ username: admin.username }, "管理员已初始化");
return await sessionInfo(ctx.db, actor, s.expiresAt);
},
{ public: true },
);
endpoint(app, D.setup, async ({ body, c }) => {
const admin = await setupAdmin(deps(), body, clientIp(c));
const s = await startSession(c, admin.id);
const actor = adminActor(admin);
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
ctx.logger.info({ username: admin.username }, "管理员已初始化");
return await sessionInfo(ctx.db, actor, s.expiresAt);
});
endpoint(
app,
D.login,
async ({ body, c }) => {
const r = await verifyLogin(deps(), body, clientIp(c));
if (r.status === "totp_required") return { status: "totp_required" as const };
const s = await startSession(c, r.admin.id);
const actor = adminActor(r.admin);
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
},
{ public: true },
);
endpoint(app, D.login, async ({ body, c }) => {
const r = await verifyLogin(deps(), body, clientIp(c));
if (r.status === "totp_required") return { status: "totp_required" as const };
const s = await startSession(c, r.admin.id);
const actor = adminActor(r.admin);
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
});
endpoint(
app,
D.session,
async ({ c }) => {
const session = c.get("session");
// CSRF cookie 丢了(被清)时顺手补发
if (session) {
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
httpOnly: false,
sameSite: "Lax",
path: "/",
secure: isHttps(c),
expires: session.expiresAt,
});
}
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
},
{ public: true },
);
endpoint(app, D.session, async ({ c }) => {
const session = c.get("session");
// CSRF cookie 丢了(被清)时顺手补发
if (session) {
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
httpOnly: false,
sameSite: "Lax",
path: "/",
secure: isHttps(c),
expires: session.expiresAt,
});
}
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
});
endpoint(app, D.logout, async ({ c, actor }) => {
const session = c.get("session");
@@ -200,44 +134,34 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
return { ok: true as const };
});
endpoint(
app,
D.password,
async ({ body, c, actor }) => {
const session = c.get("session");
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
const revoked = await changePassword(deps(), body, {
adminUserId: session.adminUserId,
sessionId: session.id,
ip: clientIp(c),
});
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
return { revokedSessions: revoked };
},
{ middleware: [requireAdminSession()] },
);
endpoint(app, D.password, async ({ body, c, actor }) => {
const session = c.get("session");
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
const revoked = await changePassword(deps(), body, {
adminUserId: session.adminUserId,
sessionId: session.id,
ip: clientIp(c),
});
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
return { revokedSessions: revoked };
});
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)), { middleware: [requireAdminSession()] });
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)));
endpoint(
app,
D.totpPost,
async ({ body, actor }) => {
const admin = await loadAdmin(ctx.db);
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
if (body.action === "begin") {
const r = await totpBegin(deps(), admin);
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
}
if (body.action === "enable") {
await totpEnable(deps(), admin, body.code);
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
} else {
await totpDisable(deps(), admin, body);
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
}
return { status: totpStatus(await loadAdmin(ctx.db)) };
},
{ middleware: [requireAdminSession()] },
);
endpoint(app, D.totpPost, async ({ body, actor }) => {
const admin = await loadAdmin(ctx.db);
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
if (body.action === "begin") {
const r = await totpBegin(deps(), admin);
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
}
if (body.action === "enable") {
await totpEnable(deps(), admin, body.code);
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
} else {
await totpDisable(deps(), admin, body);
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
}
return { status: totpStatus(await loadAdmin(ctx.db)) };
});
}
@@ -13,7 +13,7 @@ import { type DB, type Kysely, sql } from "@umt/db";
import type { Context, MiddlewareHandler } from "hono";
import type { Logger } from "pino";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp } from "../types.ts";
@@ -39,7 +39,7 @@ export function endpoint<D extends Def>(
extra: {
middleware?: MiddlewareHandler<AuthEnv>[];
status?: 200 | 201;
/** 免鉴权端点(必须同时列在 http/auth.ts 的 PUBLIC_AUTH_PATHS),不挂 requireScope */
/** 免鉴权端点(必须同时列在 http/auth.ts 的 PUBLIC_AUTH_PATHS),不挂 requireScope;等价于 def.auth = "public" */
public?: boolean;
} = {},
) {
@@ -53,7 +53,11 @@ export function endpoint<D extends Def>(
path: def.path,
summary: def.summary,
tags: def.tags ?? ["auth-settings"],
middleware: [...(extra.public ? [] : [requireScope(def.scope)]), ...(extra.middleware ?? [])] as never,
middleware: [
...(extra.public || def.auth === "public" ? [] : [requireScope(def.scope)]),
...(def.auth === "session" ? [requireAdminSession()] : []),
...(extra.middleware ?? []),
] as never,
request,
responses: {
[status]: {
+229
View File
@@ -0,0 +1,229 @@
import type { CapabilitySpec } from "@umt/contracts";
import { sql } from "@umt/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { z } from "zod";
import { createAuthorizer } from "../../src/modules/agent/authorize.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.ts";
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY, json } from "../helpers.ts";
// 验收 S1 / S4:单次审批的申请与决定只接受控制台管理员会话;服务端授权器的审批分支
// (过期、revision / payload hash 变化、已使用、已撤销、计划不符)全部拒绝,通过一次后不可重放。
const hasPg = await pgAvailable();
const PASSWORD = "correct horse battery";
// organize.execute 的最小规格:只关心授权与账本,处理器计数
const ORGANIZE_SPEC = {
id: "organize.execute",
version: 1,
title: "执行整理",
description: "测试用",
kind: "execute",
scope: "organize.execute",
riskLevel: "high",
idempotent: true,
supportsPreview: false,
replay: "never",
inputSchema: z.object({ planId: z.string(), approvalId: z.string().optional() }),
outputSchema: z.object({ ok: z.literal(true) }),
} as unknown as CapabilitySpec;
describe.skipIf(!hasPg)("单次审批(真实 PG)", () => {
let h: Harness;
let admin: Browser;
let orgKey: string;
let adminKey: string;
beforeAll(async () => {
h = await createHarness({ extraModules: [createAgentModule()] });
const token = await ensureSetupToken(h.ctx);
admin = new Browser(h.app);
const r = await admin.req("POST", "/auth/setup", { setupToken: token, username: "admin", password: PASSWORD });
expect(r.status).toBe(200);
const mk = async (name: string, scopes: string[]) =>
(await json(await admin.req("POST", "/api-keys", { name, scopes }))).data.plaintextKey as string;
orgKey = await mk("organizer", ["organize.execute"]);
adminKey = await mk("ops", ["admin"]);
}, 120_000);
afterAll(async () => {
await h?.close();
});
/** 整理计划(artifact kind=plan):payload hash 即 content_hash,revision 固定 1 */
async function seedPlan(hash = "hash-v1") {
const r = await h.db
.insertInto("artifact")
.values({ kind: "plan", title: "organize plan", content: JSON.stringify({}), content_hash: hash })
.returning("id")
.executeTakeFirstOrThrow();
return r.id;
}
async function seedApproval(
planId: string,
over: Partial<{ status: string; payload_hash: string; expected_revision: number; expires_at: Date | null }> = {},
) {
const r = await h.db
.insertInto("approval")
.values({
plan_type: "organize_plan",
plan_id: planId,
payload_hash: over.payload_hash ?? "hash-v1",
expected_revision: over.expected_revision ?? 1,
status: (over.status ?? "approved") as never,
requested_by: "user:admin",
expires_at: over.expires_at === undefined ? new Date(Date.now() + 3600_000) : over.expires_at,
})
.returning("id")
.executeTakeFirstOrThrow();
return r.id;
}
describe("审批端点只接受管理员会话(S1)", () => {
it("持 organize.execute 的 API Key 建审批 403;自批 403;admin scope 的 Key 同样 403", async () => {
const planId = await seedPlan();
const org = bearer(h.app, orgKey);
const ops = bearer(h.app, adminKey);
expect((await org("POST", "/approvals", { planType: "organize_plan", planId })).status).toBe(403);
expect((await ops("POST", "/approvals", { planType: "organize_plan", planId })).status).toBe(403);
expect((await bearer(h.app, DEV_KEY)("POST", "/approvals", { planType: "organize_plan", planId })).status).toBe(
403,
);
// 管理员会话申请一条 pending 审批;Key 想替自己的计划批准 → 403,状态不变
const created = await admin.req("POST", "/approvals", { planType: "organize_plan", planId });
expect(created.status).toBe(201);
const ap = (await json(created)).data;
expect(ap).toMatchObject({ status: "pending", payloadHash: "hash-v1", expectedRevision: 1 });
for (const k of [org, ops]) {
const r = await k("PATCH", `/approvals/${ap.id}`, { status: "approved", payloadHash: ap.payloadHash });
expect(r.status).toBe(403);
}
const still = await h.db.selectFrom("approval").select("status").where("id", "=", ap.id).executeTakeFirst();
expect(still?.status).toBe("pending");
// 查看仍按 read scope 开放给 Key
expect((await ops("GET", `/approvals/${ap.id}`)).status).toBe(200);
});
it("管理员会话可批准;缺 CSRF 403;payloadHash 不一致 409;重复决定 409", async () => {
const planId = await seedPlan();
const ap = (await json(await admin.req("POST", "/approvals", { planType: "organize_plan", planId }))).data;
expect(
(
await admin.req(
"PATCH",
`/approvals/${ap.id}`,
{ status: "approved", payloadHash: ap.payloadHash },
{ csrf: false },
)
).status,
).toBe(403);
expect(
(await admin.req("PATCH", `/approvals/${ap.id}`, { status: "approved", payloadHash: "other" })).status,
).toBe(409);
const ok = await admin.req("PATCH", `/approvals/${ap.id}`, { status: "approved", payloadHash: ap.payloadHash });
expect(ok.status).toBe(200);
expect((await json(ok)).data).toMatchObject({ status: "approved", decidedBy: expect.stringMatching(/^user:/) });
expect(
(await admin.req("PATCH", `/approvals/${ap.id}`, { status: "rejected", payloadHash: ap.payloadHash })).status,
).toBe(409);
const audits = await sql<{ n: number }>`
select count(*)::int as n from audit_event where action = 'approval.approved' and target_id = ${ap.id}`.execute(
h.db,
);
expect(audits.rows[0]!.n).toBe(1);
});
});
describe("授权器的单次审批分支(S4)", () => {
const check = (input: Record<string, unknown>) =>
createAuthorizer({ db: h.db, settings: h.ctx.settings })({
descriptor: ORGANIZE_SPEC,
input,
ctx: { actorId: "api_key:k1", taskId: "t", scopeIds: [], signal: AbortSignal.timeout(10_000), source: "web" },
});
it("有效审批放行并带回 approvalId", async () => {
const planId = await seedPlan();
const approvalId = await seedApproval(planId);
expect(await check({ planId, approvalId })).toEqual({ allowed: true, approvalId });
});
it.each([
["过期", { expires_at: new Date(Date.now() - 1000) }, /已过期/],
["revision 不匹配", { expected_revision: 2 }, /revision 已变化/],
["payload hash 不匹配", { payload_hash: "hash-v0" }, /内容已变化/],
["已使用", { status: "consumed" }, /consumed/],
["已撤销", { status: "revoked" }, /revoked/],
["未批准", { status: "pending" }, /pending/],
["已拒绝", { status: "rejected" }, /rejected/],
] as const)("%s → 拒绝", async (_name, over, reason) => {
const planId = await seedPlan();
const approvalId = await seedApproval(planId, over);
const d = await check({ planId, approvalId });
expect(d.allowed).toBe(false);
expect((d as { reason: string }).reason).toMatch(reason);
});
it("审批绑定的计划与本次调用不符 → 拒绝;审批不存在 → 拒绝", async () => {
const planA = await seedPlan();
const planB = await seedPlan();
const approvalId = await seedApproval(planA);
expect(await check({ planId: planB, approvalId })).toMatchObject({ allowed: false, reason: /不一致/ });
expect(await check({ planId: planA, approvalId: "00000000-0000-4000-8000-000000000000" })).toMatchObject({
allowed: false,
reason: /不存在/,
});
});
it("审批后改计划 → 被拒 → 重新审批 → 成功且只消费一次,之后不可重放", async () => {
const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings });
let calls = 0;
registry.register(ORGANIZE_SPEC, async () => {
calls++;
return { ok: true as const };
});
const invoke = (input: Record<string, unknown>, taskId: string) =>
registry.invoke("organize.execute", input, {
actorId: "api_key:k1",
taskId,
scopeIds: [],
signal: AbortSignal.timeout(10_000),
source: "web",
});
const planId = await seedPlan("hash-a");
const ap1 = (await json(await admin.req("POST", "/approvals", { planType: "organize_plan", planId }))).data;
await admin.req("PATCH", `/approvals/${ap1.id}`, { status: "approved", payloadHash: ap1.payloadHash });
// 计划内容变了:旧审批失效
await h.db.updateTable("artifact").set({ content_hash: "hash-b" }).where("id", "=", planId).execute();
await expect(invoke({ planId, approvalId: ap1.id }, "http:r1")).rejects.toThrow(/内容已变化/);
expect(calls).toBe(0);
const ap2 = (await json(await admin.req("POST", "/approvals", { planType: "organize_plan", planId }))).data;
expect(ap2.payloadHash).toBe("hash-b");
await admin.req("PATCH", `/approvals/${ap2.id}`, { status: "approved", payloadHash: ap2.payloadHash });
await expect(invoke({ planId, approvalId: ap2.id }, "http:r2")).resolves.toEqual({ ok: true });
expect(calls).toBe(1);
const row = await h.db.selectFrom("approval").select("status").where("id", "=", ap2.id).executeTakeFirst();
expect(row?.status).toBe("consumed");
// 换一个请求重放同一审批 → 拒绝,处理器不再执行
await expect(invoke({ planId, approvalId: ap2.id }, "http:r3")).rejects.toThrow(/consumed/);
expect(calls).toBe(1);
const ledger = await h.db
.selectFrom("capability_invocation")
.select(["approval_id", "status"])
.where("approval_id", "=", ap2.id)
.execute();
expect(ledger).toEqual([{ approval_id: ap2.id, status: "succeeded" }]);
const denied = await sql<{ n: number }>`
select count(*)::int as n from audit_event where action = 'capability.denied' and target_id = 'organize.execute'`.execute(
h.db,
);
expect(denied.rows[0]!.n).toBeGreaterThanOrEqual(2);
});
});
});
+48
View File
@@ -1,6 +1,7 @@
import { API_PREFIX, listEndpoints } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { createApp } from "../src/http/app.ts";
import { PUBLIC_AUTH_PATHS } from "../src/http/auth.ts";
import { EventBus } from "../src/http/sse.ts";
import { SERVER_MODULES } from "../src/modules/index.ts";
import type { ServerContext } from "../src/modules/types.ts";
@@ -41,3 +42,50 @@ describe("Api 注册表覆盖", () => {
expect(unimplemented, `以下端点登记在 api.ts 但未实现:\n${unimplemented.join("\n")}`).toEqual([]);
});
});
// 验收 S10:注册表的鉴权标注与服务端实际行为一致(生成 scope 文档不误导集成方)
describe("Api 注册表鉴权标注", () => {
it("auth:'public' 的端点恰好是 PUBLIC_AUTH_PATHS", () => {
const pub = listEndpoints()
.filter((e) => e.auth === "public")
.map((e) => e.path)
.sort();
expect(pub).toEqual([...PUBLIC_AUTH_PATHS].sort());
});
it("只接受控制台会话的端点都标 auth:'session' 且 scope 为 admin", () => {
const session = listEndpoints()
.filter((e) => e.auth === "session")
.map((e) => `${e.method.toUpperCase()} ${e.path}`)
.sort();
expect(session).toEqual([
"GET /auth/totp",
"PATCH /approvals/{id}",
"POST /approvals",
"POST /auth/password",
"POST /auth/totp",
]);
for (const e of listEndpoints().filter((x) => x.auth === "session")) expect(e.scope).toBe("admin");
});
it("auth:'session' 的端点拒绝 Bearer(DEV_ADMIN_KEY 也不行)", async () => {
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger: memoryLogger().logger,
bus: new EventBus(5),
role: "all",
version: "0.0.0-test",
modules: SERVER_MODULES,
ctx: { role: "all" } as unknown as ServerContext,
});
for (const e of listEndpoints().filter((x) => x.auth === "session")) {
const path = e.path.replace("{id}", "00000000-0000-4000-8000-000000000000");
const res = await app.request(`${API_PREFIX}${path}`, {
method: e.method.toUpperCase(),
headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" },
...(e.method === "get" ? {} : { body: "{}" }),
});
expect(res.status, `${e.method} ${e.path}`).toBe(403);
}
});
});
+12 -3
View File
@@ -10,7 +10,7 @@ import { EventBus } from "../../src/http/sse.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createAuthSettingsModule } from "../../src/modules/auth-settings/index.ts";
import { createSecretStore, createSettingsReader } from "../../src/modules/auth-settings/provided.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import type { ServerContext, ServerModule } from "../../src/modules/types.ts";
import { DEV_KEY, memoryLogger } from "../helpers.ts";
// 真实 PG 的 W2-A 测试夹具:独立业务 schema + 独立引擎 schema;任务引擎用假对象(只测 cancel 透传)。
@@ -64,7 +64,7 @@ async function adminQuery(q: string) {
}
}
export async function createHarness(opts: { engine?: boolean } = {}) {
export async function createHarness(opts: { engine?: boolean; extraModules?: ServerModule[] } = {}) {
const uniq = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const schema = `w2a_test_${uniq}`;
const engineSchema = `${schema}_ow`;
@@ -100,7 +100,16 @@ export async function createHarness(opts: { engine?: boolean } = {}) {
capabilities: createCapabilityRegistry({ logger }),
} as ServerContext;
const mod = createAuthSettingsModule({ setupTokenOnStart: false, engineSchema });
const app = createApp({ config, logger, bus, db, role: "all", version: "0.0.0-test", modules: [mod], ctx });
const app = createApp({
config,
logger,
bus,
db,
role: "all",
version: "0.0.0-test",
modules: [mod, ...(opts.extraModules ?? [])],
ctx,
});
return {
db,
app,
+93
View File
@@ -60,3 +60,96 @@ describe("日志脱敏:child 绑定与 Error 序列化", () => {
expect(redact(new Error("x?token=abc"))).toMatchObject({ type: "Error", message: "x?token=[REDACTED]" });
});
});
// 验收 S2:站点常见凭证参数/键名全部覆盖,键表与 Agent redactForModel 同源(@umt/contracts)
describe("日志脱敏:站点凭证键全集", () => {
const PARAMS = [
"passkey",
"PassKey",
"pass_key",
"pass-key",
"rsskey",
"torrent_pass",
"authkey",
"downhash",
"c_secure_pass",
"api_key",
"apikey",
"token",
];
it.each(PARAMS)("URL query 参数 %s 被遮住", (name) => {
const out = redactString(`GET https://pt.example/rss.php?id=7&${name}=SECRETVALUE9&x=1`);
expect(out).not.toContain("SECRETVALUE9");
expect(out).toContain(`${name}=[REDACTED]`);
expect(out).toContain("id=7");
expect(out).toContain("x=1");
});
it("验收报告里的实测串一次性全部遮住", () => {
const out = redactString("/rss?rsskey=abc1&torrent_pass=dead2&authkey=zz3&downhash=q14&c_secure_pass=cc5");
for (const s of ["abc1", "dead2", "zz3", "q14", "cc5"]) expect(out).not.toContain(s);
});
const KEYS = [
"passkey",
"PassKey",
"pass-key",
"pass_key",
"PASSKEY",
"rsskey",
"rssKey",
"torrent_pass",
"torrentPass",
"authkey",
"authKey",
"downhash",
"c_secure_pass",
"x-api-key",
"X-Api-Key",
"apiKey",
"cookie",
"Cookie",
"set-cookie",
"authorization",
"accessToken",
];
it.each(KEYS)("对象键 %s 在嵌套对象与数组里都整体替换", (key) => {
const input = { top: { [key]: "SECRETV" }, list: [{ deep: { [key]: "SECRETW" } }], [key]: ["SECRETX"] };
const out = redact(input) as Record<string, unknown>;
expect(JSON.stringify(out)).not.toMatch(/SECRET[VWX]/);
expect((out.top as Record<string, unknown>)[key]).toBe("[REDACTED]");
expect(out[key]).toBe("[REDACTED]");
});
it("cookie / x-api-key 请求头行(字符串里)被遮住", () => {
const out = redactString("request headers:\ncookie: c_secure_pass=abc; uid=1\nx-api-key: KKKKKKKK\nAccept: */*");
expect(out).not.toMatch(/abc|KKKKKKKK/);
expect(out).toContain("Accept: */*");
});
it("日志输出里 headers / 数组中的凭证不出现", () => {
const { logger, lines } = memoryLogger();
logger.warn(
{
req: { headers: { Cookie: "SECRET_A", "X-Api-Key": "SECRET_B", accept: "json" } },
urls: ["https://x/rss?rsskey=SECRET_C", "https://x/dl?torrent_pass=SECRET_D&downhash=SECRET_E"],
site: { authKey: "SECRET_F", c_secure_pass: "SECRET_G" },
usage: { inputTokens: 42 },
},
"fetch https://x/?authkey=SECRET_H&c_secure_pass=SECRET_I",
);
const out = lines.join("");
expect(out).not.toMatch(/SECRET_[A-I]/);
const line = JSON.parse(lines.at(-1)!);
expect(line.req.headers.accept).toBe("json");
expect(line.usage.inputTokens).toBe(42);
});
it("与 Agent 的 redactForModel 同源:同一输入得到同样的遮挡结果", async () => {
const { redactString: agentRedact } = await import("@umt/agent");
const s = "https://x/rss?rsskey=a1&torrent_pass=b2&authkey=c3&downhash=d4&c_secure_pass=e5&passkey=f6";
expect(redactString(s)).toBe(agentRedact(s));
});
});