mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(server): add agent action audit API
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b07b2994cc
commit
1fc365a784
5 files changed
+366
-7
No files matched your search
@@ -918,6 +918,7 @@ export const PERMISSION_KEYS = [
|
||||
"tools:manage_connections",
|
||||
"tools:manage_profiles",
|
||||
"tools:view_audit",
|
||||
"audit:view_agent_actions",
|
||||
"tools:use",
|
||||
"tools:manage_runtime",
|
||||
"inbox:manage",
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
documents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
type Db = ReturnType<typeof createDb>;
|
||||
|
||||
async function createApp(db: Db, actor: Express.Request["actor"]) {
|
||||
const { activityRoutes } = await import("../routes/activity.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", activityRoutes(db));
|
||||
app.use((error: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(error.status ?? 500).json({ error: error.message ?? "Internal server error" });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describePostgres("agent action audit routes", () => {
|
||||
let db!: Db;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-action-audit-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => tempDb?.cleanup());
|
||||
|
||||
async function seed() {
|
||||
const company = await db.insert(companies).values({
|
||||
name: "Audit Company",
|
||||
issuePrefix: `AU${randomUUID().slice(0, 6).toUpperCase()}`,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const [agent, otherAgent] = await db.insert(agents).values([1, 2].map((index) => ({
|
||||
companyId: company.id,
|
||||
name: `Audit Agent ${index}`,
|
||||
role: "engineer",
|
||||
status: "active" as const,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
}))).returning();
|
||||
const issue = await db.insert(issues).values({
|
||||
companyId: company.id,
|
||||
identifier: `${company.issuePrefix}-1`,
|
||||
title: "Audit target",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const comment = await db.insert(issueComments).values({
|
||||
companyId: company.id,
|
||||
issueId: issue.id,
|
||||
authorAgentId: agent.id,
|
||||
body: "A useful comment excerpt for the audit feed.",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const document = await db.insert(documents).values({
|
||||
companyId: company.id,
|
||||
title: "Plan",
|
||||
latestBody: "Plan body",
|
||||
createdByAgentId: agent.id,
|
||||
updatedByAgentId: agent.id,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const issueDocument = await db.insert(issueDocuments).values({
|
||||
companyId: company.id,
|
||||
issueId: issue.id,
|
||||
documentId: document.id,
|
||||
key: "plan",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const run = await db.insert(heartbeatRuns).values({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
responsibleUserId: "legacy-user",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const base = new Date("2026-07-17T00:00:00.000Z");
|
||||
await db.insert(activityLog).values([
|
||||
{ companyId: company.id, actorType: "agent", actorId: agent.id, action: "issue.comment.created", entityType: "issue_comment", entityId: comment.id, agentId: agent.id, runId: run.id, responsibleUserId: null, createdAt: new Date(base.getTime() + 3000) },
|
||||
{ companyId: company.id, actorType: "system", actorId: "system", action: "issue.document.updated", entityType: "issue_document", entityId: issueDocument.id, agentId: agent.id, runId: run.id, responsibleUserId: "direct-user", createdAt: new Date(base.getTime() + 2000) },
|
||||
{ companyId: company.id, actorType: "agent", actorId: otherAgent.id, action: "issue.updated", entityType: "issue", entityId: issue.id, agentId: otherAgent.id, responsibleUserId: "other-user", createdAt: new Date(base.getTime() + 1000) },
|
||||
]);
|
||||
return { company, agent, otherAgent, issue, comment, issueDocument, run };
|
||||
}
|
||||
|
||||
it("denies agents and board users without the audit permission", async () => {
|
||||
const { company, agent } = await seed();
|
||||
const agentResponse = await request(await createApp(db, {
|
||||
type: "agent", agentId: agent.id, companyId: company.id, runId: null, source: "agent_jwt",
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions`);
|
||||
expect(agentResponse.status).toBe(403);
|
||||
|
||||
const boardResponse = await request(await createApp(db, {
|
||||
type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false,
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions`);
|
||||
expect(boardResponse.status).toBe(403);
|
||||
expect(boardResponse.body.error).toContain("audit:view_agent_actions");
|
||||
});
|
||||
|
||||
it("paginates, filters, enriches entities, and falls back to the run responsible user", async () => {
|
||||
const { company, agent, otherAgent, issue, comment, issueDocument, run } = await seed();
|
||||
const app = await createApp(db, { type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false });
|
||||
const first = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?limit=1`);
|
||||
expect(first.status, JSON.stringify(first.body)).toBe(200);
|
||||
expect(first.body.items).toHaveLength(1);
|
||||
expect(first.body.items[0].responsibleUserId).toBe("legacy-user");
|
||||
expect(first.body.items[0].entity.comment).toEqual({ id: comment.id, excerpt: "A useful comment excerpt for the audit feed." });
|
||||
expect(first.body.items[0].entity.issue).toMatchObject({ id: issue.id, identifier: issue.identifier, title: issue.title });
|
||||
expect(first.body.nextCursor).toEqual(expect.any(String));
|
||||
|
||||
const second = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?limit=1&cursor=${encodeURIComponent(first.body.nextCursor)}`);
|
||||
expect(second.body.items[0].entity.document).toEqual({ id: expect.any(String), key: "plan" });
|
||||
|
||||
const cases = [
|
||||
[`agentId=${agent.id}`, 2],
|
||||
["responsibleUserId=legacy-user", 1],
|
||||
[`runId=${run.id}`, 2],
|
||||
["entityType=issue_document", 1],
|
||||
[`entityId=${issueDocument.id}`, 1],
|
||||
["action=issue.comment", 1],
|
||||
["actorType=system", 1],
|
||||
["from=2026-07-17T00%3A00%3A01.500Z&to=2026-07-17T00%3A00%3A02.500Z", 1],
|
||||
[`agentId=${otherAgent.id}`, 1],
|
||||
] as const;
|
||||
for (const [query, count] of cases) {
|
||||
const response = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?${query}`);
|
||||
expect(response.status, `${query}: ${JSON.stringify(response.body)}`).toBe(200);
|
||||
expect(response.body.items, query).toHaveLength(count);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a signed-in board user with the explicit permission", async () => {
|
||||
const { company } = await seed();
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id, principalType: "user", principalId: "reader", status: "active", membershipRole: "viewer",
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id, principalType: "user", principalId: "reader", permissionKey: "audit:view_agent_actions", scope: null, grantedByUserId: null,
|
||||
});
|
||||
const response = await request(await createApp(db, {
|
||||
type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false,
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions`);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.body.items).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ 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 { forbidden } from "../errors.js";
|
||||
import { agentActionAuditService } from "../services/agent-action-audit.js";
|
||||
|
||||
const createActivitySchema = z.object({
|
||||
actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"),
|
||||
@@ -18,12 +20,35 @@ const createActivitySchema = z.object({
|
||||
details: z.record(z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
const agentActionAuditQuerySchema = z.object({
|
||||
agentId: z.string().uuid().optional(),
|
||||
responsibleUserId: z.string().min(1).optional(),
|
||||
runId: z.string().uuid().optional(),
|
||||
entityType: z.string().min(1).optional(),
|
||||
entityId: z.string().min(1).optional(),
|
||||
action: z.string().min(1).optional(),
|
||||
actorType: z.enum(["agent", "user", "system", "plugin"]).optional(),
|
||||
from: z.coerce.date().optional(),
|
||||
to: z.coerce.date().optional(),
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
|
||||
export function activityRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = activityService(db);
|
||||
const access = accessService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const issueSvc = issueService(db);
|
||||
const agentAudit = agentActionAuditService(db);
|
||||
|
||||
async function assertAgentAuditPermission(req: import("express").Request, companyId: string) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
if (req.actor.userId && await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions")) return;
|
||||
throw forbidden("Missing permission: audit:view_agent_actions");
|
||||
}
|
||||
|
||||
async function assertCompanyScopeReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
|
||||
const decision = await access.decide({
|
||||
@@ -88,6 +113,21 @@ export function activityRoutes(db: Db) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/audit/agent-actions", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertAgentAuditPermission(req, companyId);
|
||||
const query = agentActionAuditQuerySchema.parse(req.query);
|
||||
try {
|
||||
res.json(await agentAudit.list({ companyId, ...query }));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "Invalid audit cursor") {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
|
||||
@@ -65,6 +65,14 @@ export interface LogActivityInput {
|
||||
details?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function redactActivityDetails(db: Db, details: Record<string, unknown> | null) {
|
||||
if (!details) return null;
|
||||
const currentUserRedactionOptions = {
|
||||
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
|
||||
};
|
||||
return redactCurrentUserValue(sanitizeRecord(details), currentUserRedactionOptions);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
@@ -125,13 +133,7 @@ export async function resolveResponsibleUserIdForActivity(db: Db, input: LogActi
|
||||
}
|
||||
|
||||
export async function logActivity(db: Db, input: LogActivityInput) {
|
||||
const currentUserRedactionOptions = {
|
||||
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
|
||||
};
|
||||
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
|
||||
const redactedDetails = sanitizedDetails
|
||||
? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions)
|
||||
: null;
|
||||
const redactedDetails = await redactActivityDetails(db, input.details ?? null);
|
||||
const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input);
|
||||
await db.insert(activityLog).values({
|
||||
companyId: input.companyId,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { and, desc, eq, gte, inArray, isNotNull, lt, lte, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, heartbeatRuns, issueComments, issueDocuments, issues } from "@paperclipai/db";
|
||||
import { redactActivityDetails } from "./activity-log.js";
|
||||
|
||||
export interface AgentActionAuditFilters {
|
||||
companyId: string;
|
||||
agentId?: string;
|
||||
responsibleUserId?: string;
|
||||
runId?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
action?: string;
|
||||
actorType?: "agent" | "user" | "system" | "plugin";
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
type CursorValue = { createdAt: string; id: string };
|
||||
|
||||
function decodeCursor(cursor: string | undefined): CursorValue | null {
|
||||
if (!cursor) return null;
|
||||
try {
|
||||
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as CursorValue;
|
||||
const createdAt = new Date(value.createdAt);
|
||||
if (!value.id || Number.isNaN(createdAt.getTime())) return null;
|
||||
return { createdAt: createdAt.toISOString(), id: value.id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCursor(value: CursorValue) {
|
||||
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function excerpt(value: string, maxLength = 280) {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export function agentActionAuditService(db: Db) {
|
||||
return {
|
||||
list: async (filters: AgentActionAuditFilters) => {
|
||||
const cursor = decodeCursor(filters.cursor);
|
||||
if (filters.cursor && !cursor) throw new Error("Invalid audit cursor");
|
||||
const effectiveResponsibleUserId = sql<string | null>`coalesce(${activityLog.responsibleUserId}, ${heartbeatRuns.responsibleUserId})`;
|
||||
const conditions = [eq(activityLog.companyId, filters.companyId), isNotNull(activityLog.agentId)];
|
||||
if (filters.agentId) conditions.push(eq(activityLog.agentId, filters.agentId));
|
||||
if (filters.responsibleUserId) conditions.push(eq(effectiveResponsibleUserId, filters.responsibleUserId));
|
||||
if (filters.runId) conditions.push(eq(activityLog.runId, filters.runId));
|
||||
if (filters.entityType) conditions.push(eq(activityLog.entityType, filters.entityType));
|
||||
if (filters.entityId) conditions.push(eq(activityLog.entityId, filters.entityId));
|
||||
if (filters.action) conditions.push(sql<boolean>`starts_with(${activityLog.action}, ${filters.action})`);
|
||||
if (filters.actorType) conditions.push(eq(activityLog.actorType, filters.actorType));
|
||||
if (filters.from) conditions.push(gte(activityLog.createdAt, filters.from));
|
||||
if (filters.to) conditions.push(lte(activityLog.createdAt, filters.to));
|
||||
if (cursor) {
|
||||
const cursorDate = new Date(cursor.createdAt);
|
||||
conditions.push(or(
|
||||
lt(activityLog.createdAt, cursorDate),
|
||||
and(eq(activityLog.createdAt, cursorDate), lt(activityLog.id, cursor.id)),
|
||||
)!);
|
||||
}
|
||||
|
||||
const rows = await db.select({
|
||||
id: activityLog.id,
|
||||
companyId: activityLog.companyId,
|
||||
actorType: activityLog.actorType,
|
||||
actorId: activityLog.actorId,
|
||||
action: activityLog.action,
|
||||
entityType: activityLog.entityType,
|
||||
entityId: activityLog.entityId,
|
||||
agentId: activityLog.agentId,
|
||||
runId: activityLog.runId,
|
||||
responsibleUserId: effectiveResponsibleUserId,
|
||||
details: activityLog.details,
|
||||
createdAt: activityLog.createdAt,
|
||||
}).from(activityLog).leftJoin(heartbeatRuns, and(
|
||||
eq(heartbeatRuns.companyId, activityLog.companyId),
|
||||
eq(heartbeatRuns.id, activityLog.runId),
|
||||
)).where(and(...conditions)).orderBy(desc(activityLog.createdAt), desc(activityLog.id)).limit(filters.limit + 1);
|
||||
|
||||
const page = rows.slice(0, filters.limit);
|
||||
const entityIds = [...new Set(page.map((row) => row.entityId))];
|
||||
const commentRows = entityIds.length === 0 ? [] : await db.select({
|
||||
id: issueComments.id, body: issueComments.body, issueId: issues.id, identifier: issues.identifier, title: issues.title,
|
||||
}).from(issueComments).innerJoin(issues, and(eq(issues.id, issueComments.issueId), eq(issues.companyId, filters.companyId)))
|
||||
.where(and(eq(issueComments.companyId, filters.companyId), inArray(issueComments.id, entityIds)));
|
||||
const issueRows = entityIds.length === 0 ? [] : await db.select({
|
||||
id: issues.id, identifier: issues.identifier, title: issues.title,
|
||||
}).from(issues).where(and(eq(issues.companyId, filters.companyId), inArray(issues.id, entityIds)));
|
||||
const documentRows = entityIds.length === 0 ? [] : await db.select({
|
||||
id: issueDocuments.id, documentId: issueDocuments.documentId, key: issueDocuments.key,
|
||||
issueId: issues.id, identifier: issues.identifier, title: issues.title,
|
||||
}).from(issueDocuments).innerJoin(issues, and(eq(issues.id, issueDocuments.issueId), eq(issues.companyId, filters.companyId)))
|
||||
.where(and(eq(issueDocuments.companyId, filters.companyId), or(
|
||||
inArray(issueDocuments.id, entityIds), inArray(issueDocuments.documentId, entityIds),
|
||||
)));
|
||||
|
||||
const comments = new Map(commentRows.map((row) => [row.id, row]));
|
||||
const issueMap = new Map(issueRows.map((row) => [row.id, row]));
|
||||
const documents = new Map<string, (typeof documentRows)[number]>();
|
||||
for (const row of documentRows) {
|
||||
documents.set(row.id, row);
|
||||
documents.set(row.documentId, row);
|
||||
}
|
||||
|
||||
const items = await Promise.all(page.map(async (row) => {
|
||||
const comment = comments.get(row.entityId);
|
||||
const issue = issueMap.get(row.entityId);
|
||||
const document = documents.get(row.entityId);
|
||||
const issueSnippet = comment
|
||||
? { id: comment.issueId, identifier: comment.identifier, title: comment.title }
|
||||
: document
|
||||
? { id: document.issueId, identifier: document.identifier, title: document.title }
|
||||
: issue ? { id: issue.id, identifier: issue.identifier, title: issue.title } : null;
|
||||
return {
|
||||
...row,
|
||||
details: await redactActivityDetails(db, row.details),
|
||||
entity: {
|
||||
issue: issueSnippet,
|
||||
comment: comment ? { id: comment.id, excerpt: excerpt(comment.body) } : null,
|
||||
document: document ? { id: document.documentId, key: document.key } : null,
|
||||
},
|
||||
};
|
||||
}));
|
||||
const last = page.at(-1);
|
||||
return {
|
||||
items,
|
||||
nextCursor: rows.length > filters.limit && last
|
||||
? encodeCursor({ createdAt: last.createdAt.toISOString(), id: last.id })
|
||||
: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user