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([
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;
})

View File

@ -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" },
}),

View File

@ -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);
}

View File

@ -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");
});

View File

@ -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<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 {
@ -151,11 +176,17 @@ function decodeCredentials(provider: string, plaintext: string): Record<string,
export class SpaceMemoryProviderResolver implements MemoryProviderResolver {
constructor(
private readonly prisma: Pick<PrismaClient, "spaceMemoryConfig" | "deploymentSettings">,
private readonly prisma: Pick<
PrismaClient,
"spaceMemoryConfig" | "deploymentSettings" | "spaceMember"
>,
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({
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,