From db39fe77520a04547ab0663d9074c24cf0a78949 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:57:35 +0200 Subject: [PATCH] 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 * test: drop unused ownerActor in routine remove authz case Co-authored-by: cynarAI --------- Co-authored-by: Cursor Agent Co-authored-by: cynarAI --- apps/api/src/router.ts | 6 +- apps/api/src/routine-remove-authz.test.ts | 18 +++++ packages/testkit/src/authorization.test.ts | 78 +++++++++++++++++++--- 3 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/routine-remove-authz.test.ts diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 29a9a55..0040536 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -2431,7 +2431,11 @@ export function createRouter(deps: RouterDeps) { }), remove: authed.routines.remove.handler(async ({ context, input }) => { 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(); await deps.prisma.routine.delete({ where: { id: existing.id } }); diff --git a/apps/api/src/routine-remove-authz.test.ts b/apps/api/src/routine-remove-authz.test.ts new file mode 100644 index 0000000..1891eb7 --- /dev/null +++ b/apps/api/src/routine-remove-authz.test.ts @@ -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"); + }); +}); diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 9e83e7e..1891235 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -128,7 +128,10 @@ describeWithDatabase("API authorization and resource isolation", () => { ["memory/update", { documentId: "missing-memory", content: "Nope" }], ["memory/exportMarkdown", {}], ["memory/providerConfig"], - ["memory/connectProvider", { provider: "test", settings: {}, credentials: {}, defaultMemoryScope: "isolated" }], + [ + "memory/connectProvider", + { provider: "test", settings: {}, credentials: {}, defaultMemoryScope: "isolated" }, + ], ["memory/setDefaultScope", { defaultMemoryScope: "shared" }], ["memory/disconnectProvider"], ["routines/list", { botId: "missing-bot" }], @@ -545,6 +548,53 @@ describeWithDatabase("API authorization and resource isolation", () => { 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(app, member, "me"); + + const shared = await rpc(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(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 () => { const owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner"); 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/connectProvider", { - provider: "supermemory", - 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/connectProvider", + { + provider: "supermemory", + 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); expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toEqual({