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" })),
|
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
|
||||||
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
|
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) },
|
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
||||||
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
|
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
|
||||||
$transaction: vi.fn(async (queries) => Promise.all(queries)),
|
$transaction: vi.fn(async (queries) => Promise.all(queries)),
|
||||||
@ -65,6 +69,29 @@ describe("consent grants", () => {
|
|||||||
expect(status.recipients[0]?.name).toBe("OpenAI");
|
expect(status.recipients[0]?.name).toBe("OpenAI");
|
||||||
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
|
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 () => {
|
it("returns an operator policy without requiring a hosted provider", async () => {
|
||||||
const { deps, actor } = setup();
|
const { deps, actor } = setup();
|
||||||
deps.env.privacyPolicyUrl = "https://example.com/privacy";
|
deps.env.privacyPolicyUrl = "https://example.com/privacy";
|
||||||
|
|||||||
@ -8,15 +8,25 @@ import {
|
|||||||
toStringRecord,
|
toStringRecord,
|
||||||
} from "@rakazo/adapters";
|
} from "@rakazo/adapters";
|
||||||
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
|
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 {
|
import {
|
||||||
findDefaultModelCredential,
|
findDefaultModelCredential,
|
||||||
findDefaultVoiceCredential,
|
findDefaultVoiceCredential,
|
||||||
findModelCredential,
|
findModelCredential,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
|
import { isSpaceOwner } from "./memory-provider-config.js";
|
||||||
import type { RouterDeps } from "./router.js";
|
import type { RouterDeps } from "./router.js";
|
||||||
import { resolveThreadTarget } from "./thread-target.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(
|
export async function aiConsentStatus(
|
||||||
deps: RouterDeps,
|
deps: RouterDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@ -31,7 +41,8 @@ export async function aiConsentStatus(
|
|||||||
? [target.botId]
|
? [target.botId]
|
||||||
: target.memberBotIds
|
: target.memberBotIds
|
||||||
: undefined;
|
: 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([
|
await Promise.all([
|
||||||
modelsEnabled
|
modelsEnabled
|
||||||
? deps.prisma.spaceModelPreference.findMany({
|
? deps.prisma.spaceModelPreference.findMany({
|
||||||
@ -61,9 +72,7 @@ export async function aiConsentStatus(
|
|||||||
include: { credential: true },
|
include: { credential: true },
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
uses.includes("memory")
|
includesMemory ? isSpaceOwner(deps.prisma, actor) : false,
|
||||||
? deps.prisma.spaceMemoryConfig.findUnique({ where: { spaceId: actor.spaceId } })
|
|
||||||
: null,
|
|
||||||
modelsEnabled
|
modelsEnabled
|
||||||
? deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } })
|
? deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } })
|
||||||
: null,
|
: null,
|
||||||
@ -161,6 +170,11 @@ export async function aiConsentStatus(
|
|||||||
}
|
}
|
||||||
for (const voice of voices)
|
for (const voice of voices)
|
||||||
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
|
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
|
||||||
|
if (includesMemory) {
|
||||||
|
if (memoryOwner) {
|
||||||
|
const memory = await deps.prisma.spaceMemoryConfig.findUnique({
|
||||||
|
where: { spaceId: actor.spaceId },
|
||||||
|
});
|
||||||
if (memory)
|
if (memory)
|
||||||
add(
|
add(
|
||||||
aiRecipient({
|
aiRecipient({
|
||||||
@ -169,6 +183,12 @@ export async function aiConsentStatus(
|
|||||||
baseUrl: toStringRecord(memory.settings).baseUrl,
|
baseUrl: toStringRecord(memory.settings).baseUrl,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
(await deps.prisma.spaceMemoryConfig.count({ where: { spaceId: actor.spaceId } })) > 0
|
||||||
|
) {
|
||||||
|
add(genericSpaceMemoryRecipient());
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
uses.includes("model") &&
|
uses.includes("model") &&
|
||||||
deps.cloudAgent &&
|
deps.cloudAgent &&
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ComposioEmulator } from "@rakazo/adapters";
|
import { ComposioEmulator } from "@rakazo/adapters";
|
||||||
import type { appContract, Space, SpaceNavigation } from "@rakazo/contracts";
|
import type { AiConsentStatus, appContract, Space, SpaceNavigation } from "@rakazo/contracts";
|
||||||
import {
|
import {
|
||||||
claimEmptySpaceDeletionForMember,
|
claimEmptySpaceDeletionForMember,
|
||||||
deleteEmptySpaceForMember,
|
deleteEmptySpaceForMember,
|
||||||
@ -585,6 +585,78 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
).not.toBeNull();
|
).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 () => {
|
it("keeps space data and computers behind the selected space boundary", async () => {
|
||||||
const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner");
|
const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner");
|
||||||
const original = await rpc<Actor>(app, cookie, "me");
|
const original = await rpc<Actor>(app, cookie, "me");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user