fix(server): batch retained repository admission authorization

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-14 21:59:38 -05:00
1 parent ccc77c5b5e
commit df7a8c02bd
4 files changed
+247 -82

No files matched your search

@@ -1,10 +1,14 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { and, eq, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaces, issueAccessGrants,
issues, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "../services/authorization.js";
import { authorizationService, executionWorkspaceReadSqlCondition, type AuthorizationActor } from "../services/authorization.js";
import { assertExecutionWorkspaceRootReadAccess } from "../services/execution-workspace-repositories.js";
import { assertTaskWorkspaceAccess } from "../services/task-workspace-source-access.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@@ -102,6 +106,32 @@ const support = await getEmbeddedPostgresTestSupport();
}
});
it("authorizes a retained repository in bounded database round trips as its workspace history grows", async () => {
const cwd = await mkdtemp(path.join(tmpdir(), "root-admission-query-"));
const workspaceIds = Array.from({ length: 40 }, () => randomUUID());
const sourceIds = workspaceIds.map(() => randomUUID());
await db.insert(executionWorkspaces).values(workspaceIds.map(id => ({
id, companyId, cwd, name: "Historical root", mode: "shared_workspace", strategyType: "operator_directory",
metadata: { _issuePrivacySources: { [sourceId]: true } },
})));
await db.insert(issues).values(sourceIds.map((id, i) => ({ id, companyId, title: "Source", executionWorkspaceId: workspaceIds[i] })));
try {
const statements: string[] = [];
const recorded = drizzle(db.$client, { schema: db._.fullSchema,
logger: { logQuery: query => statements.push(query) } }) as unknown as typeof db;
await assertExecutionWorkspaceRootReadAccess(recorded, { companyId, cwd, actor: { ...actor } });
// Access decisions must still cover every source and both principals,
// without reloading the same actor/run/membership for each workspace.
expect(statements.length).toBeLessThan(60);
await db.update(issues).set({ visibility: "private", privacyRootIssueId: sourceIds[39] }).where(eq(issues.id, sourceIds[39]));
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd, actor: { ...actor } })).rejects.toThrow();
} finally {
await db.delete(issues).where(inArray(issues.id, sourceIds));
await db.delete(executionWorkspaces).where(inArray(executionWorkspaces.id, workspaceIds));
await rm(cwd, { recursive: true, force: true });
}
});
it.each(["missing", "malformed", "uppercase", "foreign"])("denies a %s retained source without throwing a UUID parse error", async kind => {
const id = kind === "malformed" ? "not-a-uuid" : kind === "uppercase" ? sourceId.toUpperCase() : randomUUID();
if (kind === "foreign") {
@@ -123,4 +153,21 @@ const support = await getEmbeddedPostgresTestSupport();
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
expect(await read()).toHaveLength(0);
});
it.each(["enforce", "shadow", "off"])("matches individual read decisions for a fresh batch in %s mode", async mode => {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", mode);
try {
const resources = [{ type: "issue" as const, companyId, issueId: sourceId }, { type: "company" as const, companyId }];
const actors: AuthorizationActor[] = [actor, { ...actor, onBehalfOfUserId: undefined },
{ ...actor, companyId: randomUUID() }, { type: "none" },
{ type: "board", source: "session", userId }, { type: "board", source: "local_implicit", userId: "local-board" }];
const service = authorizationService(db);
for (const candidate of actors) {
const individual = [];
for (const resource of resources) individual.push(await service.decide({ actor: { ...candidate }, resource,
action: resource.type === "issue" ? "issue:read" : "company_scope:read" }));
expect(await service.decideReads(candidate, resources)).toEqual(individual);
}
} finally { vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce"); }
});
});
+123 -54
View File
@@ -729,19 +729,75 @@ export function authorizationDeniedDetails(decision: AuthorizationDecision) {
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type ReadAuthorizationSnapshot = {
issues: Map<string, IssueAuthorizationRow & IssuePrivacyRow & { readable: boolean }>;
projects: Map<string, ProjectAuthorizationRow & ProjectPrivacyRow & { readable: boolean }>;
memo: Map<string, Promise<unknown>>;
};
type ReadAuthorizationResource = Extract<AuthorizationResource, { type: "issue" | "project" | "company" }>;
export function authorizationService(db: Db | DbTransaction) {
return createAuthorizationService(db);
}
function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadAuthorizationSnapshot) {
// Only a single, read-only batch owns this snapshot. Ordinary decisions and
// subsequent admissions always read current policies, membership and grants.
function batchRead<T>(key: string, read: () => Promise<T>): Promise<T> {
if (!readSnapshot) return read();
let result = readSnapshot.memo.get(key);
if (!result) { result = read(); readSnapshot.memo.set(key, result); }
return result as Promise<T>;
}
async function decideReads(actor: AuthorizationActor, resources: ReadAuthorizationResource[]): Promise<AuthorizationDecision[]> {
const snapshot: ReadAuthorizationSnapshot = { issues: new Map(), projects: new Map(), memo: new Map() };
// Do not carry a previous request's responsible-user snapshot into this batch.
const batchActor: ResponsibleUserActorWithMemo = { ...actor, __responsibleUserSnapshotMemo: undefined };
const issueIds = [...new Set(resources.flatMap(resource => resource.type === "issue" && resource.issueId ? [resource.issueId] : []))];
const projectIds = new Set(resources.flatMap(resource => resource.type === "project" && resource.projectId ? [resource.projectId]
: resource.type === "issue" && resource.projectId ? [resource.projectId] : []));
for (let offset = 0; offset < issueIds.length; offset += 500) {
const rows = await db.select({
id: issues.id, companyId: issues.companyId, projectId: issues.projectId, parentId: issues.parentId,
assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, checkoutRunId: issues.checkoutRunId,
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: issuePrivacyCondition(batchActor),
}).from(issues).where(inArray(issues.id, issueIds.slice(offset, offset + 500)));
for (const row of rows) { snapshot.issues.set(row.id, row); if (row.projectId) projectIds.add(row.projectId); }
}
const ids = [...projectIds];
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: projectPrivacyCondition(batchActor),
}).from(projects).where(inArray(projects.id, ids.slice(offset, offset + 500)));
for (const row of rows) snapshot.projects.set(row.id, row);
}
const batch = createAuthorizationService(db, snapshot);
const decisions: AuthorizationDecision[] = [];
for (const resource of resources) decisions.push(await batch.decide({ actor: batchActor, resource,
action: resource.type === "issue" ? "issue:read" : resource.type === "project" ? "project:read" : "company_scope:read" }));
return decisions;
}
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
if (!userId) return false;
if (
await db
.select({ id: instanceUserRoles.id })
.from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
.then((rows) => rows[0] ?? null)
) {
return true;
}
return false;
return batchRead(`admin:${userId}`, async () => {
if (
await db
.select({ id: instanceUserRoles.id })
.from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
.then((rows) => rows[0] ?? null)
) {
return true;
}
return false;
});
}
async function getActiveMembership(
@@ -749,18 +805,20 @@ export function authorizationService(db: Db | DbTransaction) {
principalType: PrincipalType,
principalId: string,
) {
return db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, principalType),
eq(companyMemberships.principalId, principalId),
eq(companyMemberships.status, "active"),
),
)
.then((rows) => rows[0] ?? null);
return batchRead(`membership:${companyId}:${principalType}:${principalId}`, async () => {
return db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, principalType),
eq(companyMemberships.principalId, principalId),
eq(companyMemberships.status, "active"),
),
)
.then((rows) => rows[0] ?? null);
});
}
async function loadResponsibleUserSnapshot(companyId: string, userId: string): Promise<ResponsibleUserSnapshot> {
@@ -921,21 +979,24 @@ export function authorizationService(db: Db | DbTransaction) {
}
async function loadAgent(agentId: string): Promise<AgentAuthorizationRow | null> {
return db
.select({
id: agents.id,
companyId: agents.companyId,
role: agents.role,
status: agents.status,
reportsTo: agents.reportsTo,
permissions: agents.permissions,
})
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
return batchRead(`agent:${agentId}`, async () => {
return db
.select({
id: agents.id,
companyId: agents.companyId,
role: agents.role,
status: agents.status,
reportsTo: agents.reportsTo,
permissions: agents.permissions,
})
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
});
}
async function loadProject(projectId: string): Promise<ProjectAuthorizationRow | null> {
if (readSnapshot?.projects.has(projectId)) return readSnapshot.projects.get(projectId)!;
return db
.select({
id: projects.id,
@@ -948,6 +1009,7 @@ export function authorizationService(db: Db | DbTransaction) {
}
async function loadIssue(issueId: string): Promise<IssueAuthorizationRow | null> {
if (readSnapshot?.issues.has(issueId)) return readSnapshot.issues.get(issueId)!;
return db
.select({
id: issues.id,
@@ -968,22 +1030,24 @@ export function authorizationService(db: Db | DbTransaction) {
}
async function loadRunPolicy(runId: string | null | undefined, companyId: string, agentId: string) {
if (!runId) return null;
const row = await db
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!row || row.companyId !== companyId || row.agentId !== agentId) return null;
const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null;
return isPlainRecord(context?.executionPolicy)
? { companyId: row.companyId, executionPolicy: context.executionPolicy }
: null;
return batchRead(`run:${companyId}:${agentId}:${runId}`, async () => {
if (!runId) return null;
const row = await db
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!row || row.companyId !== companyId || row.agentId !== agentId) return null;
const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null;
return isPlainRecord(context?.executionPolicy)
? { companyId: row.companyId, executionPolicy: context.executionPolicy }
: null;
});
}
async function loadRunIssueId(runId: string | null | undefined, companyId: string, agentId: string) {
@@ -2664,12 +2728,14 @@ export function authorizationService(db: Db | DbTransaction) {
&& intersectedDecision.reason !== "allow_local_board"
&& issuePrivacyMode() !== "off"
) {
const project = await db
const retainedProject = readSnapshot?.projects.get(input.resource.projectId);
const project = retainedProject?.companyId === input.resource.companyId ? retainedProject : await db
.select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility })
.from(projects)
.where(and(eq(projects.id, input.resource.projectId), eq(projects.companyId, input.resource.companyId)))
.then((rows) => rows[0] ?? null);
if (project && !(await canActorReadProjectPrivacy(db, input.actor, project))) {
if (project && !(retainedProject?.companyId === input.resource.companyId
? retainedProject.readable : await canActorReadProjectPrivacy(db, input.actor, project))) {
const denied = deny({
action: input.action,
reason: "deny_project_private",
@@ -2698,7 +2764,8 @@ export function authorizationService(db: Db | DbTransaction) {
|| issuePrivacyMode() === "off"
) return intersectedDecision;
const issue = await db
const retainedIssue = readSnapshot?.issues.get(input.resource.issueId);
const issue = retainedIssue?.companyId === input.resource.companyId ? retainedIssue : await db
.select({
id: issues.id,
companyId: issues.companyId,
@@ -2713,7 +2780,8 @@ export function authorizationService(db: Db | DbTransaction) {
.from(issues)
.where(and(eq(issues.id, input.resource.issueId), eq(issues.companyId, input.resource.companyId)))
.then((rows) => rows[0] ?? null);
if (!issue || await canActorReadIssuePrivacy(db, input.actor, issue)) return intersectedDecision;
if (!issue || (retainedIssue?.companyId === input.resource.companyId
? retainedIssue.readable : await canActorReadIssuePrivacy(db, input.actor, issue))) return intersectedDecision;
const denied = deny({
action: input.action,
@@ -2755,6 +2823,7 @@ export function authorizationService(db: Db | DbTransaction) {
return {
decide,
decideReads,
projectDiscoveryCandidateIds,
decidePrincipalGrant,
};
@@ -4,13 +4,13 @@ import { isDeepStrictEqual, promisify } from "node:util";
import fs from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import path from "node:path";
import { and, eq, isNotNull, or, sql } from "drizzle-orm";
import { and, inArray, eq, isNotNull, or, sql } from "drizzle-orm";
import { activityLog, agents, heartbeatRuns, projects, projectWorkspaces, executionWorkspaceRepositoryAccess, executionWorkspaceRepositoryRoots, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db";
import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared";
import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { conflict, forbidden, notFound } from "../errors.js";
import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js";
import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js";
import { assertTaskWorkspacesReadAccess, assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js";
import { authorizationService, canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition, issueReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH, resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
@@ -239,7 +239,7 @@ export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransact
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string;
}): Promise<void> {
const { sourceIds, sourceProjectIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true });
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId);
await assertTaskWorkspacesReadAccess(db, input.actor, input.companyId, sourceIds);
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds);
}
@@ -258,7 +258,7 @@ async function retainGitMetadataOwners(tx: DbTransaction, input: {
const projectIds = new Set([...closure.sourceProjectIds, ...input.projectIds]);
for (const reader of closure.overlappingEntries.filter(entry => entry.kind === "reader")) {
const { actor } = await retainedRootReader(tx, input.companyId, reader);
for (const id of workspaceIds) await assertTaskWorkspaceAccess(tx, actor, input.companyId, id);
await assertTaskWorkspacesReadAccess(tx, actor, input.companyId, workspaceIds);
await assertRootProjectAccess(tx, actor, input.companyId, projectIds);
}
for (const [kind, ids] of [["source", workspaceIds], ["project_source", projectIds]] as const) {
@@ -278,10 +278,8 @@ export async function assertIsolatedWorkspaceSourceReadAccess(db: Db, input: {
const workspaceIds = new Set<string>(), projectIds = new Set<string>();
for (const cwd of roots) {
const closure = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd, inspectCurrentAliases: true });
for (const id of closure.sourceIds) {
await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, id);
workspaceIds.add(id);
}
await assertTaskWorkspacesReadAccess(tx, input.actor, input.companyId, closure.sourceIds);
for (const id of closure.sourceIds) workspaceIds.add(id);
await assertRootProjectAccess(tx, input.actor, input.companyId, closure.sourceProjectIds);
for (const id of closure.sourceProjectIds) projectIds.add(id);
}
@@ -315,10 +313,8 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
for (const id of closure.sourceProjectIds) projectIds.add(id);
} catch (error) { failure ??= error; }
}
for (const id of workspaceIds) {
try { await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, id); }
catch (error) { failure ??= error; }
}
try { await assertTaskWorkspacesReadAccess(tx, input.actor, input.companyId, workspaceIds); }
catch (error) { failure ??= error; }
for (const id of projectIds) {
try { await assertRootProjectAccess(tx, input.actor, input.companyId, new Set([id])); }
catch (error) { failure ??= error; }
@@ -350,13 +346,13 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
try {
const target = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd: canonicalCwd, preparingCopyProjectIds });
await assertRootProjectTrust(tx, { companyId: input.companyId, actor: input.actor, issueId: input.issueId, projectIds: target.sourceProjectIds });
for (const id of target.sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, id);
await assertTaskWorkspacesReadAccess(tx, input.actor, input.companyId, target.sourceIds);
await assertRootProjectAccess(tx, input.actor, input.companyId, target.sourceProjectIds,
{ sharedProjectIds: target.sharedProjectIds, issueId: input.issueId });
for (const reader of target.overlappingEntries.filter(entry => entry.kind === "reader")) {
const { actor, issue } = await retainedRootReader(tx, input.companyId, reader);
await assertRootProjectTrust(tx, { companyId: input.companyId, actor, issueId: issue.id, projectIds: target.sourceProjectIds });
for (const id of target.sourceIds) await assertTaskWorkspaceAccess(tx, actor, input.companyId, id);
await assertTaskWorkspacesReadAccess(tx, actor, input.companyId, target.sourceIds);
await assertRootProjectAccess(tx, actor, input.companyId, target.sourceProjectIds);
}
} catch (error) { failure ??= error; }
@@ -451,7 +447,7 @@ export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransac
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string; taskCreation?: TaskWorkspaceCreationTrustContext;
} & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">): Promise<void> {
const { sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true });
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId);
await assertTaskWorkspacesReadAccess(db, input.actor, input.companyId, sourceIds);
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds, { ...input, sharedProjectIds });
await assertRootProjectTrust(db, { ...input, projectIds: sourceProjectIds });
}
@@ -461,7 +457,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
return (await toolAccessService(db).listProjectRepositories(companyId, userId, localTrusted)).repositories;
}
async function requiresAnonymousAcquisition(reader: Db | DbTransaction, input: {
issue: typeof issues.$inferSelect; workspace: typeof executionWorkspaces.$inferSelect | null;
issue: typeof issues.$inferSelect; workspace: Pick<typeof executionWorkspaces.$inferSelect, "projectId"> | null;
agentId?: string | null; runId?: string | null;
}) {
const { issue, workspace } = input;
@@ -556,10 +552,12 @@ export function executionWorkspaceRepositoryService(db: Db) {
type: "board", userId: actor.onBehalfOfUserId, ignoreInstanceAdmin: true,
}) : undefined));
if (!ownSource) throw forbidden("Task workspace access is no longer available");
} else for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(tx, actor, input.companyId, sourceId);
const sourceWorkspaces = sourceIds.size ? await tx.select().from(executionWorkspaces).where(and(
eq(executionWorkspaces.companyId, input.companyId), or(...[...sourceIds].map(id => eq(executionWorkspaces.id, id))))) : [];
for (const sourceWorkspace of sourceWorkspaces.length ? sourceWorkspaces : [null]) {
} else await assertTaskWorkspacesReadAccess(tx, actor, input.companyId, sourceIds);
// Acquisition policy depends on the source project, not on each historical
// workspace. Revalidate each distinct project for every retained reader.
const sourceProjects = sourceIds.size ? await tx.selectDistinct({ projectId: executionWorkspaces.projectId }).from(executionWorkspaces).where(and(
eq(executionWorkspaces.companyId, input.companyId), inArray(executionWorkspaces.id, [...sourceIds]))) : [];
for (const sourceWorkspace of sourceProjects.length ? sourceProjects : [null]) {
if (await requiresAnonymousAcquisition(tx, { issue, workspace: sourceWorkspace, agentId: reader.agentId!, runId: reader.scopeId })) {
await reserveRootPolicy(tx, input.companyId, canonicalCwd, "anonymous_only");
}
@@ -569,7 +567,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
if (!input.issueId) throw forbidden("Workspace write authorization requires a task");
await assertRootProjectTrust(tx, { ...input, projectIds: sourceProjectIds, actor: input.actor });
await assertRootProjectAccess(tx, input.actor, input.companyId, sourceProjectIds, { sharedProjectIds, issueId: input.issueId });
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, sourceId);
await assertTaskWorkspacesReadAccess(tx, input.actor, input.companyId, sourceIds);
}
for (const [kind, ids] of [["source", sharedSourceIds], ["isolated_source", isolatedSourceIds], ["project_source", sourceProjectIds],
["shared_project_source", sharedProjectIds], ["isolated_project_source", isolatedProjectIds]] as const) {
@@ -714,10 +712,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
for (const id of source.sourceProjectIds) projectIds.add(id);
} catch (error) { failure ??= error; }
}
for (const id of workspaceIds) {
try { await assertTaskWorkspaceAccess(tx, actor, input.companyId, id); }
catch (error) { failure ??= error; }
}
try { await assertTaskWorkspacesReadAccess(tx, actor, input.companyId, workspaceIds); }
catch (error) { failure ??= error; }
for (const id of projectIds) {
try { await assertRootProjectAccess(tx, actor, input.companyId, new Set([id])); }
catch (error) { failure ??= error; }
@@ -2,7 +2,7 @@ import { executionWorkspaces, issues, type Db } from "@paperclipai/db";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { forbidden } from "../errors.js";
import { accessService } from "./access.js";
import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { authorizationService, executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { canonicalExecutionUuid } from "./execution-identity.js";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
@@ -61,3 +61,56 @@ export async function assertTaskWorkspaceAccess(
if (!decision.allowed) throw forbidden("Task workspace retains files outside this actor's authorization boundary");
}
}
/** Authorize the complete retained source set with one policy snapshot per read
* batch. Keep the single-workspace write path's assignment checks separate. */
export async function assertTaskWorkspacesReadAccess(
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceIds: Iterable<string>,
) {
const ids = [...new Set(workspaceIds)];
if (!ids.length) return;
const privacy = await executionWorkspaceReadSqlCondition(db, actor);
const projectIds = new Set<string>(), sourceIds = new Set<string>();
let needsCompanyRead = false;
for (let offset = 0; offset < ids.length; offset += 500) {
const batch = ids.slice(offset, offset + 500);
const workspaces = await db.select({ id: executionWorkspaces.id, projectId: executionWorkspaces.projectId,
sourceIssueId: executionWorkspaces.sourceIssueId, metadata: executionWorkspaces.metadata,
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, batch), privacy));
if (workspaces.length !== batch.length) throw forbidden("Task workspace access is no longer available");
const retained = new Set(workspaces.flatMap(workspace => [
...Object.keys(workspace.metadata?._issuePrivacySources ?? {}), ...(workspace.sourceIssueId ? [workspace.sourceIssueId] : []),
]));
const attached = await db.select({ id: issues.id, workspaceId: issues.executionWorkspaceId }).from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.executionWorkspaceId, batch)));
const attachedWorkspaceIds = new Set(attached.map(source => source.workspaceId));
for (const source of attached) sourceIds.add(source.id);
const retainedIds = [...retained];
for (let start = 0; start < retainedIds.length; start += 500) {
const retainedBatch = retainedIds.slice(start, start + 500);
const sources = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.companyId, companyId),
inArray(sql`${issues.id}`, retainedBatch.map(id => canonicalExecutionUuid(sql`${id}::text`)))));
const found = new Set(sources.map(source => source.id));
if (retainedBatch.some(id => !found.has(id))) throw forbidden("Task workspace source is no longer accessible");
for (const source of sources) sourceIds.add(source.id);
}
for (const workspace of workspaces) {
if (workspace.projectId) projectIds.add(workspace.projectId);
else if (!workspace.sourceIssueId && !Object.keys(workspace.metadata?._issuePrivacySources ?? {}).length
&& !attachedWorkspaceIds.has(workspace.id)) needsCompanyRead = true;
}
}
const resources = [
...[...projectIds].map(projectId => ({ type: "project" as const, companyId, projectId })),
...[...sourceIds].map(issueId => ({ type: "issue" as const, companyId, issueId })),
...(needsCompanyRead ? [{ type: "company" as const, companyId }] : []),
];
const decisions = await authorizationService(db).decideReads(actor, resources);
for (let index = 0; index < decisions.length; index++) {
if (decisions[index].allowed) continue;
throw forbidden(resources[index].type === "issue"
? "Task workspace retains files outside this actor's authorization boundary"
: resources[index].type === "company"
? "An unscoped workspace is outside this actor's authorization boundary" : decisions[index].explanation);
}
}