diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 4ae992bc87e..8af04cbd37b 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -45,6 +45,24 @@ pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-run-history.ts - The fixture has 1,400 synthetic runs with 192 KiB contexts. Both queries return the same 200 complete rows. This benchmark excludes HTTP and redaction latency. +Workspace reads also compare retained task IDs as guarded, canonical UUIDs. +This lets PostgreSQL check each source task by primary key before it evaluates +task privacy. Casting the indexed task ID to text can instead evaluate privacy +across company history for each workspace. Repository admission repeats these +reads while holding its company provenance lock, so that plan can delay other +agent launches. Missing, malformed, noncanonical, and foreign-company source +IDs still deny access. + +To compare this query on a disposable fixture with 13,429 tasks: + +```sh +pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-workspace-authorization.ts --baseline-ref=5c7a025032 +``` + +The benchmark loads the baseline authorization module with current dependencies, +compares complete workspace rows, and prints five warm samples and query plans. +It measures SQL execution, not the full repository admission or agent startup. + ## 1. Embedded PostgreSQL — zero config If you don't set `DATABASE_URL`, the server automatically starts an embedded PostgreSQL instance and manages a local data directory. diff --git a/scripts/benchmark-workspace-authorization.ts b/scripts/benchmark-workspace-authorization.ts new file mode 100644 index 00000000000..c7a40efc2d3 --- /dev/null +++ b/scripts/benchmark-workspace-authorization.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createRequire } from "node:module"; +const { and, eq } = createRequire(new URL("../server/package.json", import.meta.url))("drizzle-orm"); +import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaces, issues, + startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js"; +import * as authorization from "../server/src/services/authorization.js"; + +const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15) ?? "HEAD"; +const baselineRevision = execFileSync("git", ["rev-parse", `${baselineRef}^{commit}`], { encoding: "utf8" }).trim(); +process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; +const keepAlive = setInterval(() => {}, 1000); +let database: Awaited> | undefined; +let db: Db | undefined; +let baselineDir: string | undefined; +try { + database = await startEmbeddedPostgresTestDatabase("workspace-authorization-benchmark-"); + db = createDb(database.connectionString); + baselineDir = await fs.mkdtemp(fileURLToPath(new URL("../server/scripts/.workspace-auth-baseline-", import.meta.url))); + const original = new URL("../server/src/services/authorization.ts", import.meta.url); + const source = execFileSync("git", ["show", `${baselineRevision}:server/src/services/authorization.ts`], { encoding: "utf8" }); + const rewritten = source.replace(/from "(\.{1,2}\/[^\"]+)"/g, + (_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, original)))}`); + const baselinePath = path.join(baselineDir, "authorization.ts"); + await fs.writeFile(baselinePath, rewritten); + const baseline = await import(pathToFileURL(baselinePath).href) as typeof authorization; + const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), workspaceId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Synthetic workspace authorization", issuePrefix: "BENCH" }); + await db.insert(authUsers).values({ id: userId, name: "Sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "process" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" }); + await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, name: "Shared source", mode: "shared_workspace", strategyType: "operator_directory" }); + await db.$client.unsafe(`insert into issues (company_id, title) select $1, 'Historical task ' || n from generate_series(1, 13428) n`, [companyId]); + const [sourceIssue] = await db.insert(issues).values({ companyId, title: "Retained source after unrelated history" }).returning({ id: issues.id }); + await db.update(executionWorkspaces).set({ sourceIssueId: sourceIssue.id }).where(eq(executionWorkspaces.id, workspaceId)); + await db.$client.unsafe("ANALYZE"); + const actor: authorization.AuthorizationActor = { type: "agent", source: "agent_jwt", agentId, companyId, onBehalfOfUserId: userId }; + const queries = await Promise.all([baseline, authorization].map(async module => db!.select().from(executionWorkspaces).where(and( + eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId), + await module.executionWorkspaceReadSqlCondition(db!, actor), + )).toSQL())); + const samples: number[][] = [[], []]; + let expected: unknown; + for (let n = 0; n < 7; n++) { + for (const variant of n % 2 ? [1, 0] : [0, 1]) { + const query = queries[variant]; + const start = performance.now(); + const rows = await db.$client.unsafe(query.sql, query.params as never[]).values(); + if (n >= 2) samples[variant].push(performance.now() - start); + assert.equal(rows.length, 1); + if (expected === undefined) expected = rows; + else assert.deepEqual(rows, expected); + } + } + const results = []; + for (const [variant, query] of queries.entries()) { + const [plan] = await db.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.sql}`, query.params as never[]); + results.push({ variant: variant ? "treatment" : "baseline", samplesMs: samples[variant], + medianMs: [...samples[variant]].sort((a, b) => a - b)[2], sql: query.sql, params: query.params, plan: plan["QUERY PLAN"] }); + } + console.log(JSON.stringify({ baselineRevision, tasks: 13429, retainedSources: 1, equalCompleteRows: true, + fixtureIds: { companyId, agentId, userId, workspaceId }, + postgres: (await db.$client.unsafe("select version()"))[0].version, results }, null, 2)); +} finally { + try { + await db?.$client.end({ timeout: 0 }); + await database?.cleanup(); + if (baselineDir) await fs.rm(baselineDir, { recursive: true, force: true }); + } finally { clearInterval(keepAlive); } +} diff --git a/server/src/__tests__/workspace-authorization-query.test.ts b/server/src/__tests__/workspace-authorization-query.test.ts new file mode 100644 index 00000000000..ce66a9c7a19 --- /dev/null +++ b/server/src/__tests__/workspace-authorization-query.test.ts @@ -0,0 +1,90 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { and, eq, sql } from "drizzle-orm"; +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 { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("workspace authorization query", () => { + let database: Awaited>; + let db: ReturnType; + const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), workspaceId = randomUUID(); + const sourceId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId, agentId, onBehalfOfUserId: userId }; + + beforeAll(async () => { + vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce"); + database = await startEmbeddedPostgresTestDatabase("workspace-authorization-query-"); + db = createDb(database.connectionString); + await db.insert(companies).values({ id: companyId, name: "Workspace authorization", issuePrefix: "WAQ" }); + await db.insert(authUsers).values({ id: userId, name: "Sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "process" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" }); + 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.$client.unsafe("ANALYZE"); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { + vi.unstubAllEnvs(); + await db?.$client.end({ timeout: 0 }); + await database?.cleanup(); + }); + + async function query() { + return db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), + eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(db, actor))).toSQL(); + } + async function read() { + const q = await query(); + return db.$client.unsafe(q.sql, q.params as never[]); + } + async function retain(id: string) { + // Seed historical/corrupt provenance in this disposable database. Normal + // writes have a trigger that deliberately preserves the previous keys. + await db.transaction(async tx => { + await tx.execute(sql`set local session_replication_role = replica`); + await tx.update(executionWorkspaces).set({ metadata: { _issuePrivacySources: { [id]: true } } }) + .where(eq(executionWorkspaces.id, workspaceId)); + }); + } + + it("checks retained task IDs without evaluating privacy for unrelated company history", async () => { + await retain(sourceId); + expect(await read()).toHaveLength(1); + const q = await query(); + const [result] = await db.$client.unsafe(`EXPLAIN (ANALYZE, FORMAT JSON) ${q.sql}`, q.params as never[]); + type Plan = { "Node Type": string; "Actual Loops": number; Plans?: Plan[] }; + function privacyTraversals(plan: Plan): number { + return (plan["Node Type"] === "Recursive Union" ? plan["Actual Loops"] : 0) + + (plan.Plans ?? []).reduce((sum, child) => sum + privacyTraversals(child), 0); + } + // A single source should not make admission authorize 1,200 unrelated tasks + // for both the agent and its responsible user under the company-wide lock. + expect(privacyTraversals(result["QUERY PLAN"][0].Plan)).toBeLessThan(100); + }); + + 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") { + const [foreign] = await db.insert(companies).values({ name: "Foreign", issuePrefix: "OTHER" }).returning(); + await db.insert(issues).values({ id, companyId: foreign.id, title: "Foreign source" }); + } + await retain(id); + expect(await read()).toHaveLength(0); + }); + + it("requires both principals and rechecks revoked grants for retained private files", async () => { + await retain(sourceId); + await db.update(issues).set({ visibility: "private", privacyRootIssueId: sourceId }).where(eq(issues.id, sourceId)); + expect(await read()).toHaveLength(0); + await db.insert(issueAccessGrants).values({ issueId: sourceId, subjectType: "agent", subjectId: agentId, source: "explicit" }); + expect(await read()).toHaveLength(0); + const [grant] = await db.insert(issueAccessGrants).values({ issueId: sourceId, subjectType: "user", subjectId: userId, source: "explicit" }).returning(); + expect(await read()).toHaveLength(1); + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id)); + expect(await read()).toHaveLength(0); + }); +}); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 9b68990080e..e78eae1b013 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -2,6 +2,7 @@ import { and, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { withHumanDirectedWork } from "./human-directed-work.js"; import { hasOwnerChatInstructionAuthority } from "./owner-chat-instruction-authority.js"; +import { canonicalExecutionUuid } from "./execution-identity.js"; import { agents, approvals, @@ -2769,7 +2770,7 @@ export async function executionWorkspaceReadSqlCondition(db: Db | DbTransaction, select 1 from ${projects} where ${projects.id} = ${executionWorkspaces.projectId} and ${projects.companyId} = ${executionWorkspaces.companyId} and ${projectCondition})) and not exists (select 1 from jsonb_object_keys(coalesce(${executionWorkspaces.metadata}->'_issuePrivacySources', '{}'::jsonb)) source(id) - where not exists (select 1 from ${issues} where ${issues.id}::text = source.id + where not exists (select 1 from ${issues} where ${issues.id} = ${canonicalExecutionUuid(sql`source.id`)} and ${issues.companyId} = ${executionWorkspaces.companyId} and ${issueCondition})) and not exists (select 1 from ${issues} where ${issues.companyId} = ${executionWorkspaces.companyId}