diff --git a/server/src/__tests__/workspace-authorization-query.test.ts b/server/src/__tests__/workspace-authorization-query.test.ts index 31c702e908b..31035a72480 100644 --- a/server/src/__tests__/workspace-authorization-query.test.ts +++ b/server/src/__tests__/workspace-authorization-query.test.ts @@ -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"); } + }); + }); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index e78eae1b013..c40b4bca796 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -729,19 +729,75 @@ export function authorizationDeniedDetails(decision: AuthorizationDecision) { type DbTransaction = Parameters[0]>[0]; +type ReadAuthorizationSnapshot = { + issues: Map; + projects: Map; + memo: Map>; +}; + +type ReadAuthorizationResource = Extract; + 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(key: string, read: () => Promise): Promise { + if (!readSnapshot) return read(); + let result = readSnapshot.memo.get(key); + if (!result) { result = read(); readSnapshot.memo.set(key, result); } + return result as Promise; + } + + async function decideReads(actor: AuthorizationActor, resources: ReadAuthorizationResource[]): Promise { + 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 { 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 { @@ -921,21 +979,24 @@ export function authorizationService(db: Db | DbTransaction) { } async function loadAgent(agentId: string): Promise { - 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 { + 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 { + 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, }; diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index c91685ea9a0..469a36a36eb 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -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 { 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(), projectIds = new Set(); 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[4]>, "write">): Promise { 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 | 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; } diff --git a/server/src/services/task-workspace-source-access.ts b/server/src/services/task-workspace-source-access.ts index bc0cf0a4d14..69410a380a0 100644 --- a/server/src/services/task-workspace-source-access.ts +++ b/server/src/services/task-workspace-source-access.ts @@ -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[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, +) { + const ids = [...new Set(workspaceIds)]; + if (!ids.length) return; + const privacy = await executionWorkspaceReadSqlCondition(db, actor); + const projectIds = new Set(), sourceIds = new Set(); + 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); + } +}