Root cause
Authentication & access control introduced
Add server routes for companies, approvals, costs, and dashboard
How AI contributed: Direct introductionPublished Apr 16, 2026 in
paperclipai/paperclipAll 8 findings in this repository
Approval/decision endpoints accepted client-supplied decidedByUserId and wrote it as the authoritative actor, forging the governance audit trail.
Root cause
Add server routes for companies, approvals, costs, and dashboard
Fix
The fix removes decidedByUserId from request validators and derives it from the authenticated actor for approve, reject, and request-revision operations, preventing clients from forging decision attribution.
AI-assisted fix: ChatGPT/Codex · Dotta
Case facts
@@ -0,0 +1,88 @@+import { Router } from "express";+import type { Db } from "@paperclip/db";+import { createApprovalSchema, resolveApprovalSchema } from "@paperclip/shared";+import { validate } from "../middleware/validate.js";+import { approvalService, logActivity } from "../services/index.js";+import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";++export function approvalRoutes(db: Db) {+ const router = Router();+ const svc = approvalService(db);++ router.get("/companies/:companyId/approvals", async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);+ const status = req.query.status as string | undefined;+ const result = await svc.list(companyId, status);+ res.json(result);+ });++ router.post("/companies/:companyId/approvals", validate(createApprovalSchema), async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);++ const actor = getActorInfo(req);+ const approval = await svc.create(companyId, {+ ...req.body,+ requestedByUserId: actor.actorType === "user" ? actor.actorId : null,+ requestedByAgentId:+ req.body.requestedByAgentId ?? (actor.actorType === "agent" ? actor.actorId : null),+ status: "pending",+ decisionNote: null,+ decidedByUserId: null,+ decidedAt: null,+ updatedAt: new Date(),+ });++ await logActivity(db, {+ companyId,+ actorType: actor.actorType,+ actorId: actor.actorId,+ agentId: actor.agentId,+ action: "approval.created",+ entityType: "approval",+ entityId: approval.id,+ details: { type: approval.type },+ });++ res.status(201).json(approval);+ });++ router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {+ assertBoard(req);+ const id = req.params.id as string;+ const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);++ await logActivity(db, {+ companyId: approval.companyId,+ actorType: "user",+ actorId: req.actor.userId ?? "board",+ action: "approval.approved",+ entityType: "approval",+ entityId: approval.id,+ details: { type: approval.type },+ });++ res.json(approval);+ });++ router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {+ assertBoard(req);+ const id = req.params.id as string;+ const approval = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);++ await logActivity(db, {+ companyId: approval.companyId,+ actorType: "user",+ actorId: req.actor.userId ?? "board",+ action: "approval.rejected",+ entityType: "approval",+ entityId: approval.id,+ details: { type: approval.type },+ });++ res.json(approval);+ });++ return router;+}@@ -0,0 +1,113 @@+import { and, eq } from "drizzle-orm";+import type { Db } from "@paperclip/db";+import { approvals } from "@paperclip/db";+import { notFound, unprocessable } from "../errors.js";+import { agentService } from "./agents.js";++export function approvalService(db: Db) {+ const agentsSvc = agentService(db);++ return {+ list: (companyId: string, status?: string) => {+ const conditions = [eq(approvals.companyId, companyId)];+ if (status) conditions.push(eq(approvals.status, status));+ return db.select().from(approvals).where(and(...conditions));+ },++ getById: (id: string) =>+ db+ .select()+ .from(approvals)+ .where(eq(approvals.id, id))+ .then((rows) => rows[0] ?? null),++ create: (companyId: string, data: Omit<typeof approvals.$inferInsert, "companyId">) =>+ db+ .insert(approvals)+ .values({ ...data, companyId })+ .returning()+ .then((rows) => rows[0]),++ approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {+ const existing = await db+ .select()+ .from(approvals)+ .where(eq(approvals.id, id))+ .then((rows) => rows[0] ?? null);++ if (!existing) throw notFound("Approval not found");+ if (existing.status !== "pending") {+ throw unprocessable("Only pending approvals can be approved");+ }++ const now = new Date();+ const updated = await db+ .update(approvals)+ .set({+ status: "approved",+ decidedByUserId,+ decisionNote: decisionNote ?? null,+ decidedAt: now,+ updatedAt: now,+ })+ .where(eq(approvals.id, id))+ .returning()+ .then((rows) => rows[0]);++ if (updated.type === "hire_agent") {+ const payload = updated.payload as Record<string, unknown>;+ await agentsSvc.create(updated.companyId, {+ name: String(payload.name ?? "New Agent"),+ role: String(payload.role ?? "general"),+ title: typeof payload.title === "string" ? payload.title : null,+ reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,+ capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,+ adapterType: String(payload.adapterType ?? "process"),+ adapterConfig:+ typeof payload.adapterConfig === "object" && payload.adapterConfig !== null+ ? (payload.adapterConfig as Record<string, unknown>)+ : {},+ contextMode: String(payload.contextMode ?? "thin"),+ budgetMonthlyCents:+ typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,+ metadata:+ typeof payload.metadata === "object" && payload.metadata !== null+ ? (payload.metadata as Record<string, unknown>)+ : null,+ status: "idle",+ spentMonthlyCents: 0,+ lastHeartbeatAt: null,+ });+ }++ return updated;+ },++ reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {+ const existing = await db+ .select()+ .from(approvals)@@ -134,11 +134,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; }- const { approval, applied } = await svc.approve(- id,- req.body.decidedByUserId ?? "board",- req.body.decisionNote,- );+ const decidedByUserId = req.actor.userId ?? "board";+ const { approval, applied } = await svc.approve(id, decidedByUserId, req.body.decisionNote); if (applied) { const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);@@ -233,11 +230,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; }- const { approval, applied } = await svc.reject(- id,- req.body.decidedByUserId ?? "board",- req.body.decisionNote,- );+ const decidedByUserId = req.actor.userId ?? "board";+ const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote); if (applied) { await logActivity(db, {@@ -264,11 +258,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; }- const approval = await svc.requestRevision(- id,- req.body.decidedByUserId ?? "board",- req.body.decisionNote,- );+ const decidedByUserId = req.actor.userId ?? "board";+ const approval = await svc.requestRevision(id, decidedByUserId, req.body.decisionNote); await logActivity(db, { companyId: approval.companyId,@@ -12,14 +12,12 @@ export type CreateApproval = z.infer<typeof createApprovalSchema>; export const resolveApprovalSchema = z.object({ decisionNote: z.string().optional().nullable(),- decidedByUserId: z.string().optional().default("board"), }); export type ResolveApproval = z.infer<typeof resolveApprovalSchema>; export const requestApprovalRevisionSchema = z.object({ decisionNote: z.string().optional().nullable(),- decidedByUserId: z.string().optional().default("board"), }); export type RequestApprovalRevision = z.infer<typeof requestApprovalRevisionSchema>;AI-assisted change 8750fb956bf91e1a7f3b5efd47704cf617a7ff79bcb5aabddca026df2e125ce9 · Fix 321bccc35fb00d13b288bf53e7aea761f612d1be03a03838934a7aed53068785