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:
parent
2f8368a9ce
commit
44d1bc7277
@ -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";
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user