fix(api): key-first sweep scope, audit at warn, mixed-scope guard for completed batches

Round-2 findings of the omni-code-review battery on the previous commit:

- a presented API key always scopes the sweep to that key, even when the request
  also carries a dashboard session cookie (parity with GET /v1/batches; a leaked
  or over-shared key can never widen a destructive sweep); only a session without
  a key sweeps the whole instance
- both sweep modes log at warn so the audit trail survives APP_LOG_LEVEL=warn;
  the failure log carries the error stack
- a scope carrying both apiKeyId and allTenants is rejected instead of widening
- changelog fragment links the PR
This commit is contained in:
diegosouzapw
2026-09-10 14:51:59 -03:00
parent 43aba7dd7e
commit becb5954cc
5 changed files with 62 additions and 21 deletions

View File

@@ -139,6 +139,15 @@ describe("deleteCompletedBatches — ownership boundary (GHSA-wvxc-jp3v-5mg5)",
}),
/apiKeyId required unless allTenants/
);
assert.throws(
// A mixed scope must be rejected, never silently widened to the instance.
() =>
(deleteCompletedBatches as unknown as (s: unknown) => unknown)({
apiKeyId: "key_survivor_wvxc",
allTenants: true,
}),
/mutually exclusive/
);
assert.ok(getBatch(survivor.batch.id), "a rejected call must not delete anything");
assert.strictEqual(

View File

@@ -112,16 +112,12 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp
assert.ok(getBatch(victim.batch.id), "key B's batch still survives");
});
it("a dashboard session sweeps the whole instance — even when the request also carries an API key", async () => {
const keyA = await createApiKey("wvxc-route-session-a", "machine-wvxc-sa", []);
it("a dashboard session WITHOUT a key sweeps the whole instance", async () => {
const keyB = await createApiKey("wvxc-route-session-b", "machine-wvxc-sb", []);
const other = seedCompletedBatch(keyB.id, "wvxc-route-session-other");
const unowned = seedCompletedBatch(null, "wvxc-route-session-unowned");
const { res, body } = await callDelete({
Authorization: `Bearer ${keyA.key}`,
cookie: await sessionCookie(),
});
const { res, body } = await callDelete({ cookie: await sessionCookie() });
assert.strictEqual(res.status, 200);
assert.ok(
@@ -133,6 +129,33 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp
assert.strictEqual(getFile(other.file.id), null, "session sweep soft-deletes the files too");
});
it("a request carrying BOTH a session cookie and an API key is scoped to the key (the key wins, like GET /v1/batches)", async () => {
const keyA = await createApiKey("wvxc-route-both-a", "machine-wvxc-ba", []);
const keyB = await createApiKey("wvxc-route-both-b", "machine-wvxc-bb", []);
const own = seedCompletedBatch(keyA.id, "wvxc-route-both-own");
const other = seedCompletedBatch(keyB.id, "wvxc-route-both-other");
const unowned = seedCompletedBatch(null, "wvxc-route-both-unowned");
const { res, body } = await callDelete({
Authorization: `Bearer ${keyA.key}`,
cookie: await sessionCookie(),
});
assert.strictEqual(res.status, 200);
assert.strictEqual(body.deletedBatches, 1, "only key A's own completed batch is swept");
assert.strictEqual(getBatch(own.batch.id), null, "key A's own batch is swept");
assert.ok(
getBatch(other.batch.id),
"key B's batch survives — a presented key never widens the sweep"
);
assert.ok(getBatch(unowned.batch.id), "the unowned batch survives a key-scoped sweep");
assert.strictEqual(
getFileContent(other.file.id)?.toString(),
"wvxc-route-both-other",
"key B's file content is intact"
);
});
it("rejects an unauthenticated request with 401 and deletes nothing", async () => {
const keyB = await createApiKey("wvxc-route-401-b", "machine-wvxc-401", []);
const seeded = seedCompletedBatch(keyB.id, "wvxc-route-401");