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", () => {
|
||||
function fixture() {
|
||||
const order: string[] = [];
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user