From 68ddd6a7a0aeb5ca20d0dda24593806522c6a72c Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:07:38 -0500 Subject: [PATCH] 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 --- server/src/__tests__/activity-routes.test.ts | 100 +++++++++ .../agent-action-audit-routes.test.ts | 175 +++++++++++++++ server/src/routes/activity.ts | 78 ++++++- server/src/services/agent-action-audit.ts | 6 +- ui/src/api/audit.ts | 20 +- ui/src/lib/queryKeys.ts | 2 + ui/src/pages/audit/AuditFeed.test.tsx | 111 +++++++++- ui/src/pages/audit/AuditFeed.tsx | 207 +++++++++++------- ui/src/pages/audit/CompanyAudit.tsx | 6 +- 9 files changed, 601 insertions(+), 104 deletions(-) diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 97a23fb288..e6d9d61113 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -21,6 +21,11 @@ const mockIssueService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ decide: vi.fn(), + canUser: vi.fn(), +})); + +const mockAgentActionAuditService = vi.hoisted(() => ({ + list: vi.fn(), })); vi.mock("../services/activity.js", () => ({ @@ -37,6 +42,10 @@ vi.mock("../services/index.js", () => ({ heartbeatService: () => mockHeartbeatService, })); +vi.mock("../services/agent-action-audit.js", () => ({ + agentActionAuditService: () => mockAgentActionAuditService, +})); + async function createApp( actor: Record = { type: "board", @@ -98,12 +107,103 @@ 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(); + mockAccessService.canUser.mockReset(); + mockAgentActionAuditService.list.mockReset(); mockAccessService.decide.mockResolvedValue({ allowed: true, action: "company_scope:read", reason: "allow_test", explanation: "Allowed by test mock.", }); + mockAccessService.canUser.mockResolvedValue(false); + }); + + it("returns redacted all-actors rows to a basic company reader", async () => { + mockAgentActionAuditService.list.mockResolvedValue({ + items: [{ + id: "activity-1", + companyId: "company-1", + actorType: "plugin", + actorId: "plugin-1", + action: "plugin.synced", + entityType: "company", + entityId: "company-1", + agentId: "agent-1", + runId: "run-1", + responsibleUserId: "user-2", + details: { privateAttribution: true }, + createdAt: "2026-08-04T00:00:00.000Z", + entity: { issue: null, comment: null, document: null }, + }], + nextCursor: null, + }); + + const app = await createApp(); + const res = await request(app) + .get("/api/companies/company-1/audit/agent-actions?actorScope=all"); + + expect(res.status).toBe(200); + expect(mockAgentActionAuditService.list).toHaveBeenCalledWith({ + companyId: "company-1", + actorScope: "all", + limit: 50, + }); + expect(res.body.items[0]).toMatchObject({ + actorType: "plugin", + actorId: "plugin-1", + action: "plugin.synced", + entityType: "company", + entityId: "company-1", + createdAt: "2026-08-04T00:00:00.000Z", + agentId: null, + runId: null, + responsibleUserId: null, + details: null, + }); + expect(res.body.accessTier).toBe("basic"); + }); + + it("rejects attribution filters for a basic all-actors reader", async () => { + const app = await createApp(); + const res = await request(app) + .get("/api/companies/company-1/audit/agent-actions?actorScope=all&runId=00000000-0000-4000-8000-000000000001"); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("audit:view_agent_actions"); + expect(mockAgentActionAuditService.list).not.toHaveBeenCalled(); + }); + + it("keeps attribution for a permitted all-actors reader", async () => { + mockAccessService.canUser.mockResolvedValue(true); + mockAgentActionAuditService.list.mockResolvedValue({ + items: [{ + id: "activity-1", + agentId: "agent-1", + runId: "run-1", + responsibleUserId: "user-2", + details: { attribution: true }, + }], + nextCursor: null, + }); + + const app = await createApp(); + const res = await request(app) + .get("/api/companies/company-1/audit/agent-actions?actorScope=all&actorType=system"); + + expect(res.status).toBe(200); + expect(mockAgentActionAuditService.list).toHaveBeenCalledWith({ + companyId: "company-1", + actorScope: "all", + actorType: "system", + limit: 50, + }); + expect(res.body.items[0]).toMatchObject({ + agentId: "agent-1", + runId: "run-1", + responsibleUserId: "user-2", + details: { attribution: true }, + }); + expect(res.body.accessTier).toBe("full"); }); it("limits company activity lists by default", async () => { diff --git a/server/src/__tests__/agent-action-audit-routes.test.ts b/server/src/__tests__/agent-action-audit-routes.test.ts index 2c068fe055..332720a8a2 100644 --- a/server/src/__tests__/agent-action-audit-routes.test.ts +++ b/server/src/__tests__/agent-action-audit-routes.test.ts @@ -115,6 +115,42 @@ describePostgres("agent action audit routes", () => { return { company, agent, otherAgent, issue, comment, issueDocument, run }; } + async function seedActorOnlyRows(companyId: string) { + return db.insert(activityLog).values([ + { + companyId, + actorType: "user", + actorId: "board-user", + action: "company.updated", + entityType: "company", + entityId: companyId, + responsibleUserId: "sensitive-responsible-user", + details: { changed: "name" }, + createdAt: new Date("2026-07-17T00:00:06.000Z"), + }, + { + companyId, + actorType: "system", + actorId: "scheduler", + action: "heartbeat.scheduled", + entityType: "company", + entityId: companyId, + details: { schedule: "private-schedule" }, + createdAt: new Date("2026-07-17T00:00:05.000Z"), + }, + { + companyId, + actorType: "plugin", + actorId: "example-plugin", + action: "plugin.synced", + entityType: "company", + entityId: companyId, + details: { pluginConfig: "private-config" }, + createdAt: new Date("2026-07-17T00:00:04.000Z"), + }, + ]).returning(); + } + it("denies agents and board users without the audit permission", async () => { const { company, agent } = await seed(); const agentResponse = await request(await createApp(db, { @@ -122,13 +158,146 @@ describePostgres("agent action audit routes", () => { })).get(`/api/companies/${company.id}/audit/agent-actions`); expect(agentResponse.status).toBe(403); + const agentAllActorsResponse = 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?actorScope=all`); + expect(agentAllActorsResponse.status, JSON.stringify(agentAllActorsResponse.body)).toBe(200); + expect(agentAllActorsResponse.body.items).toHaveLength(3); + expect(agentAllActorsResponse.body.items.every((item: { responsibleUserId: string | null }) => ( + item.responsibleUserId === null + ))).toBe(true); + 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"); + + const invalidBoardResponse = await request(await createApp(db, { + type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions?limit=invalid`); + expect(invalidBoardResponse.status).toBe(403); + expect(invalidBoardResponse.body.error).toContain("audit:view_agent_actions"); }, 30_000); + it("lets a company member read basic all-actor rows without attribution", async () => { + const { company } = await seed(); + await seedActorOnlyRows(company.id); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: "reader", + status: "active", + membershipRole: "viewer", + }); + const app = await createApp(db, { + type: "board", + userId: "reader", + companyIds: [company.id], + source: "session", + isInstanceAdmin: false, + }); + + const items: Array> = []; + let cursor: string | null = null; + do { + const cursorQuery = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""; + const response = await request(app) + .get(`/api/companies/${company.id}/audit/agent-actions?actorScope=all&limit=2${cursorQuery}`); + expect(response.status, JSON.stringify(response.body)).toBe(200); + items.push(...response.body.items); + expect(response.body.accessTier).toBe("basic"); + cursor = response.body.nextCursor; + } while (cursor); + + expect(items).toHaveLength(6); + expect(new Set(items.map((item) => item.actorType))).toEqual( + new Set(["agent", "user", "system", "plugin"]), + ); + for (const item of items) { + expect(item).toMatchObject({ + agentId: null, + runId: null, + responsibleUserId: null, + details: null, + }); + expect(item).toEqual(expect.objectContaining({ + actorType: expect.any(String), + actorId: expect.any(String), + action: expect.any(String), + entityType: expect.any(String), + entityId: expect.any(String), + createdAt: expect.any(String), + })); + } + }); + + it("rejects the full filter set for a basic all-actors reader", async () => { + const { company, agent, issue, run } = await seed(); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: "reader", + status: "active", + membershipRole: "viewer", + }); + const app = await createApp(db, { + type: "board", + userId: "reader", + companyIds: [company.id], + source: "session", + isInstanceAdmin: false, + }); + const filters = [ + `agentId=${agent.id}`, + "responsibleUserId=legacy-user", + `runId=${run.id}`, + "entityType=issue", + `entityId=${issue.id}`, + "action=issue.", + "actorType=agent", + "from=2026-07-17T00%3A00%3A00.000Z", + "to=2026-07-18T00%3A00%3A00.000Z", + ]; + + for (const filter of filters) { + const response = await request(app).get( + `/api/companies/${company.id}/audit/agent-actions?actorScope=all&${filter}`, + ); + expect(response.status, filter).toBe(403); + expect(response.body.error).toContain("audit:view_agent_actions"); + } + }); + + it("keeps the default scope unchanged and gives permitted readers the full all-actors view", async () => { + const { company } = await seed(); + const actorOnlyRows = await seedActorOnlyRows(company.id); + const app = await createApp(db, { + type: "board", + userId: "local-board", + companyIds: [company.id], + source: "local_implicit", + isInstanceAdmin: false, + }); + + const defaultResponse = await request(app) + .get(`/api/companies/${company.id}/audit/agent-actions`); + expect(defaultResponse.status, JSON.stringify(defaultResponse.body)).toBe(200); + expect(defaultResponse.body.items).toHaveLength(3); + expect(defaultResponse.body.items.map((item: { id: string }) => item.id)).not.toContain(actorOnlyRows[0]!.id); + + const allResponse = await request(app) + .get(`/api/companies/${company.id}/audit/agent-actions?actorScope=all`); + expect(allResponse.status, JSON.stringify(allResponse.body)).toBe(200); + expect(defaultResponse.body.accessTier).toBe("full"); + expect(allResponse.body.accessTier).toBe("full"); + expect(allResponse.body.items).toHaveLength(6); + expect(allResponse.body.items.find((item: { id: string }) => item.id === actorOnlyRows[0]!.id)).toMatchObject({ + responsibleUserId: "sensitive-responsible-user", + details: { changed: "name" }, + }); + }); + it("returns a client error for invalid audit query parameters", async () => { const { company } = await seed(); const response = await request(await createApp(db, { @@ -137,6 +306,12 @@ describePostgres("agent action audit routes", () => { expect(response.status).toBe(400); expect(response.body.error).toBe("Invalid agent action audit query"); + const scopeResponse = await request(await createApp(db, { + type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions?actorScope=unknown`); + expect(scopeResponse.status).toBe(400); + expect(scopeResponse.body.error).toBe("Invalid agent action audit query"); + const cursorResponse = await request(await createApp(db, { type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, })).get(`/api/companies/${company.id}/audit/agent-actions?cursor=invalid`); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 225161e3d0..85e8b20690 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -99,7 +99,10 @@ const createActivitySchema = z.object({ 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(), @@ -121,14 +124,54 @@ export function activityRoutes(db: 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 (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; + if (await hasAgentAuditPermission(req, companyId)) return; throw forbidden("Missing permission: audit:view_agent_actions"); } + function hasAttributionFilters(query: z.infer) { + 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; + }>(result: T): T { + return { + ...result, + items: result.items.map((item) => ({ + ...item, + agentId: null, + runId: null, + responsibleUserId: null, + details: null, + })), + }; + } + async function assertCompanyScopeReadAllowed(req: Parameters[0], res: any, companyId: string) { const decision = await access.decide({ actor: req.actor, @@ -194,12 +237,38 @@ export function activityRoutes(db: Db) { router.get("/companies/:companyId/audit/agent-actions", async (req, res) => { const companyId = req.params.companyId as string; - await assertAgentAuditPermission(req, companyId); + 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); } - res.json(await agentAudit.list({ companyId, ...parsedQuery.data })); + 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) => { @@ -238,6 +307,7 @@ export function activityRoutes(db: Db) { 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, diff --git a/server/src/services/agent-action-audit.ts b/server/src/services/agent-action-audit.ts index 864b48dd74..f3ac6cb0fc 100644 --- a/server/src/services/agent-action-audit.ts +++ b/server/src/services/agent-action-audit.ts @@ -8,6 +8,7 @@ import { visibleIssueCondition } from "./issue-visibility.js"; export interface AgentActionAuditFilters { companyId: string; + actorScope?: "agents" | "all"; agentId?: string; responsibleUserId?: string; runId?: string; @@ -53,7 +54,10 @@ export function agentActionAuditService(db: Db) { const cursor = decodeCursor(filters.cursor); if (filters.cursor && !cursor) throw badRequest("Invalid audit cursor"); const effectiveResponsibleUserId = sql`coalesce(${activityLog.responsibleUserId}, ${heartbeatRuns.responsibleUserId})`; - const conditions = [eq(activityLog.companyId, filters.companyId), isNotNull(activityLog.agentId)]; + const conditions = [eq(activityLog.companyId, filters.companyId)]; + // Preserve the historical agent-audit query unless the caller opts into + // the unified all-actors feed explicitly. + if (filters.actorScope !== "all") conditions.push(isNotNull(activityLog.agentId)); if (filters.agentId) conditions.push(eq(activityLog.agentId, filters.agentId)); if (filters.responsibleUserId) conditions.push(or( eq(activityLog.responsibleUserId, filters.responsibleUserId), diff --git a/ui/src/api/audit.ts b/ui/src/api/audit.ts index 4a80440284..2af7eeb60c 100644 --- a/ui/src/api/audit.ts +++ b/ui/src/api/audit.ts @@ -6,10 +6,11 @@ import { api } from "./client"; * Consumes the unified read API shipped by Phase 2c * (`server/src/routes/activity.ts` → `services/agent-action-audit.ts`): * GET /companies/:companyId/audit/agent-actions - * Gated server-side by the `audit:view_agent_actions` board permission — the - * client renders an upsell/permission-denied state when the request 403s - * (see `ui/src/pages/CompanyAudit.tsx`). CSV export streams from the sibling - * `.csv` endpoint, which logs the export action itself. + * The default agent scope is gated server-side by the + * `audit:view_agent_actions` board permission. The explicit all-actors scope + * also supports a company-read basic tier with attribution stripped. CSV + * export streams from the permission-gated sibling `.csv` endpoint, which + * logs the export action itself. */ /** Render-ready entity snippets attached to each row at read time (no N+1). */ @@ -39,10 +40,14 @@ export interface AuditActionRecord { export interface AuditActionsResponse { items: AuditActionRecord[]; nextCursor: string | null; + /** Controls whether attribution filters and CSV export are available. */ + accessTier: "basic" | "full"; } /** Server-side filters for the audit feed. All optional. */ export interface AuditActionFilters { + /** Defaults to `agents`; `all` opts into the unified all-actors feed. */ + actorScope?: "agents" | "all"; agentId?: string | null; responsibleUserId?: string | null; runId?: string | null; @@ -60,6 +65,7 @@ export interface AuditActionFilters { function buildAuditQuery(filters: AuditActionFilters): URLSearchParams { const search = new URLSearchParams(); + if (filters.actorScope) search.set("actorScope", filters.actorScope); if (filters.agentId) search.set("agentId", filters.agentId); if (filters.responsibleUserId) search.set("responsibleUserId", filters.responsibleUserId); if (filters.runId) search.set("runId", filters.runId); @@ -76,9 +82,9 @@ function buildAuditQuery(filters: AuditActionFilters): URLSearchParams { export const auditApi = { /** - * Cursor-paginated agent-action feed. Returns `{ items, nextCursor }`. - * Throws `ApiError` with status 403 when the caller lacks - * `audit:view_agent_actions`. + * Cursor-paginated activity/audit feed. The default agent scope throws a + * 403 without `audit:view_agent_actions`; `actorScope: "all"` returns a + * redacted basic tier to ordinary company readers. */ listAgentActions: (companyId: string, filters: AuditActionFilters = {}) => { const search = buildAuditQuery(filters); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index f6e4529271..054afdf961 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -56,6 +56,7 @@ export const queryKeys = { agentActions: ( companyId: string, filters: { + actorScope?: "agents" | "all" | null; agentId?: string | null; responsibleUserId?: string | null; runId?: string | null; @@ -70,6 +71,7 @@ export const queryKeys = { "audit", companyId, "agent-actions", + filters.actorScope ?? "agents", filters.agentId ?? "__all", filters.responsibleUserId ?? "__all", filters.runId ?? "__all", diff --git a/ui/src/pages/audit/AuditFeed.test.tsx b/ui/src/pages/audit/AuditFeed.test.tsx index adf788a119..32374f5840 100644 --- a/ui/src/pages/audit/AuditFeed.test.tsx +++ b/ui/src/pages/audit/AuditFeed.test.tsx @@ -91,7 +91,7 @@ describe("AuditFeed", () => { beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); - listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null }); + listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null, accessTier: "full" }); listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "Fable", icon: null }]); listUserDirectoryMock.mockResolvedValue({ users: [{ principalId: "user-1", status: "active", user: { id: "user-1", name: "Dotta", email: null, image: null } }], @@ -116,6 +116,7 @@ describe("AuditFeed", () => { ); }); await flushReact(); + return client; } function clickButton(text: string) { @@ -129,6 +130,10 @@ describe("AuditFeed", () => { it("renders the humanized sentence, the task link, the excerpt, and the on-behalf chip", async () => { await render(); + expect(listAgentActionsMock).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ actorScope: "all" }), + ); expect(container.textContent).toContain("Fable"); expect(container.textContent).toContain("commented on"); const taskLink = container.querySelector('a[href="/issues/PAP-1"]'); @@ -151,11 +156,104 @@ describe("AuditFeed", () => { expect(container.textContent).not.toContain("Recorded by Paperclip"); }); + it("hides attribution filters and export for a basic all-actors reader", async () => { + listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null, accessTier: "basic" }); + await render(); + + expect(container.textContent).toContain("commented on"); + expect(container.textContent).not.toContain("All agents"); + expect(container.textContent).not.toContain("All responsible users"); + expect(container.textContent).not.toContain("Export CSV"); + }); + + it("clears privileged filters and recovers the basic feed after an access downgrade", async () => { + let permissionRevoked = false; + listAgentActionsMock.mockImplementation((_companyId: string, filters: { from?: string }) => { + if (permissionRevoked && filters.from) { + return Promise.reject( + new ApiError("Missing permission: audit:view_agent_actions", 403, { error: "Missing permission" }), + ); + } + return Promise.resolve({ + items: [record()], + nextCursor: null, + accessTier: permissionRevoked ? "basic" : "full", + }); + }); + await render(); + + const fromDate = container.querySelector('input[aria-label="From date"]'); + expect(fromDate).toBeTruthy(); + const setInputValue = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + expect(setInputValue).toBeTruthy(); + await act(async () => { + permissionRevoked = true; + setInputValue!.call(fromDate, "2026-08-01"); + fromDate!.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + expect(listAgentActionsMock.mock.calls.some(([, filters]) => filters.from)).toBe(true); + expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual( + expect.objectContaining({ actorScope: "all", from: undefined }), + ); + expect(container.textContent).toContain("commented on"); + expect(container.textContent).not.toContain("Paperclip Enterprise view"); + expect(container.textContent).not.toContain("All agents"); + expect(container.textContent).not.toContain("Export CSV"); + }); + + it("drops cached privileged pages when pagination observes an access downgrade", async () => { + let permissionRevoked = false; + listAgentActionsMock.mockImplementation((_companyId: string, filters: { cursor?: string }) => { + if (filters.cursor === "cursor-2") { + return Promise.resolve({ + items: [record({ + id: "evt-2", + agentId: null, + runId: null, + responsibleUserId: null, + details: null, + })], + nextCursor: null, + accessTier: "basic", + }); + } + if (permissionRevoked) { + return Promise.resolve({ + items: [record({ agentId: null, runId: null, responsibleUserId: null, details: null })], + nextCursor: null, + accessTier: "basic", + }); + } + return Promise.resolve({ items: [record()], nextCursor: "cursor-2", accessTier: "full" }); + }); + await render(); + + expect(container.textContent).toContain("on behalf of Dotta"); + expect(container.textContent).toContain("Export CSV"); + permissionRevoked = true; + await clickButton("Load more"); + await flushReact(); + + expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual( + expect.objectContaining({ actorScope: "all", cursor: undefined }), + ); + expect(container.textContent).toContain("commented on"); + expect(container.textContent).not.toContain("on behalf of Dotta"); + expect(container.querySelector('a[href="/agents/agent-1/runs/run-1"]')).toBeFalsy(); + expect(container.textContent).not.toContain("All agents"); + expect(container.textContent).not.toContain("Export CSV"); + }); + it("hides the agent filter and pins the query when lockedAgentId is set", async () => { await render({ lockedAgentId: "agent-1" }); const [, filters] = listAgentActionsMock.mock.calls[0]; - expect((filters as { agentId?: string }).agentId).toBe("agent-1"); + expect(filters).toEqual(expect.objectContaining({ actorScope: "agents", agentId: "agent-1" })); // No "All agents" option means the agent filter is hidden on the per-agent tab. expect(container.textContent).not.toContain("All agents"); }); @@ -171,9 +269,9 @@ describe("AuditFeed", () => { it("loads more when a cursor is returned", async () => { listAgentActionsMock.mockImplementation((_companyId: string, filters: { cursor?: string }) => { if (filters.cursor === "cursor-2") { - return Promise.resolve({ items: [record({ id: "evt-2", entity: { issue: { id: "i2", identifier: "PAP-2", title: "Second" }, comment: null, document: null } })], nextCursor: null }); + return Promise.resolve({ items: [record({ id: "evt-2", entity: { issue: { id: "i2", identifier: "PAP-2", title: "Second" }, comment: null, document: null } })], nextCursor: null, accessTier: "full" }); } - return Promise.resolve({ items: [record()], nextCursor: "cursor-2" }); + return Promise.resolve({ items: [record()], nextCursor: "cursor-2", accessTier: "full" }); }); await render(); @@ -196,7 +294,10 @@ describe("AuditFeed", () => { await clickButton("Export CSV"); await flushReact(); - expect(exportCsvMock).toHaveBeenCalledWith("company-1", expect.any(Object)); + expect(exportCsvMock).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ actorScope: "all" }), + ); expect(createUrl).toHaveBeenCalled(); expect(revokeUrl).not.toHaveBeenCalled(); const deferredRevoke = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 5_000)?.[0]; diff --git a/ui/src/pages/audit/AuditFeed.tsx b/ui/src/pages/audit/AuditFeed.tsx index e128e7b49a..740027519c 100644 --- a/ui/src/pages/audit/AuditFeed.tsx +++ b/ui/src/pages/audit/AuditFeed.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Download, ScrollText, ShieldAlert } from "lucide-react"; import type { Agent } from "@paperclipai/shared"; @@ -256,6 +256,10 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro ); const filters: AuditActionFilters = { + // The company feed is the shared all-actors view. The per-agent tab keeps + // the legacy privileged scope because it always carries an attribution + // filter and must not silently downgrade to the basic tier. + actorScope: lockedAgentId ? "agents" : "all", agentId: lockedAgentId ?? (agent === ALL ? undefined : agent), responsibleUserId: responsibleUser === ALL ? undefined : responsibleUser, action: actionDomain === ALL ? undefined : actionDomain, @@ -275,6 +279,7 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro const feed = useInfiniteQuery({ queryKey: queryKeys.audit.agentActions(companyId, { + actorScope: filters.actorScope, agentId: filters.agentId, responsibleUserId: filters.responsibleUserId, action: filters.action, @@ -295,6 +300,33 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro ); const permissionDenied = feed.error instanceof ApiError && feed.error.status === 403; + const hasBasicPage = feed.data?.pages.some((page) => page.accessTier === "basic") ?? false; + const hasFullPage = feed.data?.pages.some((page) => page.accessTier === "full") ?? false; + // Access may be revoked between cursor requests. Treat the least-privileged + // page as authoritative until every cached page has been fetched again. + const accessTier = hasBasicPage ? "basic" : feed.data?.pages[0]?.accessTier; + const hasMixedAccessTiers = hasBasicPage && hasFullPage; + const canUseAdvancedControls = lockedAgentId + ? true + : accessTier === "full"; + const recoveringFromAccessDowngrade = Boolean( + !lockedAgentId + && ((permissionDenied && hasActiveFilters) || hasMixedAccessTiers), + ); + + useEffect(() => { + if (!lockedAgentId && (accessTier === "basic" || recoveringFromAccessDowngrade)) { + setAgent(ALL); + setResponsibleUser(ALL); + setActionDomain(ALL); + setEntityType(ALL); + setDateFrom(""); + setDateTo(""); + } + if (hasMixedAccessTiers) { + void feed.refetch(); + } + }, [accessTier, feed, hasMixedAccessTiers, lockedAgentId, recoveringFromAccessDowngrade]); const clearFilters = () => { setAgent(ALL); @@ -309,6 +341,7 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro setExporting(true); try { const blob = await auditApi.exportAgentActionsCsv(companyId, { + actorScope: filters.actorScope, agentId: filters.agentId, responsibleUserId: filters.responsibleUserId, action: filters.action, @@ -338,7 +371,7 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro } }; - if (permissionDenied) { + if (permissionDenied && !recoveringFromAccessDowngrade) { return ; } @@ -349,100 +382,108 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro

Audit

- Everything your agents did, newest first — each line is one recorded action, with the - person responsible for it. Click through to the task or run for the full context. + Everything your company did, newest first — each line is one recorded action. Full + audit access also shows responsible-person and run attribution.

) : null} -
- {!lockedAgentId ? ( - + + + + + All agents + {(agents.data ?? []).map((a) => ( + + {a.name} + + ))} + + + ) : null} + - ) : null} - - - - setDateFrom(e.target.value)} - className="w-36" - /> - setDateTo(e.target.value)} - className="w-36" - /> - {hasActiveFilters ? ( - + ) : null} + - ) : null} - -
+ + ) : null} - {feed.isLoading ? ( + {recoveringFromAccessDowngrade ? ( + + + Refreshing audit access… + + + ) : feed.isLoading ? ( Loading… diff --git a/ui/src/pages/audit/CompanyAudit.tsx b/ui/src/pages/audit/CompanyAudit.tsx index 06ae873496..a1084cf126 100644 --- a/ui/src/pages/audit/CompanyAudit.tsx +++ b/ui/src/pages/audit/CompanyAudit.tsx @@ -6,10 +6,8 @@ import { EmptyState } from "../../components/EmptyState"; import { AuditFeed } from "./AuditFeed"; /** - * Company-level agent audit page — a permission-gated - * rich view in the unified codebase, matching the `tools:view_audit` precedent. - * The feed itself renders the upsell/permission-denied state when the caller - * lacks `audit:view_agent_actions` (server-authoritative, see `AuditFeed`). + * Company-level audit page. All company readers receive the redacted shared + * feed; attribution filters and export remain permission-gated server-side. */ export function CompanyAudit() { const { selectedCompanyId } = useCompany();