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" }),
|
||||
},
|
||||
run: {
|
||||
findFirst: vi.fn().mockResolvedValue({ botId: "bot-1" }),
|
||||
findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "user-1" }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
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 () => {
|
||||
const tx = {
|
||||
$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 () => {
|
||||
const store = vi.fn();
|
||||
const tx = {
|
||||
|
||||
@ -551,11 +551,12 @@ export async function answerRunInput(
|
||||
: undefined;
|
||||
if (choiceAsk && !selectedChoice) 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 approvalUserId: string | null = null;
|
||||
|
||||
if (approvalAsk) {
|
||||
if (run.userId !== input.answeredByUserId) return null;
|
||||
if (!pendingAsk.actions?.some((action) => action.id === input.answer)) return null;
|
||||
approvalEffect = await tx.externalEffect.findFirst({
|
||||
where: {
|
||||
@ -567,7 +568,6 @@ export async function answerRunInput(
|
||||
});
|
||||
if (!approvalEffect) return null;
|
||||
if (input.answer === "always") {
|
||||
if (run.userId !== input.answeredByUserId) return null;
|
||||
approvalUserId = input.answeredByUserId;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user