mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators need one activity feed for human, agent, plugin, and system changes > - The existing audit endpoint returns only rows that have agent attribution > - The full audit view also requires a dedicated permission > - This pull request adds an explicit all-actors scope with basic and privileged access tiers > - The benefit is that company members can inspect the shared activity history while sensitive attribution and export controls stay protected ## Linked Issues or Issue Description **What existing behavior does this improve?** The company audit activity endpoint and the board audit route. **Subsystem affected** Server REST API and board UI routing/API contracts. **Current behavior** The agent-action audit endpoint excludes activity without an agent ID. It also rejects company members who do not have the full audit permission. **Proposed behavior** Callers can opt into `actorScope=all`. A company member receives all actor kinds with sensitive attribution fields removed. A permitted board user receives complete rows and can use attribution filters. The default scope and CSV permission remain unchanged. **Reason and benefit** The board needs one chronological activity source for user, agent, plugin, and system actions. A two-tier response keeps the feed useful without widening access to detailed attribution or export capabilities. **Breaking changes** None. The endpoint keeps the existing agent-only scope and permission behavior by default. ## What Changed - Added `actorScope=all` to the unified audit query and included activity from every actor type. - Added a company-readable basic tier that removes run, responsible-user, agent, and details attribution. - Kept attribution filters and CSV export behind `audit:view_agent_actions`. - Added route and integration coverage for basic readers, permitted readers, pagination, filter denial, and all actor kinds. - Added the missing unprefixed `/audit` redirect and company route classification. ## Verification - `pnpm exec vitest run server/src/__tests__/activity-routes.test.ts server/src/__tests__/agent-action-audit-routes.test.ts ui/src/lib/company-routes.test.ts --reporter=verbose` (35 tests passed) - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` ## Risks - The all-actors query can return more rows than the legacy agent-only query. Cursor pagination and existing limits bound each request. - The basic tier intentionally exposes action and actor-kind context. It removes detailed run, agent, responsible-user, and details attribution. - The legacy endpoint behavior remains the default, which reduces compatibility risk. > The roadmap marks activity log and action attribution as shipped. This change improves that existing capability and does not introduce a separate workflow system. ## Model Used - OpenAI Codex, `gpt-5.6-sol`, 114K context, agentic reasoning with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
import { Router } from "express";
|
|
import { z } from "zod";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { normalizeIssueIdentifier } from "@paperclipai/shared";
|
|
import { validate } from "../middleware/validate.js";
|
|
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 { badRequest, forbidden } from "../errors.js";
|
|
import { agentActionAuditService } from "../services/agent-action-audit.js";
|
|
import { logActivity } from "../services/activity-log.js";
|
|
|
|
/** Max rows a single CSV export will stream (guards against runaway exports). */
|
|
const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000;
|
|
const AUDIT_CSV_PAGE_SIZE = 200;
|
|
const CSV_FORMULA_CHARS = /^[=+\-@\t\r]/;
|
|
|
|
const AUDIT_CSV_COLUMNS = [
|
|
"createdAt",
|
|
"action",
|
|
"actorType",
|
|
"actorId",
|
|
"agentId",
|
|
"runId",
|
|
"responsibleUserId",
|
|
"entityType",
|
|
"entityId",
|
|
"issueIdentifier",
|
|
"issueTitle",
|
|
"commentExcerpt",
|
|
"documentKey",
|
|
] as const;
|
|
|
|
function csvCell(value: unknown): string {
|
|
if (value === null || value === undefined) return "";
|
|
const str = value instanceof Date ? value.toISOString() : String(value);
|
|
// Prevent spreadsheet applications from interpreting user-controlled cells
|
|
// as formulas when an operator opens the export.
|
|
const safe = CSV_FORMULA_CHARS.test(str) ? `'${str}` : str;
|
|
// Quote if the value contains a delimiter, quote, or newline; escape quotes by doubling.
|
|
return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
|
|
}
|
|
|
|
function readNested(value: unknown, ...keys: string[]): string | null {
|
|
let cursor: unknown = value;
|
|
for (const key of keys) {
|
|
if (!cursor || typeof cursor !== "object") return null;
|
|
cursor = (cursor as Record<string, unknown>)[key];
|
|
}
|
|
return typeof cursor === "string" ? cursor : null;
|
|
}
|
|
|
|
type AuditCsvRow = {
|
|
createdAt: Date | string;
|
|
action: string;
|
|
actorType: string | null;
|
|
actorId: string | null;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
responsibleUserId: string | null;
|
|
entityType: string;
|
|
entityId: string;
|
|
// Enrichment snippet is redacted server-side into a plain record, so read it
|
|
// defensively rather than assuming a fixed shape.
|
|
entity: unknown;
|
|
};
|
|
|
|
function auditRowsToCsv(rows: AuditCsvRow[]): string {
|
|
const lines = [AUDIT_CSV_COLUMNS.join(",")];
|
|
for (const row of rows) {
|
|
lines.push([
|
|
csvCell(row.createdAt),
|
|
csvCell(row.action),
|
|
csvCell(row.actorType),
|
|
csvCell(row.actorId),
|
|
csvCell(row.agentId),
|
|
csvCell(row.runId),
|
|
csvCell(row.responsibleUserId),
|
|
csvCell(row.entityType),
|
|
csvCell(row.entityId),
|
|
csvCell(readNested(row.entity, "issue", "identifier")),
|
|
csvCell(readNested(row.entity, "issue", "title")),
|
|
csvCell(readNested(row.entity, "comment", "excerpt")),
|
|
csvCell(readNested(row.entity, "document", "key")),
|
|
].join(","));
|
|
}
|
|
// Trailing newline keeps POSIX tools + spreadsheet importers happy.
|
|
return `${lines.join("\r\n")}\r\n`;
|
|
}
|
|
|
|
const createActivitySchema = z.object({
|
|
actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"),
|
|
actorId: z.string().min(1),
|
|
action: z.string().min(1),
|
|
entityType: z.string().min(1),
|
|
entityId: z.string().min(1),
|
|
agentId: z.string().uuid().optional().nullable(),
|
|
details: z.record(z.unknown()).optional().nullable(),
|
|
});
|
|
|
|
const agentActionAuditActorScopeSchema = z.enum(["agents", "all"]);
|
|
|
|
const agentActionAuditQuerySchema = z.object({
|
|
actorScope: agentActionAuditActorScopeSchema.default("agents"),
|
|
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 hasAgentAuditPermission(req: import("express").Request, companyId: string) {
|
|
if (req.actor.type !== "board") return false;
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
|
|
return Boolean(
|
|
req.actor.userId
|
|
&& await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions"),
|
|
);
|
|
}
|
|
|
|
async function assertAgentAuditPermission(req: import("express").Request, companyId: string) {
|
|
assertBoard(req);
|
|
assertCompanyAccess(req, companyId);
|
|
if (await hasAgentAuditPermission(req, companyId)) return;
|
|
throw forbidden("Missing permission: audit:view_agent_actions");
|
|
}
|
|
|
|
function hasAttributionFilters(query: z.infer<typeof agentActionAuditQuerySchema>) {
|
|
return query.agentId !== undefined
|
|
|| query.responsibleUserId !== undefined
|
|
|| query.runId !== undefined
|
|
|| query.entityType !== undefined
|
|
|| query.entityId !== undefined
|
|
|| query.action !== undefined
|
|
|| query.actorType !== undefined
|
|
|| query.from !== undefined
|
|
|| query.to !== undefined;
|
|
}
|
|
|
|
function stripAuditAttribution<T extends {
|
|
items: Array<{
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
responsibleUserId: string | null;
|
|
details: unknown;
|
|
}>;
|
|
}>(result: T): T {
|
|
return {
|
|
...result,
|
|
items: result.items.map((item) => ({
|
|
...item,
|
|
agentId: null,
|
|
runId: null,
|
|
responsibleUserId: null,
|
|
details: null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function assertCompanyScopeReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "company_scope:read",
|
|
resource: { type: "company", companyId },
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(403).json({ error: "Activity is outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
async function assertIssueReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, issue: {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
parentId: string | null;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
status: string;
|
|
}) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "issue:read",
|
|
resource: {
|
|
type: "issue",
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
projectId: issue.projectId,
|
|
parentIssueId: issue.parentId,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
status: issue.status,
|
|
},
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(403).json({ error: "Issue activity is outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
async function resolveIssueByRef(rawId: string) {
|
|
const identifier = normalizeIssueIdentifier(rawId);
|
|
if (identifier) {
|
|
return issueSvc.getByIdentifier(identifier);
|
|
}
|
|
return issueSvc.getById(rawId);
|
|
}
|
|
|
|
router.get("/companies/:companyId/activity", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return;
|
|
|
|
const filters = {
|
|
companyId,
|
|
agentId: req.query.agentId as string | undefined,
|
|
entityType: req.query.entityType as string | undefined,
|
|
entityId: req.query.entityId as string | undefined,
|
|
limit: normalizeActivityLimit(Number(req.query.limit)),
|
|
};
|
|
const result = await svc.list(filters);
|
|
res.json(result);
|
|
});
|
|
|
|
router.get("/companies/:companyId/audit/agent-actions", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const parsedActorScope = agentActionAuditActorScopeSchema.safeParse(req.query.actorScope ?? "agents");
|
|
if (!parsedActorScope.success) {
|
|
throw badRequest("Invalid agent action audit query", parsedActorScope.error.issues);
|
|
}
|
|
if (parsedActorScope.data === "agents") {
|
|
// Keep the legacy authorization-before-validation behavior for callers
|
|
// that omit the new flag.
|
|
await assertAgentAuditPermission(req, companyId);
|
|
} else {
|
|
assertCompanyAccess(req, companyId);
|
|
if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return;
|
|
}
|
|
|
|
const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query);
|
|
if (!parsedQuery.success) {
|
|
throw badRequest("Invalid agent action audit query", parsedQuery.error.issues);
|
|
}
|
|
if (parsedQuery.data.actorScope === "agents") {
|
|
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
|
|
res.json({ ...result, accessTier: "full" });
|
|
return;
|
|
}
|
|
|
|
const canViewAttribution = await hasAgentAuditPermission(req, companyId);
|
|
if (!canViewAttribution && hasAttributionFilters(parsedQuery.data)) {
|
|
throw forbidden("Audit filters require permission: audit:view_agent_actions");
|
|
}
|
|
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
|
|
res.json({
|
|
...(canViewAttribution ? result : stripAuditAttribution(result)),
|
|
accessTier: canViewAttribution ? "full" : "basic",
|
|
});
|
|
});
|
|
|
|
router.get("/companies/:companyId/audit/agent-actions.csv", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
await assertAgentAuditPermission(req, companyId);
|
|
const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query);
|
|
if (!parsedQuery.success) {
|
|
throw badRequest("Invalid agent action audit query", parsedQuery.error.issues);
|
|
}
|
|
// Drive our own pagination for the export; a client-supplied cursor/limit
|
|
// would silently truncate the export, so ignore them.
|
|
const { cursor: _cursor, limit: _limit, ...filters } = parsedQuery.data;
|
|
const rows: Awaited<ReturnType<typeof agentAudit.list>>["items"] = [];
|
|
let cursor: string | undefined;
|
|
do {
|
|
const page = await agentAudit.list({ companyId, ...filters, cursor, limit: AUDIT_CSV_PAGE_SIZE });
|
|
for (const item of page.items) {
|
|
if (rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS) break;
|
|
rows.push(item);
|
|
}
|
|
cursor = page.nextCursor ?? undefined;
|
|
} while (cursor && rows.length < AUDIT_CSV_EXPORT_MAX_ROWS);
|
|
|
|
// The export is itself an auditable act (training-data export precedent):
|
|
// record who exported what filter set and how many rows left the system.
|
|
const actorUserId = req.actor.type === "board" ? req.actor.userId ?? null : null;
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actorUserId ? "user" : "system",
|
|
actorId: actorUserId ?? "local-board",
|
|
action: "audit.exported",
|
|
entityType: "company",
|
|
entityId: companyId,
|
|
details: {
|
|
format: "csv",
|
|
rowCount: rows.length,
|
|
truncated: rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS && Boolean(cursor),
|
|
filters: {
|
|
actorScope: filters.actorScope,
|
|
agentId: filters.agentId ?? null,
|
|
responsibleUserId: filters.responsibleUserId ?? null,
|
|
runId: filters.runId ?? null,
|
|
entityType: filters.entityType ?? null,
|
|
entityId: filters.entityId ?? null,
|
|
action: filters.action ?? null,
|
|
actorType: filters.actorType ?? null,
|
|
from: filters.from ? filters.from.toISOString() : null,
|
|
to: filters.to ? filters.to.toISOString() : null,
|
|
},
|
|
},
|
|
});
|
|
|
|
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
|
res.setHeader("Content-Disposition", `attachment; filename="agent-audit-${companyId}.csv"`);
|
|
res.send(auditRowsToCsv(rows));
|
|
});
|
|
|
|
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
|
|
assertBoard(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const event = await svc.create({
|
|
companyId,
|
|
...req.body,
|
|
details: req.body.details ? sanitizeRecord(req.body.details) : null,
|
|
});
|
|
res.status(201).json(event);
|
|
});
|
|
|
|
router.get("/issues/:id/activity", async (req, res) => {
|
|
const rawId = req.params.id as string;
|
|
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
|
|
if (!issue) return;
|
|
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
|
const result = await svc.forIssue(issue.id);
|
|
res.json(result);
|
|
});
|
|
|
|
router.get("/issues/:id/runs", async (req, res) => {
|
|
const rawId = req.params.id as string;
|
|
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
|
|
if (!issue) return;
|
|
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
|
const result = await svc.runsForIssue(issue.companyId, issue.id);
|
|
res.json(result);
|
|
});
|
|
|
|
router.get("/heartbeat-runs/:runId/issues", async (req, res) => {
|
|
assertAuthenticated(req);
|
|
const runId = req.params.runId as string;
|
|
const run = await heartbeat.getRun(runId);
|
|
if (!run || !hasCompanyAccess(req, run.companyId)) {
|
|
// Return `200 []` for both "doesn't exist" and "cross-tenant" — preserves the
|
|
// legacy API contract while keeping the cross-tenant existence oracle closed
|
|
// (both branches yield indistinguishable responses).
|
|
res.json([]);
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, run.companyId);
|
|
if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return;
|
|
const result = await svc.issuesForRun(runId);
|
|
res.json(result);
|
|
});
|
|
|
|
return router;
|
|
}
|