Files
PaperClipAI/server/src/routes/activity.ts
T
DottaandPaperclip 68ddd6a7a0 feat(activity): add two-tier all-actors audit feed (#10831)
## 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>
2026-08-04 23:07:38 -05:00

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;
}