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 <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-22 05:00:41 +00:00 committed by Ingo Wagner
parent 75b33c4be6
commit 016fd0c628
5 changed files with 103 additions and 18 deletions

View File

@ -1487,7 +1487,12 @@ export function createRouter(deps: RouterDeps) {
}); });
const [configuredMemory] = await Promise.all([ const [configuredMemory] = await Promise.all([
target.kind === "bot" 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); getLogger().error("semantic memory resolution after thread clear failed", error);
return null; return null;
}) })

View File

@ -264,7 +264,10 @@ import {
parseMcpServerToolArgs, parseMcpServerToolArgs,
} from "./mcp-server-tool.js"; } from "./mcp-server-tool.js";
import { loadAgentMemoryContext } from "./memory-context.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 { selectMemoryTools } from "./memory-tools.js";
import { import {
isCatalogModelChoice, isCatalogModelChoice,
@ -1013,7 +1016,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
}), }),
findDefaultModelCredential(deps.prisma, run), findDefaultModelCredential(deps.prisma, run),
deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }), deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }),
deps.memoryProviders.resolve(run.spaceId), deps.memoryProviders.resolve(memoryProviderResolveRequest(run.spaceId, run.userId)),
deps.prisma.taughtSkill.findMany({ deps.prisma.taughtSkill.findMany({
where: { botId: run.botId, spaceId: run.spaceId, status: "saved" }, where: { botId: run.botId, spaceId: run.spaceId, status: "saved" },
}), }),

View File

@ -6,9 +6,10 @@ import type { PrismaClient } from "@rakazo/db";
import { getLogger } from "@rakazo/logging"; import { getLogger } from "@rakazo/logging";
import { formatCurrentTimeInstruction } from "./current-time.js"; import { formatCurrentTimeInstruction } from "./current-time.js";
import { resolveDeploymentModel } from "./deployment-model.js"; import { resolveDeploymentModel } from "./deployment-model.js";
import type { import {
ConfiguredMemoryProvider, memoryProviderResolveRequest,
MemoryProviderResolver, type ConfiguredMemoryProvider,
type MemoryProviderResolver,
} from "./memory-provider-factory.js"; } 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. // Saving only after the compare-and-set also prevents losing workers from creating duplicates.
let semanticMemory: ConfiguredMemoryProvider | null = null; let semanticMemory: ConfiguredMemoryProvider | null = null;
try { try {
semanticMemory = await deps.memoryProviders.resolve(thread.spaceId); semanticMemory = await deps.memoryProviders.resolve(
memoryProviderResolveRequest(thread.spaceId, thread.userId),
);
} catch (error) { } catch (error) {
getLogger().error("Failed to load semantic memory provider for history compaction", error); getLogger().error("Failed to load semantic memory provider for history compaction", error);
} }

View File

@ -1,9 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest"; 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( function resolverFor(
plaintext: string, 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 = { const prisma = {
spaceMemoryConfig: { spaceMemoryConfig: {
@ -21,6 +30,11 @@ function resolverFor(
deploymentSettings: { deploymentSettings: {
findUnique: vi.fn(async () => ({ ownerUserId: options.ownerUserId ?? null })), 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) }; const secrets = { load: vi.fn(() => plaintext) };
return { return {
@ -44,7 +58,9 @@ describe("SpaceMemoryProviderResolver", () => {
ownerUserId, 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(secrets.load).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled();
}, },
@ -57,8 +73,11 @@ describe("SpaceMemoryProviderResolver", () => {
mode: "local", mode: "local",
baseUrl: "http://localhost:6767/memory/", baseUrl: "http://localhost:6767/memory/",
ownerUserId: "config-author", 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( await configured!.provider.recall(
{ query: "project", scope: "isolated", botId: "bot-1", limit: 1 }, { 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"); 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 () => { it("rejects ambiguous persisted URLs even when the deployment owner configured them", async () => {
const fetchMock = vi.fn(); const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
@ -80,14 +113,18 @@ describe("SpaceMemoryProviderResolver", () => {
baseUrl: "http://127.0.0.1:8123/internal-action#", baseUrl: "http://127.0.0.1:8123/internal-action#",
ownerUserId: "config-author", 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(); 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 { 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?.provider.describe().id).toBe("supermemory");
expect(configured?.defaultScope).toBe("shared"); expect(configured?.defaultScope).toBe("shared");
@ -97,7 +134,9 @@ describe("SpaceMemoryProviderResolver", () => {
it("keeps legacy raw Supermemory credentials usable after the schema migration", async () => { it("keeps legacy raw Supermemory credentials usable after the schema migration", async () => {
const { resolver } = resolverFor("sm_legacy_key"); 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"); expect(configured?.provider.describe().id).toBe("supermemory");
}); });

View File

@ -38,8 +38,33 @@ export interface ConfiguredMemoryProvider {
defaultScope: DurableMemoryScope; defaultScope: DurableMemoryScope;
} }
export interface MemoryProviderResolveRequest {
spaceId: string;
userId: string;
}
export function memoryProviderResolveRequest(
spaceId: string,
userId: string,
): MemoryProviderResolveRequest {
return { spaceId, userId };
}
export interface MemoryProviderResolver { export interface MemoryProviderResolver {
resolve(spaceId: string): Promise<ConfiguredMemoryProvider | null>; resolve(request: MemoryProviderResolveRequest): Promise<ConfiguredMemoryProvider | null>;
}
async function isSpaceOwner(
prisma: Pick<PrismaClient, "spaceMember">,
spaceId: string,
userId: string,
): Promise<boolean> {
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 { interface MemoryProviderAdapter {
@ -151,11 +176,17 @@ function decodeCredentials(provider: string, plaintext: string): Record<string,
export class SpaceMemoryProviderResolver implements MemoryProviderResolver { export class SpaceMemoryProviderResolver implements MemoryProviderResolver {
constructor( constructor(
private readonly prisma: Pick<PrismaClient, "spaceMemoryConfig" | "deploymentSettings">, private readonly prisma: Pick<
PrismaClient,
"spaceMemoryConfig" | "deploymentSettings" | "spaceMember"
>,
private readonly secrets: EncryptedSecretStore, private readonly secrets: EncryptedSecretStore,
) {} ) {}
async resolve(spaceId: string): Promise<ConfiguredMemoryProvider | null> { async resolve({
spaceId,
userId,
}: MemoryProviderResolveRequest): Promise<ConfiguredMemoryProvider | null> {
const config = await this.prisma.spaceMemoryConfig.findUnique({ const config = await this.prisma.spaceMemoryConfig.findUnique({
where: { spaceId }, where: { spaceId },
include: { secret: true }, include: { secret: true },
@ -169,6 +200,10 @@ export class SpaceMemoryProviderResolver implements MemoryProviderResolver {
}); });
// Also disable pre-existing local configurations authored outside the deployment boundary. // Also disable pre-existing local configurations authored outside the deployment boundary.
if (!deployment?.ownerUserId || deployment.ownerUserId !== config.userId) return null; 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( const credentials = decodeCredentials(
config.provider, config.provider,