fix(api): scope routine remove to acting user in shared Spaces (#61)
* fix(api): scope routine remove to acting user in shared Spaces routines/remove only filtered by spaceId, so any member of a shared Space could delete another member's routine by ID. Match update/testRun by also requiring userId in the lookup. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * test: drop unused ownerActor in routine remove authz case Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
492e0f0965
commit
db39fe7752
@ -2431,7 +2431,11 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}),
|
}),
|
||||||
remove: authed.routines.remove.handler(async ({ context, input }) => {
|
remove: authed.routines.remove.handler(async ({ context, input }) => {
|
||||||
const existing = await deps.prisma.routine.findFirst({
|
const existing = await deps.prisma.routine.findFirst({
|
||||||
where: { id: input.routineId, spaceId: context.actor.spaceId },
|
where: {
|
||||||
|
id: input.routineId,
|
||||||
|
spaceId: context.actor.spaceId,
|
||||||
|
userId: context.actor.userId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!existing) throw new IsolationError();
|
if (!existing) throw new IsolationError();
|
||||||
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
||||||
|
|||||||
18
apps/api/src/routine-remove-authz.test.ts
Normal file
18
apps/api/src/routine-remove-authz.test.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("routine remove authorization wiring", () => {
|
||||||
|
it("scopes remove lookups to the acting user like update and testRun", () => {
|
||||||
|
const routerSource = readFileSync(
|
||||||
|
resolve(dirname(fileURLToPath(import.meta.url)), "router.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const removeHandler = routerSource.match(
|
||||||
|
/remove: authed\.routines\.remove\.handler\(async \(\{ context, input \}\) => \{[\s\S]*?\}\),/,
|
||||||
|
)?.[0];
|
||||||
|
expect(removeHandler).toBeTruthy();
|
||||||
|
expect(removeHandler).toContain("userId: context.actor.userId");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -128,7 +128,10 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
["memory/update", { documentId: "missing-memory", content: "Nope" }],
|
["memory/update", { documentId: "missing-memory", content: "Nope" }],
|
||||||
["memory/exportMarkdown", {}],
|
["memory/exportMarkdown", {}],
|
||||||
["memory/providerConfig"],
|
["memory/providerConfig"],
|
||||||
["memory/connectProvider", { provider: "test", settings: {}, credentials: {}, defaultMemoryScope: "isolated" }],
|
[
|
||||||
|
"memory/connectProvider",
|
||||||
|
{ provider: "test", settings: {}, credentials: {}, defaultMemoryScope: "isolated" },
|
||||||
|
],
|
||||||
["memory/setDefaultScope", { defaultMemoryScope: "shared" }],
|
["memory/setDefaultScope", { defaultMemoryScope: "shared" }],
|
||||||
["memory/disconnectProvider"],
|
["memory/disconnectProvider"],
|
||||||
["routines/list", { botId: "missing-bot" }],
|
["routines/list", { botId: "missing-bot" }],
|
||||||
@ -545,6 +548,53 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
expect(computer.controlBotId).toBe(ownerBot.id);
|
expect(computer.controlBotId).toBe(ownerBot.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prevents one shared-space member from deleting another member's routine", async () => {
|
||||||
|
const owner = await signup(app, `routine-owner-${stamp}@rakazo.test`, "Routine Owner");
|
||||||
|
const member = await signup(app, `routine-member-${stamp}@rakazo.test`, "Routine Member");
|
||||||
|
const memberActor = await rpc<Actor>(app, member, "me");
|
||||||
|
|
||||||
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared routines" });
|
||||||
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
||||||
|
where: { id: shared.id },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
||||||
|
await handles.prisma.member.create({
|
||||||
|
data: {
|
||||||
|
id: `routine-member-org-${stamp}`,
|
||||||
|
organizationId: sharedRow.organizationId,
|
||||||
|
userId: memberActor.userId,
|
||||||
|
role: "member",
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await handles.prisma.spaceMember.create({
|
||||||
|
data: {
|
||||||
|
id: `routine-member-space-${stamp}`,
|
||||||
|
spaceId: shared.id,
|
||||||
|
organizationId: sharedRow.organizationId,
|
||||||
|
userId: memberActor.userId,
|
||||||
|
role: "member",
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerBot = await rpc<Bot>(app, owner, "bots/create", botInput("Owner Bot"), shared.id);
|
||||||
|
const ownerRoutine = await rpc<{ id: string }>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"routines/create",
|
||||||
|
routineInput(ownerBot.id),
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectDenied(app, member, "routines/remove", { routineId: ownerRoutine.id }, shared.id);
|
||||||
|
expect(
|
||||||
|
await handles.prisma.routine.findUnique({ where: { id: ownerRoutine.id } }),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("scopes Team Computer export to the exporting member's bot workspace in a shared Space", async () => {
|
it("scopes Team Computer export to the exporting member's bot workspace in a shared Space", async () => {
|
||||||
const owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner");
|
const owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner");
|
||||||
const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member");
|
const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member");
|
||||||
@ -910,13 +960,25 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expectForbidden(app, member, "memory/providerConfig", {}, shared.id);
|
await expectForbidden(app, member, "memory/providerConfig", {}, shared.id);
|
||||||
await expectForbidden(app, member, "memory/connectProvider", {
|
await expectForbidden(
|
||||||
provider: "supermemory",
|
app,
|
||||||
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
member,
|
||||||
credentials: { apiKey: "not-a-real-key" },
|
"memory/connectProvider",
|
||||||
defaultMemoryScope: "isolated",
|
{
|
||||||
}, shared.id);
|
provider: "supermemory",
|
||||||
await expectForbidden(app, member, "memory/setDefaultScope", { defaultMemoryScope: "shared" }, shared.id);
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
||||||
|
credentials: { apiKey: "not-a-real-key" },
|
||||||
|
defaultMemoryScope: "isolated",
|
||||||
|
},
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
await expectForbidden(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"memory/setDefaultScope",
|
||||||
|
{ defaultMemoryScope: "shared" },
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
await expectForbidden(app, member, "memory/disconnectProvider", {}, shared.id);
|
await expectForbidden(app, member, "memory/disconnectProvider", {}, shared.id);
|
||||||
|
|
||||||
expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toEqual({
|
expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toEqual({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user