fix(api): gate memory providerConfig reads to Space owners

- Replace findSpaceMemoryConfig inline call with getMemoryProviderConfig(deps, actor)
- Add owner gate: space members get 403, owners get config
- Add authorization tests for providerConfig/connectProvider/setDefaultScope/disconnectProvider
- Add wiring test to ensure router.ts uses the owner-gated handler
This commit is contained in:
Ingo Wagner 2026-09-22 07:10:07 +00:00
parent 101c908cdc
commit 2f8368a9ce
4 changed files with 190 additions and 6 deletions

View File

@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("memory provider config router wiring", () => {
it("routes providerConfig through owner-gated getMemoryProviderConfig", () => {
const routerSource = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), "router.ts"),
"utf8",
);
expect(routerSource).toContain("getMemoryProviderConfig(deps, context.actor)");
expect(routerSource).not.toMatch(/providerConfig:[\s\S]{0,240}findSpaceMemoryConfig/);
});
});

View File

@ -804,6 +804,99 @@ describe("integration setup authorization", () => {
});
});
describe("memory provider config owner gate", () => {
function memoryProviderDeps(spaceOwner: boolean) {
const spaceMemoryConfigFindUnique = vi.fn().mockResolvedValue({
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
defaultMemoryScope: "isolated",
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
});
const spaceMemberFindUnique = vi.fn().mockResolvedValue({
role: spaceOwner ? "owner" : "member",
});
const prisma = {
spaceMember: { findUnique: spaceMemberFindUnique },
spaceMemoryConfig: { findUnique: spaceMemoryConfigFindUnique },
user: {
findUniqueOrThrow: vi.fn().mockResolvedValue({
email: "user@rakazo.test",
name: "Test User",
avatarStyle: "robot",
}),
},
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaClient;
const deps = {
prisma,
env: {
defaultProvider: "fake",
defaultModel: "fake-model",
webOrigin: "http://127.0.0.1:5173",
screenProxySecret: "fake-test-secret",
sandboxProvider: "fake",
},
dataDir: "/tmp/rakazo-router-test",
} as unknown as RouterDeps;
const actor = {
spaceId: "workspace-1",
userId: "user-1",
email: spaceOwner ? "owner@rakazo.test" : "member@rakazo.test",
isDeploymentOwner: false,
} satisfies Actor;
return {
deps,
actor,
handler: new RPCHandler(createRouter(deps)),
spaceMemberFindUnique,
spaceMemoryConfigFindUnique,
};
}
it("forbids non-owners from reading provider configuration", async () => {
const { handler, actor, spaceMemberFindUnique, spaceMemoryConfigFindUnique } =
memoryProviderDeps(false);
const { response } = await handler.handle(
new Request("http://127.0.0.1/rpc/memory/providerConfig", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: null }),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(response.status).toBe(403);
expect(spaceMemberFindUnique).toHaveBeenCalled();
expect(spaceMemoryConfigFindUnique).not.toHaveBeenCalled();
});
it("returns provider configuration for Space owners", async () => {
const { handler, actor, spaceMemoryConfigFindUnique } = memoryProviderDeps(true);
const { response } = await handler.handle(
new Request("http://127.0.0.1/rpc/memory/providerConfig", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: null }),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(response.status).toBe(200);
expect(spaceMemoryConfigFindUnique).toHaveBeenCalled();
await expect(response.json()).resolves.toEqual({
json: {
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
defaultMemoryScope: "isolated",
updatedAt: "2026-08-19T00:00:00.000Z",
},
});
});
});
describe("interrupted computer reservation release", () => {
function fixture() {
const order: string[] = [];

View File

@ -114,7 +114,6 @@ import {
findDefaultModelCredential,
findDefaultVoiceCredential,
findModelCredential,
findSpaceMemoryConfig,
formatMessagingLinkCode,
InvalidSpaceNameError,
IsolationError,
@ -151,8 +150,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 {
@ -2323,10 +2322,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

@ -126,6 +126,10 @@ describeWithDatabase("API authorization and resource isolation", () => {
["memory/list", {}],
["memory/update", { documentId: "missing-memory", content: "Nope" }],
["memory/exportMarkdown", {}],
["memory/providerConfig"],
["memory/connectProvider", { provider: "test", settings: {}, credentials: {}, defaultMemoryScope: "isolated" }],
["memory/setDefaultScope", { defaultMemoryScope: "shared" }],
["memory/disconnectProvider"],
["routines/list", { botId: "missing-bot" }],
["routines/create", routineInput("missing-bot")],
["routines/update", { routineId: "missing-routine", name: "Nope" }],
@ -705,6 +709,80 @@ describeWithDatabase("API authorization and resource isolation", () => {
await expectDenied(app, intruder, "bots/list", {}, support.id);
});
it("restricts memory provider configuration to Space owners", async () => {
const owner = await signup(app, `memory-provider-owner-${stamp}@rakazo.test`, "Memory Owner");
const member = await signup(
app,
`memory-provider-member-${stamp}@rakazo.test`,
"Memory Member",
);
const memberActor = await rpc<Actor>(app, member, "me");
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared memory config" });
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
where: { id: shared.id },
select: { organizationId: true },
});
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
await handles.prisma.member.create({
data: {
id: `memory-provider-member-org-${stamp}`,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
await handles.prisma.spaceMember.create({
data: {
id: `memory-provider-member-space-${stamp}`,
spaceId: shared.id,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
const ownerActor = await rpc<Actor>(app, owner, "me", {}, shared.id);
const secret = await handles.prisma.secret.create({
data: {
userId: ownerActor.userId,
spaceId: shared.id,
kind: "memory-provider",
ciphertext: "encrypted-memory-provider-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",
},
});
await expectForbidden(app, member, "memory/providerConfig", {}, shared.id);
await expectForbidden(app, member, "memory/connectProvider", {
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
credentials: { apiKey: "not-a-real-key" },
defaultMemoryScope: "isolated",
}, shared.id);
await expectForbidden(app, member, "memory/setDefaultScope", { defaultMemoryScope: "shared" }, shared.id);
await expectForbidden(app, member, "memory/disconnectProvider", {}, shared.id);
expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toEqual({
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
defaultMemoryScope: "isolated",
updatedAt: expect.any(String),
});
});
it("enforces the space limit across concurrent creation requests", async () => {
const cookie = await signup(app, `space-limit-${stamp}@rakazo.test`, "Space Limit");
const actor = await rpc<Actor>(app, cookie, "me");