fix(api): redact memory provider details in aiConsent/status for members

Non-owner shared-space members could read the space memory provider id
and internal endpoint origin via aiConsent/status, bypassing the owner-only
policy that draft PR #46 applies to memory/providerConfig.

Owners still see full provider disclosure; members get a generic Space
memory recipient when a provider is configured, without loading settings.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-22 05:36:29 +00:00 committed by Ingo Wagner
parent 2f8368a9ce
commit 44d1bc7277
3 changed files with 134 additions and 15 deletions

View File

@ -21,7 +21,11 @@ function setup() {
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
},
spaceMemoryConfig: { findUnique: vi.fn(async () => null) },
spaceMemoryConfig: {
findUnique: vi.fn(async () => null),
count: vi.fn(async () => 0),
},
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
deploymentSettings: { findUnique: vi.fn(async () => null) },
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
$transaction: vi.fn(async (queries) => Promise.all(queries)),
@ -65,6 +69,29 @@ describe("consent grants", () => {
expect(status.recipients[0]?.name).toBe("OpenAI");
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
});
it("discloses memory provider details to space owners", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.spaceMemoryConfig.findUnique).mockResolvedValue({
provider: "supermemory",
settings: { baseUrl: "http://127.0.0.1:8123" },
} as never);
const status = await aiConsentStatus(deps, actor, { uses: ["memory"] });
expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toContain("Supermemory");
expect(status.recipients[0]?.name).toContain("127.0.0.1:8123");
expect(deps.prisma.spaceMemoryConfig.count).not.toHaveBeenCalled();
});
it("redacts space memory provider details for non-owner members", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.spaceMember.findUnique).mockResolvedValue({ role: "member" } as never);
vi.mocked(deps.prisma.spaceMemoryConfig.count).mockResolvedValue(1);
const status = await aiConsentStatus(deps, actor, { uses: ["memory"] });
expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toBe("Space memory");
expect(JSON.stringify(status.recipients)).not.toContain("127.0.0.1");
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
});
it("returns an operator policy without requiring a hosted provider", async () => {
const { deps, actor } = setup();
deps.env.privacyPolicyUrl = "https://example.com/privacy";

View File

@ -8,15 +8,25 @@ import {
toStringRecord,
} from "@rakazo/adapters";
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
import { AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
import {
findDefaultModelCredential,
findDefaultVoiceCredential,
findModelCredential,
} from "@rakazo/db";
import { isSpaceOwner } from "./memory-provider-config.js";
import type { RouterDeps } from "./router.js";
import { resolveThreadTarget } from "./thread-target.js";
function genericSpaceMemoryRecipient(): Omit<AiRecipient, "allowed"> {
return {
key: createHash("sha256").update(JSON.stringify(["memory", "space-memory"])).digest("hex"),
name: "Space memory",
use: "memory",
detail: AI_DATA_DISCLOSURES.memory,
};
}
export async function aiConsentStatus(
deps: RouterDeps,
actor: Actor,
@ -31,7 +41,8 @@ export async function aiConsentStatus(
? [target.botId]
: target.memberBotIds
: undefined;
const [preferences, defaultCredential, bots, voices, memory, settings, consents] =
const includesMemory = uses.includes("memory");
const [preferences, defaultCredential, bots, voices, memoryOwner, settings, consents] =
await Promise.all([
modelsEnabled
? deps.prisma.spaceModelPreference.findMany({
@ -61,9 +72,7 @@ export async function aiConsentStatus(
include: { credential: true },
})
: [],
uses.includes("memory")
? deps.prisma.spaceMemoryConfig.findUnique({ where: { spaceId: actor.spaceId } })
: null,
includesMemory ? isSpaceOwner(deps.prisma, actor) : false,
modelsEnabled
? deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } })
: null,
@ -161,14 +170,25 @@ export async function aiConsentStatus(
}
for (const voice of voices)
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
if (memory)
add(
aiRecipient({
provider: memory.provider,
use: "memory",
baseUrl: toStringRecord(memory.settings).baseUrl,
}),
);
if (includesMemory) {
if (memoryOwner) {
const memory = await deps.prisma.spaceMemoryConfig.findUnique({
where: { spaceId: actor.spaceId },
});
if (memory)
add(
aiRecipient({
provider: memory.provider,
use: "memory",
baseUrl: toStringRecord(memory.settings).baseUrl,
}),
);
} else if (
(await deps.prisma.spaceMemoryConfig.count({ where: { spaceId: actor.spaceId } })) > 0
) {
add(genericSpaceMemoryRecipient());
}
}
if (
uses.includes("model") &&
deps.cloudAgent &&

View File

@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { ComposioEmulator } from "@rakazo/adapters";
import type { appContract, Space, SpaceNavigation } from "@rakazo/contracts";
import type { AiConsentStatus, appContract, Space, SpaceNavigation } from "@rakazo/contracts";
import {
claimEmptySpaceDeletionForMember,
deleteEmptySpaceForMember,
@ -585,6 +585,78 @@ describeWithDatabase("API authorization and resource isolation", () => {
).not.toBeNull();
});
it("redacts memory provider details in aiConsent/status for non-owner members", async () => {
const owner = await signup(app, `ai-consent-owner-${stamp}@rakazo.test`, "Consent Owner");
const ownerActor = await rpc<Actor>(app, owner, "me");
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Consent 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, `ai-consent-member-${stamp}@rakazo.test`, "Consent 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: `ai-consent-org-member-${stamp}`,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
await handles.prisma.spaceMember.create({
data: {
id: `ai-consent-space-member-${stamp}`,
spaceId: shared.id,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
const ownerStatus = await rpc<AiConsentStatus>(
app,
owner,
"aiConsent/status",
{ uses: ["memory"] },
shared.id,
);
const memberStatus = await rpc<AiConsentStatus>(
app,
member,
"aiConsent/status",
{ uses: ["memory"] },
shared.id,
);
const ownerMemory = ownerStatus.recipients.find((recipient) => recipient.use === "memory");
const memberMemory = memberStatus.recipients.find((recipient) => recipient.use === "memory");
expect(ownerMemory?.name).toContain("127.0.0.1:8123");
expect(memberMemory?.name).toBe("Space memory");
expect(JSON.stringify(memberStatus.recipients)).not.toContain("127.0.0.1");
expect(JSON.stringify(memberStatus.recipients)).not.toMatch(/supermemory/i);
});
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");