fix(api): scope routines/remove to owner in shared Spaces
This commit is contained in:
parent
e8e0b20828
commit
9c87398f53
@ -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({
|
||||
const { 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 [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 } });
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user