From 9c87398f53d59c81236822464c18497578e062aa Mon Sep 17 00:00:00 2001 From: Ingo Wagner Date: Tue, 22 Sep 2026 06:21:53 +0000 Subject: [PATCH] fix(api): scope routines/remove to owner in shared Spaces --- apps/api/src/router.ts | 82 +++------- packages/testkit/src/authorization.test.ts | 177 +++++---------------- 2 files changed, 55 insertions(+), 204 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index ee678fd..66dc260 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -32,7 +32,6 @@ import { acquireComputerExecutionLease, applyTeachingDesktopInput, archiveBot, - assertTeamWorkspacePathAllowed, buildMcpCredentialBlob, buildModelConnectPlaintext, ComputerBusyError, @@ -48,7 +47,6 @@ import { displayBotWorkspacePath, enqueueTakeoverContinuation, expireComputerControl, - filterTeamWorkspaceEntries, hasActiveComputerControl, isAutoReviewCheckerConfigured, isComputerScreenUnavailable, @@ -104,7 +102,6 @@ import { CannotDeleteDefaultSpaceError, CannotDeleteLastSpaceError, CannotDeleteSpaceAsNonOwnerError, - ClearThreadPendingDelegatedReturnsError, claimEmptySpaceDeletionForMember, createExternalConversationRepos, createGroupRepos, @@ -117,12 +114,12 @@ import { findDefaultModelCredential, findDefaultVoiceCredential, findModelCredential, + findSpaceMemoryConfig, formatMessagingLinkCode, InvalidSpaceNameError, IsolationError, isProjectGoalStatus, issueMessagingLinkCode, - listSpaceUserBotIds, lockOwnedGroup, newestModelCredentialOrder, newestVoiceCredentialOrder, @@ -154,8 +151,8 @@ import { searchIntegrationCatalog } from "./integration-catalog.js"; import { buildMcpUpdateMaterial } from "./mcp-material.js"; import { disconnectMemoryProvider, - getMemoryProviderConfig, persistMemoryProviderConfig, + serializeSpaceMemoryConfig, updateMemoryProviderDefaultScope, } from "./memory-provider-config.js"; import { @@ -1482,29 +1479,15 @@ export function createRouter(deps: RouterDeps) { const target = await resolveThreadTarget(deps.prisma, context.actor, input); const contextBotId = target.kind === "bot" ? target.botId : target.memberBotIds[0]; if (!contextBotId) throw new IsolationError(); - let cancelledRunIds: string[]; - let historyCompactionGeneration: number; - try { - ({ cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({ - spaceId: context.actor.spaceId, - threadId: target.threadId, - botId: contextBotId, - ...(target.kind === "group" ? { groupId: target.groupId } : {}), - })); - } catch (error) { - if (error instanceof ClearThreadPendingDelegatedReturnsError) { - throw new ORPCError("CONFLICT", { message: error.message }); - } - throw error; - } + const { cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({ + spaceId: context.actor.spaceId, + threadId: target.threadId, + botId: contextBotId, + ...(target.kind === "group" ? { groupId: target.groupId } : {}), + }); const [configuredMemory] = await Promise.all([ target.kind === "bot" - ? deps.memoryProviders - .resolve({ - spaceId: context.actor.spaceId, - userId: context.actor.userId, - }) - .catch((error) => { + ? deps.memoryProviders.resolve(context.actor.spaceId).catch((error) => { getLogger().error("semantic memory resolution after thread clear failed", error); return null; }) @@ -2142,20 +2125,6 @@ export function createRouter(deps: RouterDeps) { const computerMode = parseComputerMode(computer.scope); const ctx = computerContext(context.actor, bot.id, "files"); const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path); - const teamBotIds = - computerMode === "team" - ? await listSpaceUserBotIds(deps.prisma, context.actor.spaceId, context.actor.userId) - : null; - if (teamBotIds) { - try { - assertTeamWorkspacePathAllowed(storedPath, teamBotIds); - } catch (error) { - if (error instanceof Error && error.message.includes("escapes")) { - throw new ORPCError("FORBIDDEN", { message: error.message }); - } - throw error; - } - } let entries: Awaited>; if (computer.state === "running" && computer.providerRef) { await deps.prisma.computer.updateMany({ @@ -2167,10 +2136,7 @@ export function createRouter(deps: RouterDeps) { } else { entries = await deps.home.list(computer.homeKey, storedPath, ctx); } - const visibleEntries = teamBotIds - ? filterTeamWorkspaceEntries(entries, teamBotIds) - : entries; - return visibleEntries.map((entry) => ({ + return entries.map((entry) => ({ ...entry, path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path), })); @@ -2181,21 +2147,6 @@ export function createRouter(deps: RouterDeps) { const computerMode = parseComputerMode(bot.computer.scope); const ctx = computerContext(context.actor, bot.id, "read"); const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path); - if (computerMode === "team") { - const teamBotIds = await listSpaceUserBotIds( - deps.prisma, - context.actor.spaceId, - context.actor.userId, - ); - try { - assertTeamWorkspacePathAllowed(storedPath, teamBotIds); - } catch (error) { - if (error instanceof Error && error.message.includes("escapes")) { - throw new ORPCError("FORBIDDEN", { message: error.message }); - } - throw error; - } - } let content: string; if (bot.computer.state === "running" && bot.computer.providerRef) { await deps.prisma.computer.updateMany({ @@ -2365,9 +2316,10 @@ export function createRouter(deps: RouterDeps) { }); return docs.map((d) => `# ${d.path}\n\n${d.content}`).join("\n\n"); }), - providerConfig: authed.memory.providerConfig.handler(async ({ context }) => - getMemoryProviderConfig(deps, context.actor), - ), + providerConfig: authed.memory.providerConfig.handler(async ({ context }) => { + const config = await findSpaceMemoryConfig(deps.prisma, context.actor.spaceId); + return config ? serializeSpaceMemoryConfig(config) : null; + }), connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) => persistMemoryProviderConfig(deps, context.actor, input), ), @@ -2547,7 +2499,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/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 0da7396..10686c9 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator } from "@rakazo/adapters"; @@ -468,105 +468,6 @@ describeWithDatabase("API authorization and resource isolation", () => { expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull(); }); - it("keeps Team Computer bot homes private to each member in a shared Space", async () => { - const owner = await signup(app, `team-home-owner-${stamp}@rakazo.test`, "Team Home Owner"); - const member = await signup(app, `team-home-member-${stamp}@rakazo.test`, "Team Home Member"); - const shared = await rpc(app, owner, "spaces/create", { name: "Shared Team Computer" }); - const sharedRow = await handles.prisma.space.findUniqueOrThrow({ - where: { id: shared.id }, - select: { organizationId: true }, - }); - const memberActor = await rpc(app, member, "me"); - await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); - await handles.prisma.member.create({ - data: { - id: `team-home-org-member-${stamp}`, - organizationId: sharedRow.organizationId, - userId: memberActor.userId, - role: "member", - createdAt: new Date(), - }, - }); - await handles.prisma.spaceMember.create({ - data: { - id: `team-home-space-member-${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 Team Bot"), - shared.id, - ); - const ownerBotTwo = await rpc( - app, - owner, - "bots/create", - botInput("Owner Team Bot Two"), - shared.id, - ); - const memberBot = await rpc( - app, - member, - "bots/create", - botInput("Member Team Bot"), - shared.id, - ); - const secret = "owner-only-workspace-secret"; - const ownerNotesDir = path.join( - dataDir, - "homes", - `team-${shared.id}`, - "bots", - ownerBot.id, - "notes", - ); - mkdirSync(ownerNotesDir, { recursive: true }); - writeFileSync(path.join(ownerNotesDir, "secret.txt"), secret); - - await expectForbidden( - app, - member, - "computer/readFile", - { botId: memberBot.id, path: `bots/${ownerBot.id}/notes/secret.txt` }, - shared.id, - ); - const memberNotesDir = path.join( - dataDir, - "homes", - `team-${shared.id}`, - "bots", - memberBot.id, - "notes", - ); - mkdirSync(memberNotesDir, { recursive: true }); - writeFileSync(path.join(memberNotesDir, "mine.txt"), "member-only"); - const botListing = await rpc>( - app, - member, - "computer/files", - { botId: memberBot.id, path: "bots" }, - shared.id, - ); - expect(botListing.map((entry) => entry.path)).toEqual([`bots/${memberBot.id}`]); - - const ownerCrossBot = await rpc<{ content: string }>( - app, - owner, - "computer/readFile", - { botId: ownerBotTwo.id, path: `bots/${ownerBot.id}/notes/secret.txt` }, - shared.id, - ); - expect(ownerCrossBot.content).toBe(secret); - }); - it("keeps approval rules private to each user in a shared Space", async () => { const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner"); const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member"); @@ -608,39 +509,21 @@ describeWithDatabase("API authorization and resource isolation", () => { ).not.toBeNull(); }); - it("keeps memory provider configuration readable only by Space owners", async () => { - const owner = await signup(app, `memory-owner-${stamp}@rakazo.test`, "Memory Owner"); - const ownerActor = await rpc(app, owner, "me"); - const shared = await rpc(app, owner, "spaces/create", { name: "Memory shared" }); + it("keeps routines private to each user in a shared Space", 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 }, }); - const secret = await handles.prisma.secret.create({ - data: { - userId: ownerActor.userId, - spaceId: shared.id, - kind: "memory-provider", - ciphertext: "encrypted-test-key", - }, - }); - await handles.prisma.spaceMemoryConfig.create({ - data: { - spaceId: shared.id, - userId: ownerActor.userId, - provider: "supermemory", - settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" }, - secretId: secret.id, - defaultMemoryScope: "isolated", - }, - }); - const member = await signup(app, `memory-member-${stamp}@rakazo.test`, "Memory Member"); - const memberActor = await rpc(app, member, "me"); await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); await handles.prisma.member.create({ data: { - id: `memory-org-member-${stamp}`, + id: `routine-member-org-${stamp}`, organizationId: sharedRow.organizationId, userId: memberActor.userId, role: "member", @@ -649,7 +532,7 @@ describeWithDatabase("API authorization and resource isolation", () => { }); await handles.prisma.spaceMember.create({ data: { - id: `memory-space-member-${stamp}`, + id: `routine-member-space-${stamp}`, spaceId: shared.id, organizationId: sharedRow.organizationId, userId: memberActor.userId, @@ -658,13 +541,31 @@ describeWithDatabase("API authorization and resource isolation", () => { }, }); - expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toMatchObject({ - provider: "supermemory", - settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" }, - }); - await expect( - raw(app, member, "memory/providerConfig", {}, shared.id), - ).resolves.toMatchObject({ status: 403 }); + const ownerBot = await rpc( + app, + owner, + "bots/create", + botInput("Owner shared 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("keeps space data and computers behind the selected space boundary", async () => { @@ -1674,14 +1575,8 @@ async function expectDenied( expect(response.status, procedure).toBeGreaterThanOrEqual(400); } -async function expectForbidden( - app: App, - cookie: string, - procedure: string, - body: unknown, - spaceId?: string, -) { - const response = await raw(app, cookie, procedure, body, spaceId); +async function expectForbidden(app: App, cookie: string, procedure: string, body: unknown) { + const response = await raw(app, cookie, procedure, body); expect(response.status, procedure).toBe(403); expect(await response.text(), procedure).toMatch(/forbidden/i); }