From 048e73c4a3a88fb1168292257c29ae47b04306cd Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 17 Jul 2026 02:12:13 +0000 Subject: [PATCH] fix(audit): trace sensitive reads and portability egress Co-Authored-By: Paperclip --- .../activity-log-responsible-user.test.ts | 49 +++++++++++++++++++ server/src/__tests__/activity-routes.test.ts | 15 ++++++ server/src/routes/activity-audit.ts | 20 ++++++++ server/src/routes/activity.ts | 2 + server/src/routes/agents.ts | 16 +++++- server/src/routes/companies.ts | 8 +++ server/src/routes/company-skills.ts | 8 ++- server/src/routes/issues.ts | 3 ++ server/src/routes/tool-access.ts | 11 ++++- server/src/routes/tool-gateway.ts | 2 + ui/src/lib/activity-format.ts | 36 ++++++++++++++ 11 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 server/src/routes/activity-audit.ts diff --git a/server/src/__tests__/activity-log-responsible-user.test.ts b/server/src/__tests__/activity-log-responsible-user.test.ts index d631ff15fbc..12128dc8cef 100644 --- a/server/src/__tests__/activity-log-responsible-user.test.ts +++ b/server/src/__tests__/activity-log-responsible-user.test.ts @@ -182,6 +182,55 @@ describeEmbeddedPostgres("logActivity responsible-user stamping", () => { await tempDb?.cleanup(); }); + it("persists agent, run, and responsible-user attribution for an in-run read", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Sensitive Read Audit", + issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + responsibleUserId: "responsible-user", + }); + + await logActivity(db, activityInput({ + companyId, + actorId: agentId, + agentId, + runId, + action: "activity_log.read", + entityType: "company", + entityId: companyId, + })); + + const row = await db + .select({ agentId: activityLog.agentId, runId: activityLog.runId, responsibleUserId: activityLog.responsibleUserId }) + .from(activityLog) + .where(eq(activityLog.companyId, companyId)) + .then((rows) => rows[0]); + + expect(row).toEqual({ agentId, runId, responsibleUserId: "responsible-user" }); + }); + it("persists API-key attribution for an out-of-run action", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 97a23fb2880..aee2734f51a 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -23,6 +23,12 @@ const mockAccessService = vi.hoisted(() => ({ decide: vi.fn(), })); +const mockLogRouteActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../routes/activity-audit.js", () => ({ + logRouteActivity: mockLogRouteActivity, +})); + vi.mock("../services/activity.js", () => ({ activityService: () => mockActivityService, normalizeActivityLimit: (limit: number | undefined) => { @@ -98,6 +104,8 @@ describe.sequential("activity routes", () => { for (const mock of Object.values(mockHeartbeatService)) mock.mockReset(); for (const mock of Object.values(mockIssueService)) mock.mockReset(); mockAccessService.decide.mockReset(); + mockLogRouteActivity.mockReset(); + mockLogRouteActivity.mockResolvedValue(undefined); mockAccessService.decide.mockResolvedValue({ allowed: true, action: "company_scope:read", @@ -120,6 +128,13 @@ describe.sequential("activity routes", () => { entityId: undefined, limit: 100, }); + expect(mockLogRouteActivity).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + companyId: "company-1", + action: "activity_log.read", + entityType: "company", + entityId: "company-1", + details: { resultCount: 0 }, + }); }); it("caps requested company activity list limits", async () => { diff --git a/server/src/routes/activity-audit.ts b/server/src/routes/activity-audit.ts new file mode 100644 index 00000000000..3113536a7c2 --- /dev/null +++ b/server/src/routes/activity-audit.ts @@ -0,0 +1,20 @@ +import type { Request } from "express"; +import type { Db } from "@paperclipai/db"; +import { logActivity, type LogActivityInput } from "../services/activity-log.js"; +import { getActorInfo } from "./authz.js"; + +export async function logRouteActivity( + db: Db, + req: Request, + input: Omit, +) { + const actor = getActorInfo(req); + await logActivity(db, { + ...input, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + }); +} diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 7fe6894cbf8..7313e83adc0 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -7,6 +7,7 @@ import { activityService, normalizeActivityLimit } from "../services/activity.js import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js"; import { accessService, heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; +import { logRouteActivity } from "./activity-audit.js"; const createActivitySchema = z.object({ actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"), @@ -85,6 +86,7 @@ export function activityRoutes(db: Db) { limit: normalizeActivityLimit(Number(req.query.limit)), }; const result = await svc.list(filters); + await logRouteActivity(db, req, { companyId, action: "activity_log.read", entityType: "company", entityId: companyId, details: { resultCount: result.length } }); res.json(result); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 32fbf974763..47579f0dc24 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,4 +1,5 @@ import { Router, type Request, type Response } from "express"; +import { logRouteActivity } from "./activity-audit.js"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; @@ -2091,6 +2092,7 @@ export function agentRoutes( const companyId = req.params.companyId as string; await assertCanReadConfigurations(req, companyId); const rows = await svc.list(companyId); + await logRouteActivity(db, req, { companyId, action: "agent.configuration_read", entityType: "company", entityId: companyId, details: { agentCount: rows.length } }); res.json(rows.map((row) => redactAgentConfiguration(row))); }); @@ -2230,6 +2232,7 @@ export function agentRoutes( return; } await assertCanReadConfigurations(req, agent.companyId); + await logRouteActivity(db, req, { companyId: agent.companyId, action: "agent.configuration_read", entityType: "agent", entityId: agent.id }); res.json(redactAgentConfiguration(agent)); }); @@ -2242,6 +2245,7 @@ export function agentRoutes( } await assertCanReadConfigurations(req, agent.companyId); const revisions = await svc.listConfigRevisions(id); + await logRouteActivity(db, req, { companyId: agent.companyId, action: "agent.config_revisions_read", entityType: "agent", entityId: agent.id, details: { revisionCount: revisions.length } }); res.json(revisions.map((revision) => redactConfigRevision(revision))); }); @@ -2259,6 +2263,7 @@ export function agentRoutes( res.status(404).json({ error: "Revision not found" }); return; } + await logRouteActivity(db, req, { companyId: agent.companyId, action: "agent.config_revision_read", entityType: "agent_config_revision", entityId: revisionId, details: { agentId: agent.id } }); res.json(redactConfigRevision(revision)); }); @@ -2795,7 +2800,9 @@ export function agentRoutes( const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; await assertCanReadAgent(req, existing); - res.json(await instructions.getBundle(existing)); + const bundle = await instructions.getBundle(existing); + await logRouteActivity(db, req, { companyId: existing.companyId, action: "agent.instructions_read", entityType: "agent", entityId: existing.id }); + res.json(bundle); }); router.patch("/agents/:id/instructions-bundle", validate(updateAgentInstructionsBundleSchema), async (req, res) => { @@ -2856,7 +2863,9 @@ export function agentRoutes( return; } - res.json(await instructions.readFile(existing, relativePath)); + const file = await instructions.readFile(existing, relativePath); + await logRouteActivity(db, req, { companyId: existing.companyId, action: "agent.instructions_file_read", entityType: "agent", entityId: existing.id, details: { path: relativePath } }); + res.json(file); }); router.put("/agents/:id/instructions-bundle/file", validate(upsertAgentInstructionsFileSchema), async (req, res) => { @@ -3337,6 +3346,7 @@ export function agentRoutes( return; } const keys = await svc.listKeys(id); + await logRouteActivity(db, req, { companyId: agent.companyId, action: "agent.key_metadata_read", entityType: "agent", entityId: agent.id, details: { keyCount: keys.length } }); res.json(keys); }); @@ -3793,6 +3803,8 @@ export function agentRoutes( limitBytes, }); + await logRouteActivity(db, req, { companyId: run.companyId, action: "heartbeat.log_read", entityType: "heartbeat_run", entityId: runId, details: { offset: Number.isFinite(offset) ? offset : 0, limitBytes } }); + res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 688662c1fc4..1c5ee833434 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -33,6 +33,7 @@ import { import type { StorageService } from "../storage/types.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { COMPANY_IMPORT_ROUTE_PATH } from "./company-import-paths.js"; +import { logRouteActivity } from "./activity-audit.js"; export function companyRoutes(db: Db, storage?: StorageService) { const router = Router(); @@ -247,6 +248,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { await assertSameCompanyCeoAgentOrBoard(req, companyId, "company exports"); const body = companyPortabilityExportSchema.parse(req.body); const result = await portability.exportBundle(companyId, body); + await logRouteActivity(db, req, { companyId, action: "company.exported", entityType: "company", entityId: companyId }); res.json(result); }); @@ -255,6 +257,9 @@ export function companyRoutes(db: Db, storage?: StorageService) { const body = companyPortabilityPreviewSchema.parse(req.body); assertImportTargetAccess(req, body.target); const preview = await portability.previewImport(body); + if (body.target.mode === "existing_company") { + await logRouteActivity(db, req, { companyId: body.target.companyId, action: "company.import_previewed", entityType: "company", entityId: body.target.companyId }); + } res.json(preview); }); @@ -307,6 +312,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { await assertSameCompanyCeoAgentOrBoard(req, companyId, "company exports"); const body = companyPortabilityExportSchema.parse(req.body); const preview = await portability.previewExport(companyId, body); + await logRouteActivity(db, req, { companyId, action: "company.export_previewed", entityType: "company", entityId: companyId }); res.json(preview); }); @@ -315,6 +321,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { await assertSameCompanyCeoAgentOrBoard(req, companyId, "company exports"); const body = companyPortabilityExportSchema.parse(req.body); const result = await portability.exportBundle(companyId, body); + await logRouteActivity(db, req, { companyId, action: "company.exported", entityType: "company", entityId: companyId }); res.json(result); }); @@ -332,6 +339,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { mode: "agent_safe", sourceCompanyId: companyId, }); + await logRouteActivity(db, req, { companyId, action: "company.import_previewed", entityType: "company", entityId: companyId }); res.json(preview); }); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index bcc3151ffb1..c776d91cf95 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -33,6 +33,7 @@ import { logActivity, } from "../services/index.js"; import { isGitRepoSkillImportSource, parseSkillImportSourceInput } from "../services/company-skills.js"; +import { logRouteActivity } from "./activity-audit.js"; import { getCatalogSkillOrThrow, listCatalogSkillsOrEmpty, @@ -294,7 +295,11 @@ export function companySkillRoutes(db: Db) { assertAuthenticated(req); const catalogRef = firstQueryString(req.query.ref) ?? (req.params.catalogId as string); const relativePath = firstQueryString(req.query.path) ?? "SKILL.md"; - res.json(await readCatalogSkillFile(catalogRef, relativePath)); + const result = await readCatalogSkillFile(catalogRef, relativePath); + if (req.actor.companyId) { + await logRouteActivity(db, req, { companyId: req.actor.companyId, action: "skill.source_read", entityType: "skill_catalog", entityId: catalogRef, details: { path: relativePath } }); + } + res.json(result); }); router.get("/skills/catalog/:catalogId", async (req, res) => { @@ -957,6 +962,7 @@ export function companySkillRoutes(db: Db) { res.status(404).json({ error: "Skill not found" }); return; } + await logRouteActivity(db, req, { companyId, action: "skill.source_read", entityType: "company_skill", entityId: skillId, details: { path: relativePath } }); res.json(result); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6af7134cda2..e1955c8fc79 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { logRouteActivity } from "./activity-audit.js"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import { z } from "zod"; @@ -10718,6 +10719,8 @@ export function issueRoutes( } if (!(await assertIssueReadAllowed(req, res, issue))) return; + await logRouteActivity(db, req, { companyId: attachment.companyId, action: "attachment.downloaded", entityType: "issue_attachment", entityId: attachment.id, issueId: attachment.issueId, details: { byteSize: attachment.byteSize, contentType: attachment.contentType } }); + const contentLength = attachment.byteSize; const range = parseAttachmentRangeHeader( typeof req.headers.range === "string" ? req.headers.range : undefined, diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 4469846a1e0..c5cf85be5e3 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -1,4 +1,5 @@ import { Router, type Request } from "express"; +import { logRouteActivity } from "./activity-audit.js"; import type { Db } from "@paperclipai/db"; import { agents, companies } from "@paperclipai/db"; import { eq } from "drizzle-orm"; @@ -560,6 +561,7 @@ export function toolAccessRoutes( const connection = await svc.getConnection(req.params.connectionId as string); if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found"); assertCompanyAccess(req, connection.companyId); + await logRouteActivity(db, req, { companyId: connection.companyId, action: "tool_connection.read", entityType: "tool_connection", entityId: connection.id }); res.json(connection); }); @@ -730,6 +732,7 @@ export function toolAccessRoutes( connectionId: connection.id, actionRequestId: req.params.actionRequestId as string, }); + await logRouteActivity(db, req, { companyId: connection.companyId, action: "tool.test_call_read", entityType: "tool_action_request", entityId: req.params.actionRequestId as string, details: { connectionId: connection.id } }); res.json(status); } catch (error) { if (!sendToolGatewayError(res, error)) throw error; @@ -860,7 +863,9 @@ export function toolAccessRoutes( assertCompanyAccess(req, existing.companyId); const limitRaw = Number(req.query.limit ?? 20); const limit = Number.isFinite(limitRaw) ? limitRaw : 20; - res.json(await svc.listConnectionActivity(existing.id, existing.companyId, limit)); + const activity = await svc.listConnectionActivity(existing.id, existing.companyId, limit); + await logRouteActivity(db, req, { companyId: existing.companyId, action: "tool_connection.activity_read", entityType: "tool_connection", entityId: existing.id }); + res.json(activity); }); router.get("/companies/:companyId/tools/profiles", async (req, res) => { @@ -1114,7 +1119,9 @@ export function toolAccessRoutes( assertBoard(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - res.json(await svc.getRunDecisionLookup(companyId, req.params.runId as string)); + const decisions = await svc.getRunDecisionLookup(companyId, req.params.runId as string); + await logRouteActivity(db, req, { companyId, action: "tool.decision_read", entityType: "heartbeat_run", entityId: req.params.runId as string }); + res.json(decisions); }); router.get("/companies/:companyId/tools/trust-rules", async (req, res) => { diff --git a/server/src/routes/tool-gateway.ts b/server/src/routes/tool-gateway.ts index 76a91c54a3f..226fa96b82e 100644 --- a/server/src/routes/tool-gateway.ts +++ b/server/src/routes/tool-gateway.ts @@ -12,6 +12,7 @@ import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } fr import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; import { forbidden, HttpError } from "../errors.js"; import { accessService } from "../services/index.js"; +import { logRouteActivity } from "./activity-audit.js"; const TOOL_GATEWAY_ACTIONS = [ "tool_gateway.session_created", @@ -650,6 +651,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { const windowFilter = typeof req.query.window === "string" ? req.query.window.trim() : "24h"; const searchRaw = typeof req.query.search === "string" ? req.query.search.trim() : null; const cursorRaw = typeof req.query.cursor === "string" ? req.query.cursor.trim() : null; + await logRouteActivity(db, req, { companyId, action: "tool.audit_read", entityType: "company", entityId: companyId, details: { limit } }); if (appFilter && !uuidPattern.test(appFilter)) { res.status(400).json({ error: "app must be an applicationId or connectionId UUID" }); return; diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index f0fee3dbda6..1aaa00a153f 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -82,6 +82,24 @@ const ACTIVITY_ROW_VERBS: Record = { "company.archived": "archived", "company.reactivated": "reactivated", "company.budget_updated": "updated budget for", + "company.exported": "exported", + "company.export_previewed": "previewed an export of", + "company.import_previewed": "previewed an import into", + "attachment.downloaded": "downloaded", + "agent.configuration_read": "read configuration for", + "agent.config_revisions_read": "read configuration history for", + "agent.config_revision_read": "read a configuration revision for", + "agent.instructions_read": "read instructions for", + "agent.instructions_file_read": "read an instructions file for", + "agent.key_metadata_read": "read API key metadata for", + "heartbeat.log_read": "read logs for", + "skill.source_read": "read source for", + "tool_connection.read": "read", + "tool_connection.activity_read": "read activity for", + "tool.test_call_read": "read a tool test call for", + "tool.decision_read": "read tool decisions for", + "tool.audit_read": "read tool audit history for", + "activity_log.read": "read activity for", }; const ISSUE_ACTIVITY_LABELS: Record = { @@ -129,6 +147,24 @@ const ISSUE_ACTIVITY_LABELS: Record = { "approval.created": "requested approval", "approval.approved": "approved", "approval.rejected": "rejected", + "company.exported": "exported company data", + "company.export_previewed": "previewed a company export", + "company.import_previewed": "previewed a company import", + "attachment.downloaded": "downloaded an attachment", + "agent.configuration_read": "read agent configuration", + "agent.config_revisions_read": "read agent configuration history", + "agent.config_revision_read": "read an agent configuration revision", + "agent.instructions_read": "read agent instructions", + "agent.instructions_file_read": "read an agent instructions file", + "agent.key_metadata_read": "read agent API key metadata", + "heartbeat.log_read": "read heartbeat logs", + "skill.source_read": "read skill source", + "tool_connection.read": "read a tool connection", + "tool_connection.activity_read": "read tool connection activity", + "tool.test_call_read": "read a tool test call", + "tool.decision_read": "read tool decisions", + "tool.audit_read": "read the tool audit log", + "activity_log.read": "read the activity log", }; function asRecord(value: unknown): Record | null {