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:
cynarAI 2026-09-22 10:57:35 +02:00 committed by GitHub
parent 492e0f0965
commit db39fe7752
3 changed files with 93 additions and 9 deletions

View File

@ -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 } });

View 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");
});
});

View File

@ -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({