From 468b95708204fd235e04c2e61cfacc1869a91417 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 02:14:44 -0400 Subject: [PATCH 1/2] Validate passive report attribution --- .../recon/passive-auth-surface/route.ts | 4 + src/server/recon/index.ts | 1 + .../recon/stored-passive-auth-surface.ts | 89 +++++++++++++++++- .../stored-passive-auth-surface-api.test.ts | 90 +++++++++++++++++++ 4 files changed, 181 insertions(+), 3 deletions(-) diff --git a/src/app/api/projects/[projectId]/recon/passive-auth-surface/route.ts b/src/app/api/projects/[projectId]/recon/passive-auth-surface/route.ts index 7740e1b44..b70d10f64 100644 --- a/src/app/api/projects/[projectId]/recon/passive-auth-surface/route.ts +++ b/src/app/api/projects/[projectId]/recon/passive-auth-surface/route.ts @@ -1,6 +1,7 @@ import { createStoredPassiveAuthSurface, PassiveAuthSurfaceArtifactError, + PassiveAuthSurfaceAttributionError, PassiveAuthSurfaceAuthorizationError, type StoredPassiveArtifactRef, } from "../../../../../../server/recon"; @@ -38,6 +39,9 @@ export async function POST(request: Request, context: Context) { if (error instanceof PassiveAuthSurfaceArtifactError) { return notFound(error.message); } + if (error instanceof PassiveAuthSurfaceAttributionError) { + return notFound(error.message); + } if ( error instanceof Error && /required|must be|allows?|unique/i.test(error.message) diff --git a/src/server/recon/index.ts b/src/server/recon/index.ts index df776c753..ff7643d40 100644 --- a/src/server/recon/index.ts +++ b/src/server/recon/index.ts @@ -24,6 +24,7 @@ export { MAX_PASSIVE_AUTH_SOURCE_ARTIFACTS, MAX_PASSIVE_AUTH_SOURCE_BYTES, PassiveAuthSurfaceArtifactError, + PassiveAuthSurfaceAttributionError, PassiveAuthSurfaceAuthorizationError, STORED_PASSIVE_ARTIFACT_SOURCES, type StoredPassiveArtifactRef, diff --git a/src/server/recon/stored-passive-auth-surface.ts b/src/server/recon/stored-passive-auth-surface.ts index 658c55477..3e623550d 100644 --- a/src/server/recon/stored-passive-auth-surface.ts +++ b/src/server/recon/stored-passive-auth-surface.ts @@ -1,4 +1,4 @@ -import { withDatabase } from "../db/client"; +import { type Queryable, withDatabase } from "../db/client"; import { type ArtifactServiceInstance, getArtifactService } from "../evidence"; import { listActiveTargetAuthorizationRows } from "../targets/authorization-ledger"; import { @@ -56,6 +56,13 @@ export class PassiveAuthSurfaceArtifactError extends Error { } } +export class PassiveAuthSurfaceAttributionError extends Error { + constructor() { + super("Passive-auth attribution was not found in this project."); + this.name = "PassiveAuthSurfaceAttributionError"; + } +} + type Dependencies = { artifactService?: ArtifactServiceInstance; listActiveAuthorizations?: ( @@ -68,6 +75,7 @@ export async function createStoredPassiveAuthSurface( dependencies: Dependencies = {}, ): Promise { assertStoredArtifactRefs(input.artifacts); + await assertPassiveAuthSurfaceAttribution(input); const activeAuthorizations = dependencies.listActiveAuthorizations ? await dependencies.listActiveAuthorizations(input.projectId) : await withDatabase((db) => @@ -156,13 +164,88 @@ export async function createStoredPassiveAuthSurface( }; } -function isStoredPassiveArtifactSource(value: string): value is StoredPassiveArtifactSource { +type TaskAttributionRow = { + thread_id: string | null; + metadata: unknown; +}; + +async function assertPassiveAuthSurfaceAttribution( + input: Pick< + CreateStoredPassiveAuthSurfaceInput, + "projectId" | "targetId" | "threadId" | "taskId" + >, +): Promise { + if (!input.threadId && !input.taskId) return; + + await withDatabase(async (db) => { + if (input.threadId) { + const thread = await db.query<{ id: string }>( + `SELECT id + FROM chat_threads + WHERE project_id = $1 AND id = $2 + LIMIT 1`, + [input.projectId, input.threadId], + ); + if (!thread.rows[0]) throw new PassiveAuthSurfaceAttributionError(); + } + + if (!input.taskId) return; + const task = await readTaskAttribution(db, input.projectId, input.taskId); + if (!task) throw new PassiveAuthSurfaceAttributionError(); + if (task.thread_id && task.thread_id !== input.threadId) { + throw new PassiveAuthSurfaceAttributionError(); + } + const taskTargetId = readTaskTargetId(task.metadata); + if (taskTargetId && taskTargetId !== input.targetId) { + throw new PassiveAuthSurfaceAttributionError(); + } + }); +} + +async function readTaskAttribution( + db: Queryable, + projectId: string, + taskId: string, +): Promise { + const result = await db.query( + `SELECT thread_id, metadata + FROM tasks + WHERE project_id = $1 AND id = $2 + LIMIT 1`, + [projectId, taskId], + ); + return result.rows[0]; +} + +function readTaskTargetId(value: unknown): string | undefined { + let metadata = value; + if (typeof metadata === "string") { + try { + metadata = JSON.parse(metadata); + } catch { + return undefined; + } + } + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return undefined; + } + const targetId = (metadata as Record).targetId; + return typeof targetId === "string" && targetId.trim() + ? targetId.trim() + : undefined; +} + +function isStoredPassiveArtifactSource( + value: string, +): value is StoredPassiveArtifactSource { return (STORED_PASSIVE_ARTIFACT_SOURCES as readonly string[]).includes(value); } function isArtifactInPassiveTargetScope( artifact: Pick< - Awaited>>, + Awaited< + ReturnType> + >, "source" | "targetIds" | "targetScope" >, targetId: string, diff --git a/tests/integration/stored-passive-auth-surface-api.test.ts b/tests/integration/stored-passive-auth-surface-api.test.ts index 015927e0b..3d37c2197 100644 --- a/tests/integration/stored-passive-auth-surface-api.test.ts +++ b/tests/integration/stored-passive-auth-surface-api.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { POST } from "../../src/app/api/projects/[projectId]/recon/passive-auth-surface/route"; import { getProjectStore } from "../../src/server/chat/projectAdapter"; +import { withDatabase } from "../../src/server/db/client"; import { createArtifactService, createEvidenceIngestor, @@ -16,6 +17,7 @@ import { createTargetAuthorization, upsertProjectTarget, } from "../../src/server/targets"; +import { upsertResearchTasks } from "../../src/server/tasks/tracker"; describe("stored passive auth-surface API", () => { let databaseRoot: string; @@ -45,6 +47,12 @@ describe("stored passive auth-surface API", () => { const project = await store.createProject({ name: "Passive auth map" }); const otherProject = await store.createProject({ name: "Other evidence" }); const thread = await store.createThread(project.id, { title: "Map auth" }); + const otherThread = await store.createThread(project.id, { + title: "Other workflow", + }); + const foreignThread = await store.createThread(otherProject.id, { + title: "Foreign workflow", + }); await upsertProjectTarget(project.id, { id: "target-app", threadId: thread.id, @@ -59,6 +67,41 @@ describe("stored passive auth-surface API", () => { label: "Other app", locator: "https://other.example.test", }); + await upsertResearchTasks( + project.id, + [ + { + id: "task-passive-map", + threadId: thread.id, + targetId: "target-app", + title: "Map the authorized app", + }, + { + id: "task-other-thread", + threadId: otherThread.id, + targetId: "target-app", + title: "Other workflow task", + }, + { + id: "task-other-target", + threadId: thread.id, + targetId: "target-other", + title: "Other target task", + }, + ], + { threadId: thread.id }, + ); + await upsertResearchTasks( + otherProject.id, + [ + { + id: "task-foreign-project", + threadId: foreignThread.id, + title: "Foreign project task", + }, + ], + { threadId: foreignThread.id, projectScoped: true }, + ); const artifactService = createArtifactService({ storage: null }); setArtifactService(artifactService); @@ -128,6 +171,33 @@ describe("stored passive auth-surface API", () => { grantedBy: "operator", scope: { activity: "passive" }, }); + const artifactCountBeforeAttributionChecks = await countArtifacts( + project.id, + ); + const rejectedAttributions = [ + { threadId: "thread-missing" }, + { threadId: foreignThread.id }, + { threadId: thread.id, taskId: "task-missing" }, + { threadId: thread.id, taskId: "task-foreign-project" }, + { threadId: thread.id, taskId: "task-other-thread" }, + { taskId: "task-other-thread" }, + { threadId: thread.id, taskId: "task-other-target" }, + ]; + for (const attribution of rejectedAttributions) { + const rejected = await invoke(project.id, { + targetId: "target-app", + ...attribution, + artifacts: [{ artifactId: source.id }], + }); + expect(rejected.status).toBe(404); + expect(await rejected.json()).toEqual({ + error: "Passive-auth attribution was not found in this project.", + }); + expect(await countArtifacts(project.id)).toBe( + artifactCountBeforeAttributionChecks, + ); + expect(indexed).not.toHaveBeenCalled(); + } const wrongProject = await invoke(project.id, { targetId: "target-app", threadId: thread.id, @@ -208,9 +278,29 @@ describe("stored passive auth-surface API", () => { }), }), ); + const persistedAttribution = await withDatabase((db) => + db.query<{ thread_id: string | null; task_id: string | null }>( + "SELECT thread_id, task_id FROM artifacts WHERE id = $1", + [(body.artifact as { id: string }).id], + ), + ); + expect(persistedAttribution.rows[0]).toEqual({ + thread_id: thread.id, + task_id: "task-passive-map", + }); }); }); +async function countArtifacts(projectId: string) { + const result = await withDatabase((db) => + db.query<{ count: number | string }>( + "SELECT count(*) AS count FROM artifacts WHERE project_id = $1", + [projectId], + ), + ); + return Number(result.rows[0]?.count ?? 0); +} + function invoke(projectId: string, body: unknown) { return POST( new Request( From dd1cfdceea3fa7c97c0b328fd575d9a8f99bf2f3 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 02:37:45 -0400 Subject: [PATCH 2/2] Close passive attribution persistence race --- src/server/evidence/artifact-service.ts | 272 ++++++++++++++---- src/server/evidence/index.ts | 1 + src/server/recon/passive-auth-surface.ts | 1 + .../recon/stored-passive-auth-surface.ts | 72 +++-- .../stored-passive-auth-surface-api.test.ts | 89 +++++- 5 files changed, 352 insertions(+), 83 deletions(-) diff --git a/src/server/evidence/artifact-service.ts b/src/server/evidence/artifact-service.ts index bb8da9dcb..e7e1da78f 100644 --- a/src/server/evidence/artifact-service.ts +++ b/src/server/evidence/artifact-service.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { createId } from "../../lib/ids"; -import { type Queryable, withDatabase } from "../db/client"; +import { type Queryable, withDatabase, withTransaction } from "../db/client"; import { notifyProjectChanged } from "../projects/project-events"; import { readResearchRunContext } from "../research/run-context"; import { createObjectStorageClient, type ObjectStorageClient } from "../storage"; @@ -45,8 +45,16 @@ export type CreateArtifactInput = { inlineFallback?: boolean; maxInlineBytes?: number; metadata?: Record; + attributionPolicy?: "project-thread-task-target"; }; +export class ArtifactAttributionGuardError extends Error { + constructor() { + super("Artifact attribution changed before persistence."); + this.name = "ArtifactAttributionGuardError"; + } +} + export type CreateUploadedObjectArtifactInput = { projectId: string; threadId?: string; @@ -128,13 +136,10 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) { const now = config.now ?? (() => new Date()); const randomId = config.randomId ?? (() => createId("artifact")); - const tryWriteStorage = async (input: { + const prepareStorage = async (input: { projectId: string; threadId?: string; - content: string | Uint8Array; - contentType: string; name: string; - metadata?: Record; }) => { if (!storage) { return { @@ -145,20 +150,12 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) { await storage.ensureBucket(); const key = `projects/${input.projectId}/threads/${input.threadId ?? "default"}/evidence/${now().getTime()}-${shortObjectKeyId(randomId())}-${sanitizeObjectKeySegment(input.name)}`; - await storage.putObject({ - key, - body: input.content, - contentType: input.contentType, - metadata: { - projectId: input.projectId, - threadId: input.threadId ?? "", - ...(input.metadata ?? {}), - }, - }); return { storageBucket: storage.bucket, storageKey: key, - storageMode: storage.mode ?? "object-storage", + storageMode: (storage.mode ?? "object-storage") as + | NonNullable + | "object-storage", }; }; @@ -184,11 +181,9 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) { const allowInlineFallback = input.inlineFallback ?? !isBinary; const maxInlineBytes = input.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES; - const { storageBucket, storageKey, storageMode } = await tryWriteStorage({ + const { storageBucket, storageKey, storageMode } = await prepareStorage({ projectId: input.projectId, threadId: input.threadId, - content: contentForStorage, - contentType, name, }); @@ -202,47 +197,71 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) { inlineContent = contentString.slice(0, maxInlineBytes); } - const artifactId = await withDatabase(async (db: Queryable) => { - const { threadId, targetId, taskId, findingId, agentGenerated, toolRunId, metadata } = - input; - const rows = await db.query<{ id: string }>( - `INSERT INTO artifacts (project_id, thread_id, task_id, finding_id, kind, name, content_type, storage_bucket, storage_key, size_bytes, sha256, inline_content, agent_generated, tool_run_id, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb) - RETURNING id`, - [ - input.projectId, - threadId ?? null, - taskId ?? null, - findingId ?? null, - kind, - name, - contentType, - storageBucket ?? null, - storageKey ?? null, - sizeBytes, - sha256, - storageKey ? null : inlineContent, - agentGenerated ?? true, - toolRunId ?? null, - JSON.stringify({ - ...(metadata ?? {}), - ...(researchRunContext ?? {}), - source: input.source, - targetId, - targetIds: targetScope.kind === "targets" ? targetScope.targetIds : [], - targetScope: targetScope.kind === "targets" ? "targets" : "project", - storageMode: storageKey ? (storageMode ?? "object-storage") : "inline", - ...(storageKey - ? {} - : { inlineBytes: new TextEncoder().encode(inlineContent ?? "").byteLength }), - ...(storageKey || inlineContent === contentString ? {} : { inlineTruncated: true }), - sizeBytes, - createdAt: timestamp, + let wroteStorageObject = false; + let artifactId: string | null = null; + const row = { + input, + kind, + name, + contentType, + storageBucket, + storageKey, + storageMode, + sizeBytes, + sha256, + inlineContent, + contentString, + timestamp, + targetIds: targetScope.kind === "targets" ? targetScope.targetIds : [], + targetScope: targetScope.kind === "targets" ? ("targets" as const) : ("project" as const), + researchRunContext, + }; + const writeStorageObject = async () => { + if (!storageKey || !storage) return; + await storage.putObject({ + key: storageKey, + body: contentForStorage, + contentType, + metadata: { + projectId: input.projectId, + threadId: input.threadId ?? "", + }, + }); + wroteStorageObject = true; + }; + + if (input.attributionPolicy !== "project-thread-task-target") { + await writeStorageObject(); + const rows = await withDatabase((db) => insertArtifactRow(db, row)); + artifactId = rows.rows[0]?.id ?? null; + } else { + try { + artifactId = await withDatabase(async (db, database) => + withTransaction(db, async (tx) => { + if (database.backend === "postgres") { + await lockArtifactAttribution(tx, input); + } + const rows = await insertArtifactRow(tx, row); + const persistedId = rows.rows[0]?.id ?? null; + if (!persistedId) throw new ArtifactAttributionGuardError(); + await writeStorageObject(); + return persistedId; }), - ], - ); - return rows.rows[0]?.id ?? null; - }); + ); + } catch (error) { + if (wroteStorageObject && storageKey && storage) { + try { + await storage.deleteObject({ + key: storageKey, + ...(storageBucket ? { bucket: storageBucket } : {}), + }); + } catch { + // Preserve the persistence error; an orphaned object has no database or index visibility. + } + } + throw error; + } + } if (!artifactId) { throw new Error("Failed to create artifact row"); @@ -515,6 +534,141 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) { }; } +type ArtifactRowInsert = { + input: CreateArtifactInput; + kind: NonNullable; + name: string; + contentType: string; + storageBucket?: string; + storageKey?: string; + storageMode?: "s3" | "filesystem" | "object-storage"; + sizeBytes: number; + sha256: string; + inlineContent: string | null; + contentString: string; + timestamp: string; + targetIds: string[]; + targetScope: "targets" | "project"; + researchRunContext: ReturnType; +}; + +async function insertArtifactRow(db: Queryable, row: ArtifactRowInsert) { + const { + input, + kind, + name, + contentType, + storageBucket, + storageKey, + storageMode, + sizeBytes, + sha256, + inlineContent, + contentString, + timestamp, + targetIds, + targetScope, + researchRunContext, + } = row; + const values = [ + input.projectId, + input.threadId ?? null, + input.taskId ?? null, + input.findingId ?? null, + kind, + name, + contentType, + storageBucket ?? null, + storageKey ?? null, + sizeBytes, + sha256, + storageKey ? null : inlineContent, + input.agentGenerated ?? true, + input.toolRunId ?? null, + JSON.stringify({ + ...(input.metadata ?? {}), + ...(researchRunContext ?? {}), + source: input.source, + targetId: input.targetId, + targetIds, + targetScope, + storageMode: storageKey ? (storageMode ?? "object-storage") : "inline", + ...(storageKey + ? {} + : { inlineBytes: new TextEncoder().encode(inlineContent ?? "").byteLength }), + ...(storageKey || inlineContent === contentString ? {} : { inlineTruncated: true }), + sizeBytes, + createdAt: timestamp, + }), + ]; + + if (input.attributionPolicy !== "project-thread-task-target") { + return db.query<{ id: string }>( + `INSERT INTO artifacts (project_id, thread_id, task_id, finding_id, kind, name, content_type, storage_bucket, storage_key, size_bytes, sha256, inline_content, agent_generated, tool_run_id, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb) + RETURNING id`, + values, + ); + } + if (!input.targetId) throw new ArtifactAttributionGuardError(); + + return db.query<{ id: string }>( + `INSERT INTO artifacts (project_id, thread_id, task_id, finding_id, kind, name, content_type, storage_bucket, storage_key, size_bytes, sha256, inline_content, agent_generated, tool_run_id, metadata) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb + WHERE EXISTS ( + SELECT 1 FROM targets + WHERE project_id = $1 AND id = $16 + ) + AND ($2::text IS NULL OR EXISTS ( + SELECT 1 FROM chat_threads + WHERE project_id = $1 AND id = $2 + )) + AND ($3::text IS NULL OR EXISTS ( + SELECT 1 FROM tasks + WHERE project_id = $1 + AND id = $3 + AND metadata->>'targetId' = $16 + AND ((thread_id IS NULL AND $2::text IS NULL) OR thread_id = $2) + )) + RETURNING id`, + [...values, input.targetId], + ); +} + +async function lockArtifactAttribution(db: Queryable, input: CreateArtifactInput) { + if (!input.targetId) throw new ArtifactAttributionGuardError(); + const target = await db.query<{ id: string }>( + `SELECT id FROM targets + WHERE project_id = $1 AND id = $2 + FOR UPDATE`, + [input.projectId, input.targetId], + ); + if (!target.rows[0]) throw new ArtifactAttributionGuardError(); + + if (input.threadId) { + const thread = await db.query<{ id: string }>( + `SELECT id FROM chat_threads + WHERE project_id = $1 AND id = $2 + FOR UPDATE`, + [input.projectId, input.threadId], + ); + if (!thread.rows[0]) throw new ArtifactAttributionGuardError(); + } + + if (input.taskId) { + const task = await db.query<{ id: string }>( + `SELECT id FROM tasks + WHERE project_id = $1 + AND id = $2 + AND metadata->>'targetId' = $3 + AND ((thread_id IS NULL AND $4::text IS NULL) OR thread_id = $4) + FOR UPDATE`, + [input.projectId, input.taskId, input.targetId, input.threadId ?? null], + ); + if (!task.rows[0]) throw new ArtifactAttributionGuardError(); + } +} + function resolveGeneratedEvidenceScope( input: { targetId?: string; diff --git a/src/server/evidence/index.ts b/src/server/evidence/index.ts index e3c526c98..961979008 100644 --- a/src/server/evidence/index.ts +++ b/src/server/evidence/index.ts @@ -4,6 +4,7 @@ export { setArtifactService, } from "./artifact-runtime"; export { + ArtifactAttributionGuardError, type ArtifactServiceConfig, type CreateArtifactInput, type CreateArtifactResult, diff --git a/src/server/recon/passive-auth-surface.ts b/src/server/recon/passive-auth-surface.ts index 7e146d167..88a33be68 100644 --- a/src/server/recon/passive-auth-surface.ts +++ b/src/server/recon/passive-auth-surface.ts @@ -76,6 +76,7 @@ export async function persistPassiveAuthSurface( source: "passive-recon", indexForRag: true, agentGenerated: true, + attributionPolicy: "project-thread-task-target", metadata: { workflow: "passive-auth-surface-v1", rawArtifactIds: summary.rawArtifactIds, diff --git a/src/server/recon/stored-passive-auth-surface.ts b/src/server/recon/stored-passive-auth-surface.ts index 3e623550d..5ad888e7a 100644 --- a/src/server/recon/stored-passive-auth-surface.ts +++ b/src/server/recon/stored-passive-auth-surface.ts @@ -1,5 +1,9 @@ import { type Queryable, withDatabase } from "../db/client"; -import { type ArtifactServiceInstance, getArtifactService } from "../evidence"; +import { + ArtifactAttributionGuardError, + type ArtifactServiceInstance, + getArtifactService, +} from "../evidence"; import { listActiveTargetAuthorizationRows } from "../targets/authorization-ledger"; import { type PersistPassiveAuthSurfaceResult, @@ -75,7 +79,6 @@ export async function createStoredPassiveAuthSurface( dependencies: Dependencies = {}, ): Promise { assertStoredArtifactRefs(input.artifacts); - await assertPassiveAuthSurfaceAttribution(input); const activeAuthorizations = dependencies.listActiveAuthorizations ? await dependencies.listActiveAuthorizations(input.projectId) : await withDatabase((db) => @@ -89,6 +92,7 @@ export async function createStoredPassiveAuthSurface( `Target ${input.targetId} does not have an active approved authorization in this project.`, ); } + const attribution = await resolvePassiveAuthSurfaceAttribution(input); const artifactService = dependencies.artifactService ?? getArtifactService(); const readArtifactText = artifactService.readArtifactText; @@ -142,16 +146,24 @@ export async function createStoredPassiveAuthSurface( }), ); - const result = await persistPassiveAuthSurface( - { - projectId: input.projectId, - targetId: input.targetId, - ...(input.threadId ? { threadId: input.threadId } : {}), - ...(input.taskId ? { taskId: input.taskId } : {}), - artifacts, - }, - artifactService, - ); + let result: PersistPassiveAuthSurfaceResult; + try { + result = await persistPassiveAuthSurface( + { + projectId: input.projectId, + targetId: input.targetId, + ...(attribution.threadId ? { threadId: attribution.threadId } : {}), + ...(attribution.taskId ? { taskId: attribution.taskId } : {}), + artifacts, + }, + artifactService, + ); + } catch (error) { + if (error instanceof ArtifactAttributionGuardError) { + throw new PassiveAuthSurfaceAttributionError(); + } + throw error; + } return { ...result, @@ -169,16 +181,22 @@ type TaskAttributionRow = { metadata: unknown; }; -async function assertPassiveAuthSurfaceAttribution( +type ResolvedPassiveAuthSurfaceAttribution = { + threadId?: string; + taskId?: string; +}; + +async function resolvePassiveAuthSurfaceAttribution( input: Pick< CreateStoredPassiveAuthSurfaceInput, "projectId" | "targetId" | "threadId" | "taskId" >, -): Promise { - if (!input.threadId && !input.taskId) return; +): Promise { + if (!input.threadId && !input.taskId) return {}; - await withDatabase(async (db) => { - if (input.threadId) { + return withDatabase(async (db) => { + if (!input.taskId) { + if (!input.threadId) return {}; const thread = await db.query<{ id: string }>( `SELECT id FROM chat_threads @@ -187,18 +205,32 @@ async function assertPassiveAuthSurfaceAttribution( [input.projectId, input.threadId], ); if (!thread.rows[0]) throw new PassiveAuthSurfaceAttributionError(); + return { threadId: input.threadId }; } - if (!input.taskId) return; const task = await readTaskAttribution(db, input.projectId, input.taskId); if (!task) throw new PassiveAuthSurfaceAttributionError(); - if (task.thread_id && task.thread_id !== input.threadId) { + if (input.threadId !== undefined && task.thread_id !== input.threadId) { throw new PassiveAuthSurfaceAttributionError(); } const taskTargetId = readTaskTargetId(task.metadata); - if (taskTargetId && taskTargetId !== input.targetId) { + if (taskTargetId !== input.targetId) { throw new PassiveAuthSurfaceAttributionError(); } + if (task.thread_id) { + const thread = await db.query<{ id: string }>( + `SELECT id + FROM chat_threads + WHERE project_id = $1 AND id = $2 + LIMIT 1`, + [input.projectId, task.thread_id], + ); + if (!thread.rows[0]) throw new PassiveAuthSurfaceAttributionError(); + } + return { + ...(task.thread_id ? { threadId: task.thread_id } : {}), + taskId: input.taskId, + }; }); } diff --git a/tests/integration/stored-passive-auth-surface-api.test.ts b/tests/integration/stored-passive-auth-surface-api.test.ts index 3d37c2197..5c3ec79aa 100644 --- a/tests/integration/stored-passive-auth-surface-api.test.ts +++ b/tests/integration/stored-passive-auth-surface-api.test.ts @@ -13,6 +13,8 @@ import { setArtifactService, setEvidenceIngestor, } from "../../src/server/evidence"; +import { subscribeProjectChanges } from "../../src/server/projects/project-events"; +import type { ObjectStorageClient } from "../../src/server/storage"; import { createTargetAuthorization, upsertProjectTarget, @@ -88,6 +90,12 @@ describe("stored passive auth-surface API", () => { targetId: "target-other", title: "Other target task", }, + { + id: "task-race", + threadId: thread.id, + targetId: "target-app", + title: "Task changed during report persistence", + }, ], { threadId: thread.id }, ); @@ -160,7 +168,7 @@ describe("stored passive auth-surface API", () => { const unauthorized = await invoke(project.id, { targetId: "target-app", - threadId: thread.id, + threadId: "thread-missing", artifacts: [{ artifactId: source.id }], }); expect(unauthorized.status).toBe(403); @@ -174,13 +182,17 @@ describe("stored passive auth-surface API", () => { const artifactCountBeforeAttributionChecks = await countArtifacts( project.id, ); + const projectEvents = vi.fn(); + const unsubscribeProjectEvents = subscribeProjectChanges( + project.id, + projectEvents, + ); const rejectedAttributions = [ { threadId: "thread-missing" }, { threadId: foreignThread.id }, { threadId: thread.id, taskId: "task-missing" }, { threadId: thread.id, taskId: "task-foreign-project" }, { threadId: thread.id, taskId: "task-other-thread" }, - { taskId: "task-other-thread" }, { threadId: thread.id, taskId: "task-other-target" }, ]; for (const attribution of rejectedAttributions) { @@ -198,6 +210,11 @@ describe("stored passive auth-surface API", () => { ); expect(indexed).not.toHaveBeenCalled(); } + expect( + projectEvents.mock.calls.filter( + ([event]) => (event as { topic: string }).topic === "artifact", + ), + ).toHaveLength(0); const wrongProject = await invoke(project.id, { targetId: "target-app", threadId: thread.id, @@ -225,9 +242,51 @@ describe("stored passive auth-surface API", () => { }); expect(relabeled.status).toBe(400); + const guardedPutObject = vi.fn(async () => undefined); + const guardedArtifactService = createArtifactService({ + storage: createObjectStorageStub(guardedPutObject), + }); + setArtifactService({ + ...guardedArtifactService, + async createArtifact(input) { + if (input.source === "passive-recon" && input.taskId === "task-race") { + await withDatabase((db) => + db.query( + `UPDATE tasks + SET metadata = $3::jsonb + WHERE project_id = $1 AND id = $2`, + [ + project.id, + "task-race", + JSON.stringify({ targetId: "target-other" }), + ], + ), + ); + } + return guardedArtifactService.createArtifact(input); + }, + }); + const retargetedDuringPersistence = await invoke(project.id, { + targetId: "target-app", + taskId: "task-race", + artifacts: [{ artifactId: source.id }], + }); + expect(retargetedDuringPersistence.status).toBe(404); + expect(await countArtifacts(project.id)).toBe( + artifactCountBeforeAttributionChecks, + ); + expect(indexed).not.toHaveBeenCalled(); + expect(guardedPutObject).not.toHaveBeenCalled(); + expect( + projectEvents.mock.calls.filter( + ([event]) => (event as { topic: string }).topic === "artifact", + ), + ).toHaveLength(0); + unsubscribeProjectEvents(); + setArtifactService(artifactService); + const response = await invoke(project.id, { targetId: "target-app", - threadId: thread.id, taskId: "task-passive-map", artifacts: [ { artifactId: source.id }, @@ -288,7 +347,7 @@ describe("stored passive auth-surface API", () => { thread_id: thread.id, task_id: "task-passive-map", }); - }); + }, 15_000); }); async function countArtifacts(projectId: string) { @@ -317,3 +376,25 @@ function invoke(projectId: string, body: unknown) { { params: Promise.resolve({ projectId }) }, ); } + +function createObjectStorageStub( + putObject: ObjectStorageClient["putObject"], +): ObjectStorageClient { + return { + bucket: "evidence", + mode: "filesystem", + putObject, + ensureBucket: vi.fn(async () => undefined), + getObject: vi.fn(async () => ({})), + readObjectBytes: vi.fn(async () => new Uint8Array()), + deleteObject: vi.fn(async () => undefined), + createPresignedPutObjectUrl: vi.fn(async () => ({ + url: "http://localhost/upload", + bucket: "evidence", + key: "test", + method: "PUT" as const, + headers: {}, + expiresAt: new Date().toISOString(), + })), + }; +}