From 66724b8ce09e5fda51c03ddedbb47ce04d503979 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:45:08 -0500 Subject: [PATCH] perf(auth): project only required run context fields (#16380) Project only the required JSON fields for saved-run policy and direct-parent report authorization. Preserve current reads, JSON types, identity checks, permission decisions and legacy task-key precedence. Validation: 186 affected cases; 17 database transfer-budget cases after the shared startup-timeout correction; full typecheck/build; exact-head full CI, E2E, Superagent and Apex 5/5. Co-Authored-By: Paperclip --- doc/DATABASE.md | 7 ++ .../authorization-run-context.test.ts | 98 +++++++++++++++++++ server/src/services/authorization.ts | 4 +- 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 server/src/__tests__/authorization-run-context.test.ts diff --git a/doc/DATABASE.md b/doc/DATABASE.md index cb9810ec53a..76434dce12d 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -36,6 +36,13 @@ secret redaction still run on the bounded result set. Agent detail links fetch an older selected run by ID when it is outside the history page, and verify that it belongs to the current company and agent. +Saved-run policy and parent-report lookups read only the `executionPolicy` +value or the legacy `issueId` and `taskId` values from a run context. PostgreSQL projects +those JSON values before it sends the row to the controller. Unused prompt and +launch context does not travel with each policy or parent-report check. JSON +types, task-key precedence, and company and agent identity checks are preserved. +Each decision still reads current data; this is not an authorization cache. + To compare the real summary and full history queries with an earlier revision: ```sh diff --git a/server/src/__tests__/authorization-run-context.test.ts b/server/src/__tests__/authorization-run-context.test.ts new file mode 100644 index 00000000000..0491698c5f4 --- /dev/null +++ b/server/src/__tests__/authorization-run-context.test.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto'; +import { eq, sql } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { agents, companies, companyMemberships, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issues, projects, type Db } from '@paperclipai/db'; +import { LOW_TRUST_REVIEW_PRESET } from '@paperclipai/shared'; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from './helpers/embedded-postgres.js'; +import { authorizationService } from '../services/authorization.js'; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)('authorization run context transfer', () => { + let database: Awaited>; + let client: ReturnType['$client']; + let db: Db; + let recording = false; + let jsonBytes: number[] = []; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase('paperclip-authorization-context-'); + db = createDb(database.connectionString, { maxConnections: 1 }); + client = db.$client; + client.options.transform.value.from = (value, column) => { + if (recording && column.type === 3802 && value !== null) jsonBytes.push(Buffer.byteLength(JSON.stringify(value))); + return value; + }; + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + beforeEach(() => { recording = false; jsonBytes = []; }); + afterAll(async () => { await client?.end({ timeout: 5 }); await database?.cleanup(); }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), projectId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: 'Context transfer', issuePrefix: `CT${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: 'Reader', role: 'engineer', adapterType: 'process' }); + await db.insert(projects).values({ id: projectId, companyId, name: 'Allowed project' }); + await db.insert(companyMemberships).values({ companyId, principalType: 'agent', principalId: agentId, status: 'active', membershipRole: 'member' }); + const actor = { type: 'agent' as const, source: 'agent_jwt' as const, companyId, agentId, runId }; + return { companyId, agentId, projectId, runId, actor }; + } + + it('retains saved run restrictions without transferring unrelated context', 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; + expect(await authorizationService(db).projectDiscoveryCandidateIds(fixture.actor, fixture.companyId)).toEqual([fixture.projectId]); + 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, + contextSnapshot: { contextPadding: 'unrelated'.repeat(32_768), executionPolicy } }); + recording = true; + expect(await authorizationService(db).projectDiscoveryCandidateIds(fixture.actor, fixture.companyId)).toBeNull(); + expect(jsonBytes.length).toBeGreaterThan(0); + expect(Math.max(...jsonBytes)).toBeLessThan(1024); + }); + + it.each([null, [], 'legacy', 42, true])('keeps the standard fallback for context container %j', async context => { + const fixture = await seed(); + await db.insert(heartbeatRuns).values({ id: fixture.runId, companyId: fixture.companyId, agentId: fixture.agentId }); + await db.execute(sql`update ${heartbeatRuns} set context_snapshot=${JSON.stringify(context)}::jsonb + where ${heartbeatRuns.id}=${fixture.runId} and ${heartbeatRuns.companyId}=${fixture.companyId}`); + expect(await authorizationService(db).projectDiscoveryCandidateIds(fixture.actor, fixture.companyId)).toBeNull(); + }); + + it.each(['company', 'agent'])('does not take policy from another %s identity', async mismatch => { + const fixture = await seed(), other = await seed(); + if (mismatch === 'agent') await db.update(agents).set({ companyId: fixture.companyId }).where(eq(agents.id, other.agentId)); + await db.insert(heartbeatRuns).values({ id: other.runId, + companyId: mismatch === 'company' ? other.companyId : fixture.companyId, agentId: other.agentId, + contextSnapshot: { contextPadding: 'unrelated'.repeat(32_768), executionPolicy: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, projectIds: [other.projectId] } }, + } } }); + recording = true; + expect(await authorizationService(db).projectDiscoveryCandidateIds({ ...fixture.actor, runId: other.runId }, fixture.companyId)).toBeNull(); + expect(Math.max(...jsonBytes)).toBeLessThan(1024); + }); + + it.each(['issueId', 'taskId'])('retains the direct-parent report for legacy context key %s', async key => { + const fixture = await seed(), parentId = randomUUID(), childId = randomUUID(); + await db.insert(issues).values({ id: parentId, companyId: fixture.companyId, projectId: fixture.projectId, title: 'Parent' }); + await db.insert(issues).values({ id: childId, companyId: fixture.companyId, projectId: fixture.projectId, parentId, + assigneeAgentId: fixture.agentId, title: 'Child' }); + await db.insert(heartbeatRuns).values({ id: fixture.runId, companyId: fixture.companyId, agentId: fixture.agentId, + contextSnapshot: { [key]: childId, contextPadding: 'unrelated'.repeat(32_768) } }); + await db.update(issues).set({ checkoutRunId: fixture.runId }).where(eq(issues.id, childId)); + recording = true; + const decision = await authorizationService(db).decide({ actor: fixture.actor, action: 'issue:comment', + resource: { type: 'issue', companyId: fixture.companyId, projectId: fixture.projectId, issueId: parentId } }); + expect(decision).toMatchObject({ allowed: true, reason: 'allow_direct_parent_report' }); + expect(jsonBytes.length).toBeGreaterThan(0); + expect(Math.max(...jsonBytes)).toBeLessThan(1024); + }); +}); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index e78eae1b013..315155041e8 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -974,7 +974,7 @@ export function authorizationService(db: Db | DbTransaction) { id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, - contextSnapshot: heartbeatRuns.contextSnapshot, + contextSnapshot: sql>`jsonb_build_object('executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`, }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) @@ -992,7 +992,7 @@ export function authorizationService(db: Db | DbTransaction) { .select({ companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, - contextSnapshot: heartbeatRuns.contextSnapshot, + contextSnapshot: sql>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId')`, }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId))