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,
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<ReturnType<SandboxProvider["listFiles"]>>;
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 } });

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 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<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 () => {
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<Actor>(app, owner, "me");
const shared = await rpc<Space>(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<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 },
});
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.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<Bot>(
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);
}