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([
|
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;
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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" },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user