fix(audit): trace sensitive reads and portability egress

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-27 14:54:25 +00:00
1 parent aed4478c81
commit 048e73c4a3
11 files changed
+165 -5

No files matched your search

@@ -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();
@@ -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 () => {
+20
View File
@@ -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<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId" | "agentApiKeyId">,
) {
const actor = getActorInfo(req);
await logActivity(db, {
...input,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
});
}
+2
View File
@@ -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);
});
+14 -2
View File
@@ -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);
});
+8
View File
@@ -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);
});
+7 -1
View File
@@ -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);
});
+3
View File
@@ -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,
+9 -2
View File
@@ -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) => {
+2
View File
@@ -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;
+36
View File
@@ -82,6 +82,24 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"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<string, string> = {
@@ -129,6 +147,24 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"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<string, unknown> | null {