mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Run pages show links to issues associated with a run. > - The issue-link route checks the run's company and issue visibility before it reads those links. > - That check needs four binding fields, but the route reads the full run. > - Full runs can include large prompt, output, and result fields. > - This pull request uses the existing narrow authorization read. > - The route transfers less database data and keeps its permission checks and response contract. ## Linked Issues or Issue Description **Problem or motivation** `GET /api/heartbeat-runs/:runId/issues` calls `heartbeat.getRun` for a permission check. Its consumers only need the run ID, company ID, scope kind, and issue ID. The full read also fetches context, output excerpts, result data, and runtime metadata. Repeated link reads pay for that unused data. **Proposed solution** Use `getRunReadAccess`, the existing four-field read from #17348. Keep authentication, company access, company-scope authorization, run issue visibility, and linked-issue filtering in their current owners. Keep the existing empty response for missing and inaccessible runs. **Alternatives considered** A new query contract is unnecessary because the existing read provides all required fields. A permission cache could become stale. Changing the full run reader could affect consumers that need run details. **Roadmap alignment** This is a focused improvement to an existing route. It adds no feature, route, provider, cache, or background work. Searches for run-issue links, narrow run authorization, and `getRunReadAccess` found no open duplicate. Related work: #17348. ## What Changed - Change the issue-link route to use the existing narrow run read. - Reject full run reads in the route tests. Cover readable company and issue scopes, denied issue access, missing bindings, anonymous requests, and the missing/cross-company empty response. - Add a developer note about the read contract. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/activity-routes.test.ts src/services/heartbeat/run-state.test.ts src/__tests__/agent-live-run-routes.test.ts`: 126 tests passed. - `pnpm build`: passed. - `pnpm -r typecheck`: passed. - The route tests use the real run visibility helper with only the four binding fields. The full reader throws if a route calls it. The existing run-state suite checks the exact four-column selection and covers database wiring with disposable Postgres. - `git diff --check`: passed. Reviewed the committed diff for secrets and private identifiers before push. - Apex review: 5/5 on exact head `888a4ce9ea88bb2b284eb5e28fafc01b6f48b386`, completed successfully, with zero review threads. - Full current-head CI passed: 54 successful checks and two expected skips. [Workflow attempt 2](https://github.com/paperclipai/paperclip/actions/runs/40051399577/attempts/2). [Initial serialized shard failure](https://github.com/paperclipai/paperclip/actions/runs/40051399577/job/120744710081): the first company-history case in unchanged `agent-live-run-routes.test.ts` hit its 15-second timeout; the shard had 391 passing tests and one failure. That file, its agent route, the heartbeat state reader, and the service export are unchanged from base. The five company-history cases pass on unchanged base `9fb28d06ffebbb615f413765078aeeb7c4291311` (9.12s suite) and this head (8.29s suite). They also passed in the 126-test focused run. The timeout was not reproduced locally; its cause is not established. Only the failed shard was retried on the same head. It passed in attempt 2. This establishes recovery; the initial timeout remains in this record and its cause is unproven. - The prior local full-suite failures and baseline triage remain documented in #17384. No new local full-suite pass is claimed here. This change uses focused local tests and the full current-head CI gate. - No hosted user journey, staging deployment, production deployment, or billed savings is claimed for this follow-up. ## Risks - Run visibility depends on the canonical `scopeKind` and `issueId`. The existing helper returns those same fields. It does not use prompt or result data for access decisions. - Linked issues still pass the existing SQL visibility filter. Missing and cross-company runs still return `200 []`. Anonymous requests still fail before the run read. - The database query count stays the same. The change reduces data selected for this authorization read. - No schema or API response changes. ## Model Used OpenAI Codex, GPT-6 family. The exact deployment model ID and context window are not exposed in this session. Used reasoning, repository tools, 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>
395 lines
15 KiB
TypeScript
395 lines
15 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, getActorInfo, 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";
|
|
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
|
import { issueReadSqlCondition } from "../services/authorization.js";
|
|
import { caseActivityReadSqlCondition } from "../services/case-activity-access.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().guid().optional().nullable(),
|
|
details: z.record(z.string(), z.unknown()).optional().nullable(),
|
|
});
|
|
|
|
const agentActionAuditActorScopeSchema = z.enum(["agents", "all"]);
|
|
|
|
const agentActionAuditQuerySchema = z.object({
|
|
actorScope: agentActionAuditActorScopeSchema.default("agents"),
|
|
agentId: z.string().guid().optional(),
|
|
responsibleUserId: z.string().min(1).optional(),
|
|
runId: z.string().guid().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>) {
|
|
// Case history is available to the case's ordinary readers. Its public
|
|
// dimensions do not expose run/responsible-person attribution; the case
|
|
// predicate still filters every row before pagination and enrichment.
|
|
const caseScope = query.entityType === "case" || query.action?.startsWith("case.");
|
|
return query.agentId !== undefined
|
|
|| query.responsibleUserId !== undefined
|
|
|| query.runId !== undefined
|
|
|| (!caseScope && (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)),
|
|
readCondition: await issueReadSqlCondition(db, req.actor),
|
|
caseReadCondition: await caseActivityReadSqlCondition(db, req.actor),
|
|
};
|
|
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,
|
|
caseReadCondition: await caseActivityReadSqlCondition(db, req.actor) });
|
|
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,
|
|
caseReadCondition: await caseActivityReadSqlCondition(db, req.actor) });
|
|
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 caseReadCondition = await caseActivityReadSqlCondition(db, req.actor);
|
|
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, caseReadCondition });
|
|
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,
|
|
req.actor.type === "board" ? getActorInfo(req).actorId : null);
|
|
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.getRunReadAccess(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;
|
|
if (!(await canActorReadHeartbeatRun(db, access, req.actor, run))) {
|
|
res.json([]);
|
|
return;
|
|
}
|
|
const result = await svc.issuesForRun(runId, await issueReadSqlCondition(db, req.actor));
|
|
res.json(result);
|
|
});
|
|
|
|
return router;
|
|
}
|