From 016fd0c628474eba16bb909a6f7d8cdfb1f7a1ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 05:00:41 +0000 Subject: [PATCH] fix(adapters): gate memory provider runtime resolve to Space owners Cloud/public memory providers are owner-managed infrastructure. The RPC read path is owner-gated separately; this aligns executor, history compaction, and thread-clear purge with the same boundary so non-owner members cannot load credentials or reach external endpoints at runtime. Deployment-owner local/private providers remain available to all Space members when the deployment owner configured them. Co-authored-by: cynarAI --- apps/api/src/router.ts | 7 ++- packages/adapters/src/executor.ts | 7 ++- packages/adapters/src/history-compaction.ts | 11 ++-- .../src/memory-provider-factory.test.ts | 55 ++++++++++++++++--- .../adapters/src/memory-provider-factory.ts | 41 +++++++++++++- 5 files changed, 103 insertions(+), 18 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 56ab2e7..2438c12 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -1487,7 +1487,12 @@ export function createRouter(deps: RouterDeps) { }); const [configuredMemory] = await Promise.all([ target.kind === "bot" - ? deps.memoryProviders.resolve(context.actor.spaceId).catch((error) => { + ? deps.memoryProviders + .resolve({ + spaceId: context.actor.spaceId, + userId: context.actor.userId, + }) + .catch((error) => { getLogger().error("semantic memory resolution after thread clear failed", error); return null; }) diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index d9405c8..a41fd88 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -264,7 +264,10 @@ import { parseMcpServerToolArgs, } from "./mcp-server-tool.js"; import { loadAgentMemoryContext } from "./memory-context.js"; -import type { MemoryProviderResolver } from "./memory-provider-factory.js"; +import { + memoryProviderResolveRequest, + type MemoryProviderResolver, +} from "./memory-provider-factory.js"; import { selectMemoryTools } from "./memory-tools.js"; import { isCatalogModelChoice, @@ -1013,7 +1016,7 @@ export function createRunExecutor(deps: ExecutorDeps) { }), findDefaultModelCredential(deps.prisma, run), deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }), - deps.memoryProviders.resolve(run.spaceId), + deps.memoryProviders.resolve(memoryProviderResolveRequest(run.spaceId, run.userId)), deps.prisma.taughtSkill.findMany({ where: { botId: run.botId, spaceId: run.spaceId, status: "saved" }, }), diff --git a/packages/adapters/src/history-compaction.ts b/packages/adapters/src/history-compaction.ts index 100f293..ad22090 100644 --- a/packages/adapters/src/history-compaction.ts +++ b/packages/adapters/src/history-compaction.ts @@ -6,9 +6,10 @@ import type { PrismaClient } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { formatCurrentTimeInstruction } from "./current-time.js"; import { resolveDeploymentModel } from "./deployment-model.js"; -import type { - ConfiguredMemoryProvider, - MemoryProviderResolver, +import { + memoryProviderResolveRequest, + type ConfiguredMemoryProvider, + type MemoryProviderResolver, } from "./memory-provider-factory.js"; /** @@ -352,7 +353,9 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string) // Saving only after the compare-and-set also prevents losing workers from creating duplicates. let semanticMemory: ConfiguredMemoryProvider | null = null; try { - semanticMemory = await deps.memoryProviders.resolve(thread.spaceId); + semanticMemory = await deps.memoryProviders.resolve( + memoryProviderResolveRequest(thread.spaceId, thread.userId), + ); } catch (error) { getLogger().error("Failed to load semantic memory provider for history compaction", error); } diff --git a/packages/adapters/src/memory-provider-factory.test.ts b/packages/adapters/src/memory-provider-factory.test.ts index ea07eba..c92caeb 100644 --- a/packages/adapters/src/memory-provider-factory.test.ts +++ b/packages/adapters/src/memory-provider-factory.test.ts @@ -1,9 +1,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createMemoryProvider, SpaceMemoryProviderResolver } from "./memory-provider-factory.js"; +import { + createMemoryProvider, + memoryProviderResolveRequest, + SpaceMemoryProviderResolver, +} from "./memory-provider-factory.js"; function resolverFor( plaintext: string, - options: { mode?: "cloud" | "local"; ownerUserId?: string | null; baseUrl?: string } = {}, + options: { + mode?: "cloud" | "local"; + ownerUserId?: string | null; + baseUrl?: string; + memberRole?: string | null; + } = {}, ) { const prisma = { spaceMemoryConfig: { @@ -21,6 +30,11 @@ function resolverFor( deploymentSettings: { findUnique: vi.fn(async () => ({ ownerUserId: options.ownerUserId ?? null })), }, + spaceMember: { + findUnique: vi.fn(async () => + options.memberRole === null ? null : { role: options.memberRole ?? "owner" }, + ), + }, }; const secrets = { load: vi.fn(() => plaintext) }; return { @@ -44,7 +58,9 @@ describe("SpaceMemoryProviderResolver", () => { ownerUserId, }); - await expect(resolver.resolve("workspace-1")).resolves.toBeNull(); + await expect( + resolver.resolve(memoryProviderResolveRequest("workspace-1", "config-author")), + ).resolves.toBeNull(); expect(secrets.load).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); }, @@ -57,8 +73,11 @@ describe("SpaceMemoryProviderResolver", () => { mode: "local", baseUrl: "http://localhost:6767/memory/", ownerUserId: "config-author", + memberRole: "member", }); - const configured = await resolver.resolve("workspace-1"); + const configured = await resolver.resolve( + memoryProviderResolveRequest("workspace-1", "space-member"), + ); await configured!.provider.recall( { query: "project", scope: "isolated", botId: "bot-1", limit: 1 }, { @@ -72,6 +91,20 @@ describe("SpaceMemoryProviderResolver", () => { expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:6767/memory/v4/search"); }); + it("blocks non-owners from loading cloud provider credentials at runtime", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const { resolver, secrets } = resolverFor(JSON.stringify({ apiKey: "sm_json_key" }), { + memberRole: "member", + }); + + await expect( + resolver.resolve(memoryProviderResolveRequest("workspace-1", "space-member")), + ).resolves.toBeNull(); + expect(secrets.load).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("rejects ambiguous persisted URLs even when the deployment owner configured them", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -80,14 +113,18 @@ describe("SpaceMemoryProviderResolver", () => { baseUrl: "http://127.0.0.1:8123/internal-action#", ownerUserId: "config-author", }); - await expect(resolver.resolve("workspace-1")).rejects.toThrow(/base URL/); + await expect( + resolver.resolve(memoryProviderResolveRequest("workspace-1", "config-author")), + ).rejects.toThrow(/base URL/); expect(fetchMock).not.toHaveBeenCalled(); }); - it("loads generic JSON credential payloads", async () => { + it("loads generic JSON credential payloads for Space owners", async () => { const { resolver, prisma } = resolverFor(JSON.stringify({ apiKey: "sm_json_key" })); - const configured = await resolver.resolve("workspace-1"); + const configured = await resolver.resolve( + memoryProviderResolveRequest("workspace-1", "config-author"), + ); expect(configured?.provider.describe().id).toBe("supermemory"); expect(configured?.defaultScope).toBe("shared"); @@ -97,7 +134,9 @@ describe("SpaceMemoryProviderResolver", () => { it("keeps legacy raw Supermemory credentials usable after the schema migration", async () => { const { resolver } = resolverFor("sm_legacy_key"); - const configured = await resolver.resolve("workspace-1"); + const configured = await resolver.resolve( + memoryProviderResolveRequest("workspace-1", "config-author"), + ); expect(configured?.provider.describe().id).toBe("supermemory"); }); diff --git a/packages/adapters/src/memory-provider-factory.ts b/packages/adapters/src/memory-provider-factory.ts index 15a4d7d..42094f4 100644 --- a/packages/adapters/src/memory-provider-factory.ts +++ b/packages/adapters/src/memory-provider-factory.ts @@ -38,8 +38,33 @@ export interface ConfiguredMemoryProvider { defaultScope: DurableMemoryScope; } +export interface MemoryProviderResolveRequest { + spaceId: string; + userId: string; +} + +export function memoryProviderResolveRequest( + spaceId: string, + userId: string, +): MemoryProviderResolveRequest { + return { spaceId, userId }; +} + export interface MemoryProviderResolver { - resolve(spaceId: string): Promise; + resolve(request: MemoryProviderResolveRequest): Promise; +} + +async function isSpaceOwner( + prisma: Pick, + spaceId: string, + userId: string, +): Promise { + const member = await prisma.spaceMember.findUnique({ + where: { spaceId_userId: { spaceId, userId } }, + select: { role: true }, + }); + const roles = member?.role.split(",").map((role) => role.trim()); + return roles?.includes("owner") ?? false; } interface MemoryProviderAdapter { @@ -151,11 +176,17 @@ function decodeCredentials(provider: string, plaintext: string): Record, + private readonly prisma: Pick< + PrismaClient, + "spaceMemoryConfig" | "deploymentSettings" | "spaceMember" + >, private readonly secrets: EncryptedSecretStore, ) {} - async resolve(spaceId: string): Promise { + async resolve({ + spaceId, + userId, + }: MemoryProviderResolveRequest): Promise { const config = await this.prisma.spaceMemoryConfig.findUnique({ where: { spaceId }, include: { secret: true }, @@ -169,6 +200,10 @@ export class SpaceMemoryProviderResolver implements MemoryProviderResolver { }); // Also disable pre-existing local configurations authored outside the deployment boundary. if (!deployment?.ownerUserId || deployment.ownerUserId !== config.userId) return null; + } else if (!(await isSpaceOwner(this.prisma, spaceId, userId))) { + // Cloud/public providers are owner-managed infrastructure; members must not + // load credentials or hit external endpoints through the runtime path. + return null; } const credentials = decodeCredentials( config.provider,