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:
parent
be9c310e1e
commit
c8b06ec7a8
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user