mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix: restore repository admission and Claude startup on Linux ARM64 (#16372)
Batch retained workspace admission reads while preserving individual privacy and trust decisions. Preserve bounded saved-policy projections when integrating current master, and qualify the pinned Claude executable on Linux ARM64.
Verified exact head 4d468f998b with full CI/E2E, Superagent, Snyk and Apex 5/5, with no unresolved findings. Local typecheck/build and 209 server plus 74 integrity cases pass; full local suite remains pending.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
444d5854b0
commit
854ab294cc
8 files changed
+387
-92
No files matched your search
@@ -882,6 +882,8 @@ Migration [0337_exotic_proemial_gods.sql](../packages/db/src/migrations/0337_exo
|
||||
|
||||
Migration [0338_absurd_vengeance.sql](../packages/db/src/migrations/0338_absurd_vengeance.sql) adds internal `execution_workspace_repository_access` provenance. Company/canonical-root source entries retain execution-workspace and source-project identities; reader entries retain the exact admitted run, task, agent and responsible user. New repository receipts also record their root at creation; legacy receipts receive no inferred origin. Shared project-source entries retain which physical owners require workspace write authority. Current admission, selection, requests and publication check the current task’s writer against those owners; retained past readers and file APIs still require only read authority. Genuine isolated copies retain source read access without requiring shared-folder write access. An existing linked worktree can recover a missing isolated-copy proof only after Git registration and its captured source owner are verified. Configured local sibling copies retain deny-only task/project source ownership on the bound task root before materialization; ordinary repository clones create no isolation proof. The shared root fence checks every reader against every source before admission and future publication, including tasks without repository requests. File APIs apply the same source privacy without writing provenance. These entries grant no authority and have no workspace or run deletion cascade; unavailable retained identity fails closed. Automatic provenance release is deferred, so a newly incompatible permission scope requires a separate physical root.
|
||||
|
||||
Repository admission loads retained workspace and task sources in bounded batches. Readers share source rows and privacy evaluation for identical principals, but each retained run receives its own trust and responsible-user decision. The snapshot lasts only for one read batch; later admission and publication reload current authority. Keep source-loading query counts bounded as both workspace history and retained reader counts grow.
|
||||
|
||||
Git-source capture checks the actual common Git storage directory and verified
|
||||
checkout owners. Separately stored metadata does not make its parent a checkout.
|
||||
When a linked worktree cannot identify its primary checkout, the service verifies
|
||||
|
||||
@@ -54,7 +54,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("ACPX installation integrity", () => {
|
||||
it.each([["linux", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)(
|
||||
it.each([["linux", "x64"], ["linux", "arm64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)(
|
||||
"retains Claude executable admission for a published bundle on %s %s", async (os, cpu) => {
|
||||
const parent = await realpath(await mkdtemp(join(tmpdir(), "paperclip-claude-npm-admission-")));
|
||||
temporaryDirectories.push(parent);
|
||||
@@ -223,7 +223,7 @@ describe("ACPX installation integrity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([["linux", "arm64"], ["darwin", "ia32"], ["freebsd", "x64"]] as const)(
|
||||
it.each([["linux", "ia32"], ["darwin", "ia32"], ["freebsd", "x64"]] as const)(
|
||||
"rejects the actual Claude runtime probe on unsupported %s %s",
|
||||
async (platform, arch) => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform);
|
||||
@@ -747,7 +747,7 @@ describe("ACPX installation integrity", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.runIf((process.platform === "linux" && process.arch === "x64") || (process.platform === "darwin" && ["arm64", "x64"].includes(process.arch)))(
|
||||
it.runIf(["linux", "darwin"].includes(process.platform) && ["arm64", "x64"].includes(process.arch))(
|
||||
"resolves and pins the installed Claude ACP dependency graph",
|
||||
async () => {
|
||||
const profile = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5");
|
||||
|
||||
@@ -61,6 +61,12 @@ const QUALIFIED_CLAUDE_LINUX_X64_RUNTIME = Object.freeze({
|
||||
environmentVariable: "CLAUDE_CODE_EXECUTABLE",
|
||||
});
|
||||
|
||||
const QUALIFIED_CLAUDE_LINUX_ARM64_RUNTIME = Object.freeze({
|
||||
...QUALIFIED_CLAUDE_LINUX_X64_RUNTIME,
|
||||
packageName: "@anthropic-ai/claude-agent-sdk-linux-arm64",
|
||||
executableDigest: "sha256:0292fa22ac2fd43e16be9d0e511ddd8347280d6e0ebaca744ef5b27e05d8d0f8",
|
||||
});
|
||||
|
||||
const QUALIFIED_CLAUDE_DARWIN_RUNTIMES = {
|
||||
arm64: Object.freeze({
|
||||
...QUALIFIED_CLAUDE_LINUX_X64_RUNTIME,
|
||||
@@ -389,6 +395,7 @@ function isDeclaredHoistedProviderPlatform(root: string, manifest: string, issue
|
||||
const platformQualification = [
|
||||
{ qualified: QUALIFIED_CODEX_LINUX_X64_RUNTIME, os: "linux", cpu: "x64", alias: true },
|
||||
{ qualified: QUALIFIED_CLAUDE_LINUX_X64_RUNTIME, os: "linux", cpu: "x64", alias: false },
|
||||
{ qualified: QUALIFIED_CLAUDE_LINUX_ARM64_RUNTIME, os: "linux", cpu: "arm64", alias: false },
|
||||
{ qualified: QUALIFIED_CLAUDE_DARWIN_RUNTIMES.arm64, os: "darwin", cpu: "arm64", alias: false },
|
||||
{ qualified: QUALIFIED_CLAUDE_DARWIN_RUNTIMES.x64, os: "darwin", cpu: "x64", alias: false },
|
||||
].find(value => value.qualified.packageName === packageName);
|
||||
@@ -1020,7 +1027,9 @@ async function verifyQualifiedRuntimeExecutable(input: {
|
||||
input.profile.agent === "claude"
|
||||
? process.platform === "darwin" && (process.arch === "arm64" || process.arch === "x64")
|
||||
? QUALIFIED_CLAUDE_DARWIN_RUNTIMES[process.arch]
|
||||
: QUALIFIED_CLAUDE_LINUX_X64_RUNTIME
|
||||
: process.platform === "linux" && process.arch === "arm64"
|
||||
? QUALIFIED_CLAUDE_LINUX_ARM64_RUNTIME
|
||||
: QUALIFIED_CLAUDE_LINUX_X64_RUNTIME
|
||||
: input.profile.agent === "codex"
|
||||
? QUALIFIED_CODEX_LINUX_X64_RUNTIME
|
||||
: null;
|
||||
@@ -1034,7 +1043,7 @@ async function verifyQualifiedRuntimeExecutable(input: {
|
||||
);
|
||||
}
|
||||
if (!((process.platform === "linux" && process.arch === "x64")
|
||||
|| (input.profile.agent === "claude" && process.platform === "darwin" && (process.arch === "arm64" || process.arch === "x64")))) {
|
||||
|| (input.profile.agent === "claude" && ["linux", "darwin"].includes(process.platform) && (process.arch === "arm64" || process.arch === "x64")))) {
|
||||
throw new Error(
|
||||
`ACPX ${input.profile.agent} verified runtime executable is unavailable for ${process.platform} ${process.arch}`,
|
||||
);
|
||||
|
||||
@@ -48,6 +48,21 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(Math.max(...jsonBytes)).toBeLessThan(1024);
|
||||
});
|
||||
|
||||
it('keeps batched saved policy checks below the context transfer budget', async () => {
|
||||
const fixture = await seed();
|
||||
await db.insert(heartbeatRuns).values({ id: fixture.runId, companyId: fixture.companyId, agentId: fixture.agentId,
|
||||
contextSnapshot: { contextPadding: 'unrelated'.repeat(32_768), executionPolicy: {
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, projectIds: [fixture.projectId] } },
|
||||
} } });
|
||||
recording = true;
|
||||
const decisions = await authorizationService(db).decideReads(fixture.actor,
|
||||
[{ type: 'project', companyId: fixture.companyId, projectId: fixture.projectId }]);
|
||||
expect(decisions.map(decision => decision.allowed)).toEqual([true]);
|
||||
expect(jsonBytes.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...jsonBytes)).toBeLessThan(1024);
|
||||
});
|
||||
|
||||
it.each([undefined, null, [], 'invalid', 42, true, {}])('keeps the standard fallback for saved policy %j', async executionPolicy => {
|
||||
const fixture = await seed();
|
||||
await db.insert(heartbeatRuns).values({ id: fixture.runId, companyId: fixture.companyId, agentId: fixture.agentId,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, realpath, 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 { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaces, executionWorkspaceRepositoryAccess, heartbeatRuns, issueAccessGrants,
|
||||
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";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
@@ -12,9 +16,9 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("workspace authorization query", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), workspaceId = randomUUID();
|
||||
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), workspaceId = randomUUID(), runId = randomUUID();
|
||||
const sourceId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId, agentId, onBehalfOfUserId: userId };
|
||||
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId, agentId, runId, onBehalfOfUserId: userId };
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
@@ -27,6 +31,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, name: "Retained files", mode: "shared_workspace", strategyType: "operator_directory" });
|
||||
await db.$client.unsafe(`insert into issues (company_id, title) select $1, 'Unrelated history ' || n from generate_series(1, 1200) n`, [companyId]);
|
||||
await db.insert(issues).values({ id: sourceId, companyId, title: "Retained source" });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "succeeded", contextSnapshot: { issueId: sourceId } });
|
||||
await db.$client.unsafe("ANALYZE");
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => {
|
||||
@@ -102,6 +107,59 @@ 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 } });
|
||||
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("shares source loading across retained readers during actual repository admission", async () => {
|
||||
const cwd = await realpath(await mkdtemp(path.join(tmpdir(), "multi-reader-admission-")));
|
||||
const rootKey = createHash("sha256").update(cwd).digest("hex");
|
||||
const workspaceIds = Array.from({ length: 40 }, () => randomUUID());
|
||||
const taskIds = workspaceIds.map(() => randomUUID()), runIds = taskIds.slice(0, 10).map(() => randomUUID());
|
||||
await db.insert(executionWorkspaces).values(workspaceIds.map(id => ({ id, companyId, cwd,
|
||||
name: "Historical root", mode: "shared_workspace", strategyType: "operator_directory" })));
|
||||
await db.insert(issues).values(taskIds.map((id, i) => ({ id, companyId, title: "Source", assigneeAgentId: agentId, executionWorkspaceId: workspaceIds[i] })));
|
||||
await db.insert(heartbeatRuns).values(runIds.map((id, i) => ({ id, companyId, agentId, responsibleUserId: userId,
|
||||
status: "succeeded", contextSnapshot: { issueId: taskIds[i] } })));
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values(runIds.map((scopeId, i) => ({ companyId, rootKey,
|
||||
canonicalCwd: cwd, kind: "reader", scopeId, issueId: taskIds[i], agentId, responsibleUserId: userId })));
|
||||
try {
|
||||
const statements: string[] = [];
|
||||
const recorded = drizzle(db.$client, { schema: db._.fullSchema,
|
||||
logger: { logQuery: query => statements.push(query) } }) as unknown as typeof db;
|
||||
await executionWorkspaceRepositoryService(recorded).prepareForAdmission({ companyId, cwd,
|
||||
issueId: taskIds[0], workspaceId: workspaceIds[0], agentId, runId: runIds[0], responsibleUserId: userId });
|
||||
expect(statements.filter(query => query.includes('from "execution_workspaces"')
|
||||
|| query.includes('from "issues"')).length).toBeLessThan(50);
|
||||
} finally {
|
||||
await db.delete(executionWorkspaceRepositoryAccess).where(and(eq(executionWorkspaceRepositoryAccess.companyId, companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey)));
|
||||
await db.delete(heartbeatRuns).where(inArray(heartbeatRuns.id, runIds));
|
||||
await db.delete(issues).where(inArray(issues.id, taskIds));
|
||||
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 +181,72 @@ 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"); }
|
||||
});
|
||||
|
||||
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 }];
|
||||
expect((await service.decideReads(actor, resources))[0].allowed).toBe(true);
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: sourceId, executionPolicy: {
|
||||
trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
|
||||
trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: sourceId } },
|
||||
} } }).where(eq(heartbeatRuns.id, runId));
|
||||
expect((await service.decideReads(actor, resources))[0]).toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: sourceId } }).where(eq(heartbeatRuns.id, runId));
|
||||
expect((await service.decideReads(actor, resources))[0].allowed).toBe(true);
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, userId)));
|
||||
expect((await service.decideReads(actor, resources))[0]).toMatchObject({ allowed: false, code: "RESPONSIBLE_USER_UNAVAILABLE" });
|
||||
});
|
||||
|
||||
});
|
||||
@@ -729,19 +729,89 @@ 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) {
|
||||
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[]> {
|
||||
return (await decideReadsForActors([actor], resources))[0];
|
||||
}
|
||||
|
||||
async function decideReadsForActors(actors: AuthorizationActor[], resources: ReadAuthorizationResource[]): Promise<AuthorizationDecision[][]> {
|
||||
if (!actors.length) return [];
|
||||
const snapshots: ReadAuthorizationSnapshot[] = actors.map(() => ({ issues: new Map(), projects: new Map(), memo: new Map() }));
|
||||
const batchActors: ResponsibleUserActorWithMemo[] = actors.map(actor => ({ ...actor, __responsibleUserSnapshotMemo: undefined }));
|
||||
const privacyKey = (actor: AuthorizationActor) => JSON.stringify([actor.type, actor.agentId, actor.userId, actor.onBehalfOfUserId]);
|
||||
const privacyActors = [...new Map(batchActors.map(actor => [privacyKey(actor), actor])).values()];
|
||||
const privacyIndexes = batchActors.map(actor => privacyActors.findIndex(peer => privacyKey(peer) === privacyKey(actor)));
|
||||
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: 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]] });
|
||||
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: 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]] });
|
||||
}
|
||||
}
|
||||
const results: AuthorizationDecision[][] = [];
|
||||
for (let index = 0; index < batchActors.length; index++) {
|
||||
const batch = createAuthorizationService(db, snapshots[index]);
|
||||
const decisions: AuthorizationDecision[] = [];
|
||||
for (const resource of resources) decisions.push(await batch.decide({ actor: batchActors[index], resource,
|
||||
action: resource.type === "issue" ? "issue:read" : resource.type === "project" ? "project:read" : "company_scope:read" }));
|
||||
results.push(decisions);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
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 +819,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 +993,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 +1023,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 +1044,25 @@ 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: sql<Record<string, unknown>>`jsonb_build_object('executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`,
|
||||
})
|
||||
.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: sql<Record<string, unknown>>`jsonb_build_object('executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`,
|
||||
})
|
||||
.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 +2743,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 +2779,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 +2795,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 +2838,8 @@ export function authorizationService(db: Db | DbTransaction) {
|
||||
|
||||
return {
|
||||
decide,
|
||||
decideReads,
|
||||
decideReadsForActors,
|
||||
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 { assertTaskWorkspacesReadAccessForActors, 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);
|
||||
}
|
||||
|
||||
@@ -256,11 +256,13 @@ async function retainGitMetadataOwners(tx: DbTransaction, input: {
|
||||
const closure = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd: input.commonRoot, inspectCurrentAliases: true });
|
||||
const workspaceIds = new Set([...closure.sourceIds, ...input.workspaceIds]);
|
||||
const projectIds = new Set([...closure.sourceProjectIds, ...input.projectIds]);
|
||||
const readerActors: AuthorizationActor[] = [];
|
||||
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);
|
||||
readerActors.push(actor);
|
||||
await assertRootProjectAccess(tx, actor, input.companyId, projectIds);
|
||||
}
|
||||
await assertTaskWorkspacesReadAccessForActors(tx, readerActors, input.companyId, workspaceIds);
|
||||
for (const [kind, ids] of [["source", workspaceIds], ["project_source", projectIds]] as const) {
|
||||
for (const scopeId of ids) if (!closure.entries.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId,
|
||||
@@ -278,10 +280,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 +315,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,15 +348,17 @@ 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 });
|
||||
const readerActors: AuthorizationActor[] = [];
|
||||
for (const reader of target.overlappingEntries.filter(entry => entry.kind === "reader")) {
|
||||
const { actor, issue } = await retainedRootReader(tx, input.companyId, reader);
|
||||
readerActors.push(actor);
|
||||
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 assertRootProjectAccess(tx, actor, input.companyId, target.sourceProjectIds);
|
||||
}
|
||||
await assertTaskWorkspacesReadAccessForActors(tx, readerActors, input.companyId, target.sourceIds);
|
||||
} catch (error) { failure ??= error; }
|
||||
if (!failure && verifiedCopyProjectIds) {
|
||||
for (const scopeId of verifiedCopyProjectIds) if (!existing.some(entry => entry.kind === "isolated_copy" && entry.scopeId === scopeId)) {
|
||||
@@ -451,7 +451,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 +461,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;
|
||||
@@ -542,6 +542,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const ownAdmissionOnly = input.preserveOwnAdmission && input.reader && input.sourceWorkspaceId
|
||||
&& sourceIds.size === 1 && sourceIds.has(input.sourceWorkspaceId)
|
||||
&& readers.every(reader => reader.issueId === input.reader!.issueId);
|
||||
const sourceProjects = sourceIds.size ? await tx.selectDistinct({ projectId: executionWorkspaces.projectId }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), inArray(executionWorkspaces.id, [...sourceIds]))) : [];
|
||||
const sourceActors: AuthorizationActor[] = [];
|
||||
for (const reader of readers) {
|
||||
const { actor, issue } = await retainedRootReader(tx, input.companyId, reader);
|
||||
await assertRootProjectTrust(tx, { companyId: input.companyId, projectIds: sourceProjectIds, actor, issueId: issue.id,
|
||||
@@ -556,10 +559,8 @@ 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 sourceActors.push(actor);
|
||||
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,8 +570,9 @@ 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);
|
||||
sourceActors.push(input.actor);
|
||||
}
|
||||
await assertTaskWorkspacesReadAccessForActors(tx, sourceActors, 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) {
|
||||
for (const scopeId of ids) {
|
||||
@@ -714,10 +716,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,61 @@ export async function assertTaskWorkspaceAccess(
|
||||
if (!decision.allowed) throw forbidden("Task workspace retains files outside this actor's authorization boundary");
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertTaskWorkspacesReadAccess(
|
||||
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceIds: Iterable<string>,
|
||||
) {
|
||||
return assertTaskWorkspacesReadAccessForActors(db, [actor], companyId, workspaceIds);
|
||||
}
|
||||
|
||||
export async function assertTaskWorkspacesReadAccessForActors(
|
||||
db: Db | DbTransaction, actors: AuthorizationActor[], companyId: string, workspaceIds: Iterable<string>,
|
||||
) {
|
||||
const ids = [...new Set(workspaceIds)];
|
||||
if (!ids.length || !actors.length) return;
|
||||
const privacyActors = [...new Map(actors.map(actor => [JSON.stringify({ ...actor, runId: undefined }), actor])).values()];
|
||||
const privacy = and(...await Promise.all(privacyActors.map(actor => 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 batches = await authorizationService(db).decideReadsForActors(actors, resources);
|
||||
for (const decisions of batches) 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