mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
fix(server): batch retained repository admission authorization
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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"); }
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user