fix(server): support large retained reader sets

Use PostgreSQL array constructors and cover private project grant changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-14 22:08:36 -05:00
1 parent be0d39b8d6
commit 7e5c5448a8
2 files changed
+39 -3

No files matched your search

@@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { and, eq, inArray, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaces, executionWorkspaceRepositoryAccess, heartbeatRuns, issueAccessGrants,
issues, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
issues, projects, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { authorizationService, executionWorkspaceReadSqlCondition, type AuthorizationActor } from "../services/authorization.js";
import { executionWorkspaceRepositoryService, assertExecutionWorkspaceRootReadAccess } from "../services/execution-workspace-repositories.js";
import { assertTaskWorkspaceAccess } from "../services/task-workspace-source-access.js";
@@ -200,6 +200,42 @@ const support = await getEmbeddedPostgresTestSupport();
} finally { vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce"); }
});
it("matches private project decisions as each principal gains and loses access", async () => {
const [project] = await db.insert(projects).values({ companyId, name: "Private batch source", visibility: "private" }).returning();
const resource = { type: "project" as const, companyId, projectId: project.id };
const service = authorizationService(db);
const candidates: AuthorizationActor[] = [actor, { ...actor, onBehalfOfUserId: undefined }, { type: "board", source: "session", userId }];
async function check(expected: boolean[]) {
const batched = await service.decideReadsForActors(candidates, [resource]);
for (let i = 0; i < candidates.length; i++) {
expect(batched[i][0]).toEqual(await service.decide({ actor: { ...candidates[i] }, action: "project:read", resource }));
expect(batched[i][0].allowed).toBe(expected[i]);
}
}
try {
await check([false, false, false]);
await db.insert(projectAccessMembers).values({ companyId, projectId: project.id, subjectType: "agent", subjectId: agentId });
await check([false, true, false]);
const [grant] = await db.insert(projectAccessMembers).values({ companyId, projectId: project.id, subjectType: "user", subjectId: userId }).returning();
await check([true, true, true]);
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, grant.id));
await check([false, true, false]);
} finally { await db.delete(projects).where(eq(projects.id, project.id)); }
});
it("supports more than 100 distinct reader principals for issue and project batches", async () => {
const [project] = await db.insert(projects).values({ companyId, name: "Many readers" }).returning();
const candidates: AuthorizationActor[] = Array.from({ length: 101 }, () => ({ type: "board", source: "session", userId: randomUUID() }));
const service = authorizationService(db);
try {
for (const resource of [{ type: "issue" as const, companyId, issueId: sourceId }, { type: "project" as const, companyId, projectId: project.id }]) {
const decisions = await service.decideReadsForActors(candidates, [resource]);
expect(decisions).toHaveLength(101);
expect(decisions.every(batch => batch.length === 1 && !batch[0].allowed)).toBe(true);
}
} finally { await db.delete(projects).where(eq(projects.id, project.id)); }
});
it("reloads run trust and responsible-user membership between read batches", async () => {
const service = authorizationService(db);
const resources = [{ type: "company" as const, companyId }];
+2 -2
View File
@@ -775,7 +775,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
status: issues.status, executionPolicy: issues.executionPolicy, originKind: issues.originKind, originId: issues.originId,
visibility: issues.visibility, privacyRootIssueId: issues.privacyRootIssueId,
responsibleUserId: issues.responsibleUserId, createdByUserId: issues.createdByUserId,
readable: sql<boolean[]>`jsonb_build_array(${sql.join(privacyActors.map(actor => issuePrivacyCondition(actor)), sql`, `)})`,
readable: sql<boolean[]>`ARRAY[${sql.join(privacyActors.map(actor => issuePrivacyCondition(actor)), sql`, `)}]`,
}).from(issues).where(inArray(issues.id, issueIds.slice(offset, offset + 500)));
for (const row of rows) {
for (let index = 0; index < snapshots.length; index++) snapshots[index].issues.set(row.id, { ...row, readable: row.readable[privacyIndexes[index]] });
@@ -786,7 +786,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
for (let offset = 0; offset < ids.length; offset += 500) {
const rows = await db.select({ id: projects.id, companyId: projects.companyId,
executionWorkspacePolicy: projects.executionWorkspacePolicy, visibility: projects.visibility,
readable: sql<boolean[]>`jsonb_build_array(${sql.join(privacyActors.map(actor => projectPrivacyCondition(actor)), sql`, `)})`,
readable: sql<boolean[]>`ARRAY[${sql.join(privacyActors.map(actor => projectPrivacyCondition(actor)), sql`, `)}]`,
}).from(projects).where(inArray(projects.id, ids.slice(offset, offset + 500)));
for (const row of rows) for (let index = 0; index < snapshots.length; index++) {
snapshots[index].projects.set(row.id, { ...row, readable: row.readable[privacyIndexes[index]] });