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:
parent
101c908cdc
commit
2f8368a9ce
15
apps/api/src/memory-provider-router-wiring.test.ts
Normal file
15
apps/api/src/memory-provider-router-wiring.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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", () => {
|
describe("interrupted computer reservation release", () => {
|
||||||
function fixture() {
|
function fixture() {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
|
|||||||
@ -114,7 +114,6 @@ import {
|
|||||||
findDefaultModelCredential,
|
findDefaultModelCredential,
|
||||||
findDefaultVoiceCredential,
|
findDefaultVoiceCredential,
|
||||||
findModelCredential,
|
findModelCredential,
|
||||||
findSpaceMemoryConfig,
|
|
||||||
formatMessagingLinkCode,
|
formatMessagingLinkCode,
|
||||||
InvalidSpaceNameError,
|
InvalidSpaceNameError,
|
||||||
IsolationError,
|
IsolationError,
|
||||||
@ -151,8 +150,8 @@ import { searchIntegrationCatalog } from "./integration-catalog.js";
|
|||||||
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
||||||
import {
|
import {
|
||||||
disconnectMemoryProvider,
|
disconnectMemoryProvider,
|
||||||
|
getMemoryProviderConfig,
|
||||||
persistMemoryProviderConfig,
|
persistMemoryProviderConfig,
|
||||||
serializeSpaceMemoryConfig,
|
|
||||||
updateMemoryProviderDefaultScope,
|
updateMemoryProviderDefaultScope,
|
||||||
} from "./memory-provider-config.js";
|
} from "./memory-provider-config.js";
|
||||||
import {
|
import {
|
||||||
@ -2323,10 +2322,9 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
});
|
});
|
||||||
return docs.map((d) => `# ${d.path}\n\n${d.content}`).join("\n\n");
|
return docs.map((d) => `# ${d.path}\n\n${d.content}`).join("\n\n");
|
||||||
}),
|
}),
|
||||||
providerConfig: authed.memory.providerConfig.handler(async ({ context }) => {
|
providerConfig: authed.memory.providerConfig.handler(async ({ context }) =>
|
||||||
const config = await findSpaceMemoryConfig(deps.prisma, context.actor.spaceId);
|
getMemoryProviderConfig(deps, context.actor),
|
||||||
return config ? serializeSpaceMemoryConfig(config) : null;
|
),
|
||||||
}),
|
|
||||||
connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) =>
|
connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) =>
|
||||||
persistMemoryProviderConfig(deps, context.actor, input),
|
persistMemoryProviderConfig(deps, context.actor, input),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -126,6 +126,10 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
["memory/list", {}],
|
["memory/list", {}],
|
||||||
["memory/update", { documentId: "missing-memory", content: "Nope" }],
|
["memory/update", { documentId: "missing-memory", content: "Nope" }],
|
||||||
["memory/exportMarkdown", {}],
|
["memory/exportMarkdown", {}],
|
||||||
|
["memory/providerConfig"],
|
||||||
|
["memory/connectProvider", { provider: "test", settings: {}, credentials: {}, defaultMemoryScope: "isolated" }],
|
||||||
|
["memory/setDefaultScope", { defaultMemoryScope: "shared" }],
|
||||||
|
["memory/disconnectProvider"],
|
||||||
["routines/list", { botId: "missing-bot" }],
|
["routines/list", { botId: "missing-bot" }],
|
||||||
["routines/create", routineInput("missing-bot")],
|
["routines/create", routineInput("missing-bot")],
|
||||||
["routines/update", { routineId: "missing-routine", name: "Nope" }],
|
["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);
|
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 () => {
|
it("enforces the space limit across concurrent creation requests", async () => {
|
||||||
const cookie = await signup(app, `space-limit-${stamp}@rakazo.test`, "Space Limit");
|
const cookie = await signup(app, `space-limit-${stamp}@rakazo.test`, "Space Limit");
|
||||||
const actor = await rpc<Actor>(app, cookie, "me");
|
const actor = await rpc<Actor>(app, cookie, "me");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user