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:
parent
75b33c4be6
commit
016fd0c628
@ -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;
|
||||
})
|
||||
|
||||
@ -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" },
|
||||
}),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user