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:
1 parent
79cdd26457
commit
f48e1aac96
25 files changed
+825
-235
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user