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))