fix(db): require run owner for all secret and approval answers
answerRunInput already blocked cross-member answers for credential secret asks and always-allow approvals, but one-time allow/deny and generic OTP secret asks had no run.userId check. Align the DB gate so shared-space prompt answers cannot be hijacked at the persistence layer. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
ca292d2cc0
commit
c8c6d9e042
@ -969,7 +969,7 @@ describe("answerRunInput", () => {
|
|||||||
update: vi.fn().mockResolvedValue({ id: "message-1" }),
|
update: vi.fn().mockResolvedValue({ id: "message-1" }),
|
||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
findFirst: vi.fn().mockResolvedValue({ botId: "bot-1" }),
|
findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "user-1" }),
|
||||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
findUnique: vi.fn().mockResolvedValue({ status: "queued" }),
|
findUnique: vi.fn().mockResolvedValue({ status: "queued" }),
|
||||||
},
|
},
|
||||||
@ -1111,6 +1111,54 @@ describe("answerRunInput", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let another workspace member approve a consequential action with allow", async () => {
|
||||||
|
const tx = {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||||
|
message: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({
|
||||||
|
id: "message-1",
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: "ask",
|
||||||
|
approvalEffectId: "effect-1",
|
||||||
|
text: "Review before writing",
|
||||||
|
status: "pending",
|
||||||
|
actions: [
|
||||||
|
{ id: "allow", label: "Allow once" },
|
||||||
|
{ id: "deny", label: "Deny" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
run: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "run-owner" }),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
|
externalEffect: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "effect-1", status: "intended" }),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||||
|
} as unknown as PrismaClient;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
answerRunInput(prisma, {
|
||||||
|
spaceId: "workspace-1",
|
||||||
|
threadId: "thread-1",
|
||||||
|
runId: "run-1",
|
||||||
|
messageId: "message-1",
|
||||||
|
answeredByUserId: "other-member",
|
||||||
|
answer: "allow",
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(tx.externalEffect.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.run.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not let another workspace member create an always-allow rule for the run owner", async () => {
|
it("does not let another workspace member create an always-allow rule for the run owner", async () => {
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||||
@ -1382,6 +1430,50 @@ describe("answerRunInput", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let another workspace member answer a generic secret ask for the run owner", async () => {
|
||||||
|
const store = vi.fn();
|
||||||
|
const tx = {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
|
||||||
|
run: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "owner" }),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({
|
||||||
|
id: "message-1",
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: "ask",
|
||||||
|
text: "Enter your code",
|
||||||
|
input: "secret",
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||||
|
} as unknown as PrismaClient;
|
||||||
|
expect(
|
||||||
|
await answerRunInput(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
spaceId: "workspace-1",
|
||||||
|
threadId: "thread-1",
|
||||||
|
runId: "run-1",
|
||||||
|
messageId: "message-1",
|
||||||
|
answeredByUserId: "other-member",
|
||||||
|
answer: "123456",
|
||||||
|
},
|
||||||
|
new TestFanout(),
|
||||||
|
{ store },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(store).not.toHaveBeenCalled();
|
||||||
|
expect(tx.run.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not let another workspace member save a credential as the run owner", async () => {
|
it("does not let another workspace member save a credential as the run owner", async () => {
|
||||||
const store = vi.fn();
|
const store = vi.fn();
|
||||||
const tx = {
|
const tx = {
|
||||||
|
|||||||
@ -551,11 +551,12 @@ export async function answerRunInput(
|
|||||||
: undefined;
|
: undefined;
|
||||||
if (choiceAsk && !selectedChoice) return null;
|
if (choiceAsk && !selectedChoice) return null;
|
||||||
if (secretAsk && !runSecretWriter) return null;
|
if (secretAsk && !runSecretWriter) return null;
|
||||||
if (secretAsk && pendingAsk.credential && run.userId !== input.answeredByUserId) return null;
|
if (secretAsk && run.userId !== input.answeredByUserId) return null;
|
||||||
let approvalEffect: { id: string; kind: string } | null = null;
|
let approvalEffect: { id: string; kind: string } | null = null;
|
||||||
let approvalUserId: string | null = null;
|
let approvalUserId: string | null = null;
|
||||||
|
|
||||||
if (approvalAsk) {
|
if (approvalAsk) {
|
||||||
|
if (run.userId !== input.answeredByUserId) return null;
|
||||||
if (!pendingAsk.actions?.some((action) => action.id === input.answer)) return null;
|
if (!pendingAsk.actions?.some((action) => action.id === input.answer)) return null;
|
||||||
approvalEffect = await tx.externalEffect.findFirst({
|
approvalEffect = await tx.externalEffect.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -567,7 +568,6 @@ export async function answerRunInput(
|
|||||||
});
|
});
|
||||||
if (!approvalEffect) return null;
|
if (!approvalEffect) return null;
|
||||||
if (input.answer === "always") {
|
if (input.answer === "always") {
|
||||||
if (run.userId !== input.answeredByUserId) return null;
|
|
||||||
approvalUserId = input.answeredByUserId;
|
approvalUserId = input.answeredByUserId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user