fix(api): scope routines/remove to owner in shared Spaces

This commit is contained in:
Ingo Wagner 2026-09-22 06:21:53 +00:00
parent e8e0b20828
commit 9c87398f53
2 changed files with 55 additions and 204 deletions

View File

@ -32,7 +32,6 @@ import {
acquireComputerExecutionLease, acquireComputerExecutionLease,
applyTeachingDesktopInput, applyTeachingDesktopInput,
archiveBot, archiveBot,
assertTeamWorkspacePathAllowed,
buildMcpCredentialBlob, buildMcpCredentialBlob,
buildModelConnectPlaintext, buildModelConnectPlaintext,
ComputerBusyError, ComputerBusyError,
@ -48,7 +47,6 @@ import {
displayBotWorkspacePath, displayBotWorkspacePath,
enqueueTakeoverContinuation, enqueueTakeoverContinuation,
expireComputerControl, expireComputerControl,
filterTeamWorkspaceEntries,
hasActiveComputerControl, hasActiveComputerControl,
isAutoReviewCheckerConfigured, isAutoReviewCheckerConfigured,
isComputerScreenUnavailable, isComputerScreenUnavailable,
@ -104,7 +102,6 @@ import {
CannotDeleteDefaultSpaceError, CannotDeleteDefaultSpaceError,
CannotDeleteLastSpaceError, CannotDeleteLastSpaceError,
CannotDeleteSpaceAsNonOwnerError, CannotDeleteSpaceAsNonOwnerError,
ClearThreadPendingDelegatedReturnsError,
claimEmptySpaceDeletionForMember, claimEmptySpaceDeletionForMember,
createExternalConversationRepos, createExternalConversationRepos,
createGroupRepos, createGroupRepos,
@ -117,12 +114,12 @@ import {
findDefaultModelCredential, findDefaultModelCredential,
findDefaultVoiceCredential, findDefaultVoiceCredential,
findModelCredential, findModelCredential,
findSpaceMemoryConfig,
formatMessagingLinkCode, formatMessagingLinkCode,
InvalidSpaceNameError, InvalidSpaceNameError,
IsolationError, IsolationError,
isProjectGoalStatus, isProjectGoalStatus,
issueMessagingLinkCode, issueMessagingLinkCode,
listSpaceUserBotIds,
lockOwnedGroup, lockOwnedGroup,
newestModelCredentialOrder, newestModelCredentialOrder,
newestVoiceCredentialOrder, newestVoiceCredentialOrder,
@ -154,8 +151,8 @@ import { searchIntegrationCatalog } from "./integration-catalog.js";
import { buildMcpUpdateMaterial } from "./mcp-material.js"; import { buildMcpUpdateMaterial } from "./mcp-material.js";
import { import {
disconnectMemoryProvider, disconnectMemoryProvider,
getMemoryProviderConfig,
persistMemoryProviderConfig, persistMemoryProviderConfig,
serializeSpaceMemoryConfig,
updateMemoryProviderDefaultScope, updateMemoryProviderDefaultScope,
} from "./memory-provider-config.js"; } from "./memory-provider-config.js";
import { import {
@ -1482,29 +1479,15 @@ export function createRouter(deps: RouterDeps) {
const target = await resolveThreadTarget(deps.prisma, context.actor, input); const target = await resolveThreadTarget(deps.prisma, context.actor, input);
const contextBotId = target.kind === "bot" ? target.botId : target.memberBotIds[0]; const contextBotId = target.kind === "bot" ? target.botId : target.memberBotIds[0];
if (!contextBotId) throw new IsolationError(); if (!contextBotId) throw new IsolationError();
let cancelledRunIds: string[]; const { cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({
let historyCompactionGeneration: number; spaceId: context.actor.spaceId,
try { threadId: target.threadId,
({ cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({ botId: contextBotId,
spaceId: context.actor.spaceId, ...(target.kind === "group" ? { groupId: target.groupId } : {}),
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 [configuredMemory] = await Promise.all([ const [configuredMemory] = await Promise.all([
target.kind === "bot" target.kind === "bot"
? deps.memoryProviders ? deps.memoryProviders.resolve(context.actor.spaceId).catch((error) => {
.resolve({
spaceId: context.actor.spaceId,
userId: context.actor.userId,
})
.catch((error) => {
getLogger().error("semantic memory resolution after thread clear failed", error); getLogger().error("semantic memory resolution after thread clear failed", error);
return null; return null;
}) })
@ -2142,20 +2125,6 @@ export function createRouter(deps: RouterDeps) {
const computerMode = parseComputerMode(computer.scope); const computerMode = parseComputerMode(computer.scope);
const ctx = computerContext(context.actor, bot.id, "files"); const ctx = computerContext(context.actor, bot.id, "files");
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path); 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<ReturnType<SandboxProvider["listFiles"]>>; let entries: Awaited<ReturnType<SandboxProvider["listFiles"]>>;
if (computer.state === "running" && computer.providerRef) { if (computer.state === "running" && computer.providerRef) {
await deps.prisma.computer.updateMany({ await deps.prisma.computer.updateMany({
@ -2167,10 +2136,7 @@ export function createRouter(deps: RouterDeps) {
} else { } else {
entries = await deps.home.list(computer.homeKey, storedPath, ctx); entries = await deps.home.list(computer.homeKey, storedPath, ctx);
} }
const visibleEntries = teamBotIds return entries.map((entry) => ({
? filterTeamWorkspaceEntries(entries, teamBotIds)
: entries;
return visibleEntries.map((entry) => ({
...entry, ...entry,
path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path), 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 computerMode = parseComputerMode(bot.computer.scope);
const ctx = computerContext(context.actor, bot.id, "read"); const ctx = computerContext(context.actor, bot.id, "read");
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path); 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; let content: string;
if (bot.computer.state === "running" && bot.computer.providerRef) { if (bot.computer.state === "running" && bot.computer.providerRef) {
await deps.prisma.computer.updateMany({ 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"); return docs.map((d) => `# ${d.path}\n\n${d.content}`).join("\n\n");
}), }),
providerConfig: authed.memory.providerConfig.handler(async ({ context }) => providerConfig: authed.memory.providerConfig.handler(async ({ context }) => {
getMemoryProviderConfig(deps, context.actor), const config = await findSpaceMemoryConfig(deps.prisma, context.actor.spaceId);
), return config ? serializeSpaceMemoryConfig(config) : null;
}),
connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) => connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) =>
persistMemoryProviderConfig(deps, context.actor, input), persistMemoryProviderConfig(deps, context.actor, input),
), ),
@ -2547,7 +2499,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

@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { ComposioEmulator } from "@rakazo/adapters"; 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(); 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<Space>(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<Actor>(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<Bot>(
app,
owner,
"bots/create",
botInput("Owner Team Bot"),
shared.id,
);
const ownerBotTwo = await rpc<Bot>(
app,
owner,
"bots/create",
botInput("Owner Team Bot Two"),
shared.id,
);
const memberBot = await rpc<Bot>(
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<Array<{ path: string }>>(
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 () => { 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 owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member"); const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
@ -608,39 +509,21 @@ describeWithDatabase("API authorization and resource isolation", () => {
).not.toBeNull(); ).not.toBeNull();
}); });
it("keeps memory provider configuration readable only by Space owners", async () => { it("keeps routines private to each user in a shared Space", async () => {
const owner = await signup(app, `memory-owner-${stamp}@rakazo.test`, "Memory Owner"); const owner = await signup(app, `routine-owner-${stamp}@rakazo.test`, "Routine Owner");
const ownerActor = await rpc<Actor>(app, owner, "me"); const member = await signup(app, `routine-member-${stamp}@rakazo.test`, "Routine Member");
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Memory shared" }); 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({ const sharedRow = await handles.prisma.space.findUniqueOrThrow({
where: { id: shared.id }, where: { id: shared.id },
select: { organizationId: true }, 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<Actor>(app, member, "me");
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
await handles.prisma.member.create({ await handles.prisma.member.create({
data: { data: {
id: `memory-org-member-${stamp}`, id: `routine-member-org-${stamp}`,
organizationId: sharedRow.organizationId, organizationId: sharedRow.organizationId,
userId: memberActor.userId, userId: memberActor.userId,
role: "member", role: "member",
@ -649,7 +532,7 @@ describeWithDatabase("API authorization and resource isolation", () => {
}); });
await handles.prisma.spaceMember.create({ await handles.prisma.spaceMember.create({
data: { data: {
id: `memory-space-member-${stamp}`, id: `routine-member-space-${stamp}`,
spaceId: shared.id, spaceId: shared.id,
organizationId: sharedRow.organizationId, organizationId: sharedRow.organizationId,
userId: memberActor.userId, userId: memberActor.userId,
@ -658,13 +541,31 @@ describeWithDatabase("API authorization and resource isolation", () => {
}, },
}); });
expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toMatchObject({ const ownerBot = await rpc<Bot>(
provider: "supermemory", app,
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" }, owner,
}); "bots/create",
await expect( botInput("Owner shared bot"),
raw(app, member, "memory/providerConfig", {}, shared.id), shared.id,
).resolves.toMatchObject({ status: 403 }); );
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 () => { 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); expect(response.status, procedure).toBeGreaterThanOrEqual(400);
} }
async function expectForbidden( async function expectForbidden(app: App, cookie: string, procedure: string, body: unknown) {
app: App, const response = await raw(app, cookie, procedure, body);
cookie: string,
procedure: string,
body: unknown,
spaceId?: string,
) {
const response = await raw(app, cookie, procedure, body, spaceId);
expect(response.status, procedure).toBe(403); expect(response.status, procedure).toBe(403);
expect(await response.text(), procedure).toMatch(/forbidden/i); expect(await response.text(), procedure).toMatch(/forbidden/i);
} }