fix(api): gate memory/providerConfig read behind Space owner authz

Non-owner Space members could read provider settings (mode, baseUrl,
endpoint metadata) while connect/disconnect/setDefaultScope already
required owner role. Align the read path with write authz.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-22 04:03:42 +00:00 committed by Ingo Wagner
parent be9c310e1e
commit c8b06ec7a8
4 changed files with 103 additions and 6 deletions

View File

@ -134,6 +134,15 @@ export async function persistMemoryProviderConfig(
return serializeSpaceMemoryConfig(config);
}
export async function getMemoryProviderConfig(
deps: Pick<MemoryProviderConfigDeps, "prisma">,
actor: Actor,
) {
await requireSpaceOwner(deps.prisma, actor);
const config = await findSpaceMemoryConfig(deps.prisma, actor.spaceId);
return config ? serializeSpaceMemoryConfig(config) : null;
}
export async function updateMemoryProviderDefaultScope(
deps: MemoryProviderConfigDeps,
actor: Actor,

View File

@ -3,6 +3,7 @@ import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import {
disconnectMemoryProvider,
getMemoryProviderConfig,
persistMemoryProviderConfig,
updateMemoryProviderDefaultScope,
} from "./memory-provider-config.js";
@ -366,6 +367,36 @@ describe("disconnectMemoryProvider", () => {
});
});
describe("getMemoryProviderConfig", () => {
it("rejects non-owners before reading provider configuration", async () => {
const { deps, findUnique } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-existing" },
spaceOwner: false,
});
await expect(getMemoryProviderConfig(deps, actor)).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(findUnique).not.toHaveBeenCalled();
});
it("returns serialized config for Space owners", async () => {
const config = {
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
defaultMemoryScope: "isolated",
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
};
const { deps } = makeDeps({ existing: { id: "cfg-1", secretId: "secret-existing" } });
deps.prisma.spaceMemoryConfig.findUnique = vi.fn().mockResolvedValue(config);
await expect(getMemoryProviderConfig(deps, actor)).resolves.toEqual({
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
defaultMemoryScope: "isolated",
updatedAt: "2026-08-19T00:00:00.000Z",
});
});
});
describe("updateMemoryProviderDefaultScope", () => {
it("accepts owners with additional Better Auth roles", async () => {
const { deps, update } = makeDeps({

View File

@ -117,7 +117,6 @@ import {
findDefaultModelCredential,
findDefaultVoiceCredential,
findModelCredential,
findSpaceMemoryConfig,
formatMessagingLinkCode,
InvalidSpaceNameError,
IsolationError,
@ -155,8 +154,8 @@ import { searchIntegrationCatalog } from "./integration-catalog.js";
import { buildMcpUpdateMaterial } from "./mcp-material.js";
import {
disconnectMemoryProvider,
getMemoryProviderConfig,
persistMemoryProviderConfig,
serializeSpaceMemoryConfig,
updateMemoryProviderDefaultScope,
} from "./memory-provider-config.js";
import {
@ -2366,10 +2365,9 @@ export function createRouter(deps: RouterDeps) {
});
return docs.map((d) => `# ${d.path}\n\n${d.content}`).join("\n\n");
}),
providerConfig: authed.memory.providerConfig.handler(async ({ context }) => {
const config = await findSpaceMemoryConfig(deps.prisma, context.actor.spaceId);
return config ? serializeSpaceMemoryConfig(config) : null;
}),
providerConfig: authed.memory.providerConfig.handler(async ({ context }) =>
getMemoryProviderConfig(deps, context.actor),
),
connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) =>
persistMemoryProviderConfig(deps, context.actor, input),
),

View File

@ -608,6 +608,65 @@ describeWithDatabase("API authorization and resource isolation", () => {
).not.toBeNull();
});
it("keeps memory provider configuration readable only by Space owners", async () => {
const owner = await signup(app, `memory-owner-${stamp}@rakazo.test`, "Memory Owner");
const ownerActor = await rpc<Actor>(app, owner, "me");
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Memory shared" });
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
where: { id: shared.id },
select: { organizationId: true },
});
const secret = await handles.prisma.secret.create({
data: {
userId: ownerActor.userId,
spaceId: shared.id,
kind: "memory-provider",
ciphertext: "encrypted-test-key",
},
});
await handles.prisma.spaceMemoryConfig.create({
data: {
spaceId: shared.id,
userId: ownerActor.userId,
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
secretId: secret.id,
defaultMemoryScope: "isolated",
},
});
const member = await signup(app, `memory-member-${stamp}@rakazo.test`, "Memory Member");
const memberActor = await rpc<Actor>(app, member, "me");
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
await handles.prisma.member.create({
data: {
id: `memory-org-member-${stamp}`,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
await handles.prisma.spaceMember.create({
data: {
id: `memory-space-member-${stamp}`,
spaceId: shared.id,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toMatchObject({
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
});
await expect(
raw(app, member, "memory/providerConfig", {}, shared.id),
).resolves.toMatchObject({ status: 403 });
});
it("keeps space data and computers behind the selected space boundary", async () => {
const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner");
const original = await rpc<Actor>(app, cookie, "me");