feat: add private issue sharing grants

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-01 22:54:01 +00:00
1 parent d18b769fe4
commit d92ef1e4b4
9 files changed
+829 -22

No files matched your search

@@ -0,0 +1,349 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
authUsers,
companies,
companyMemberships,
createDb,
heartbeatRuns,
issueAccessGrants,
issueReferenceMentions,
issueRelations,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
vi.mock("../services/issue-assignment-wakeup.js", () => ({
queueIssueAssignmentWakeup: vi.fn(),
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type Db = ReturnType<typeof createDb>;
function boardActor(companyId: string, userId: string): Express.Request["actor"] {
return {
type: "board",
userId,
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
};
}
function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
return { type: "agent", agentId, companyId, runId: null, source: "agent_jwt" };
}
async function createApp(db: Db, actor: Express.Request["actor"]) {
const { issueRoutes } = await import("../routes/issues.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
});
return app;
}
describeEmbeddedPostgres.sequential("issue access grant and locked edge routes", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-access-grants-");
db = createDb(tempDb.connectionString);
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
}, 20_000);
afterEach(async () => {
await db.delete(issueReferenceMentions);
await db.delete(issueRelations);
await db.delete(activityLog);
await db.delete(issueAccessGrants);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(authUsers);
await db.delete(companies);
});
afterAll(async () => {
if (previousPrivacyMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacyMode;
await tempDb?.cleanup();
});
async function seedFixture() {
const companyId = randomUUID();
const ownerId = `owner-${randomUUID()}`;
const memberId = `member-${randomUUID()}`;
const sharedUserId = `shared-${randomUUID()}`;
const now = new Date();
await db.insert(companies).values({
id: companyId,
name: "Private issue grants",
issuePrefix: `P${randomUUID().replace(/-/g, "").slice(0, 5).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(authUsers).values([
{ id: ownerId, name: "Olivia Owner", email: `${ownerId}@example.test`, createdAt: now, updatedAt: now },
{ id: memberId, name: "Nora Nonreader", email: `${memberId}@example.test`, createdAt: now, updatedAt: now },
{
id: sharedUserId,
name: "Uma User",
email: `${sharedUserId}@example.test`,
image: "https://example.test/uma.png",
createdAt: now,
updatedAt: now,
},
]);
await db.insert(companyMemberships).values([ownerId, memberId, sharedUserId].map((principalId) => ({
companyId,
principalType: "user",
principalId,
status: "active",
membershipRole: "operator",
})));
const [assignedAgent, sharedAgent] = await db.insert(agents).values([
{
companyId,
name: "Assigned Agent",
role: "engineer",
status: "active",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
companyId,
name: "Private Helper",
role: "engineer",
status: "active",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {
authorizationPolicy: {
agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true },
},
},
},
]).returning();
const privateIssueId = randomUUID();
await db.insert(issues).values({
id: privateIssueId,
companyId,
identifier: "PRV-1",
title: "Confidential root",
status: "backlog",
priority: "medium",
visibility: "private",
privacyRootIssueId: privateIssueId,
responsibleUserId: ownerId,
createdByUserId: ownerId,
});
const privateChild = await db.insert(issues).values({
companyId,
parentId: privateIssueId,
identifier: "PRV-1A",
title: "Confidential child",
status: "backlog",
priority: "medium",
visibility: "private",
privacyRootIssueId: privateIssueId,
responsibleUserId: ownerId,
createdByUserId: ownerId,
}).returning().then((rows) => rows[0]!);
const openIssue = await db.insert(issues).values({
companyId,
identifier: "PRV-2",
title: "Open issue",
status: "todo",
priority: "medium",
createdByUserId: ownerId,
}).returning().then((rows) => rows[0]!);
return {
companyId,
ownerId,
memberId,
sharedUserId,
assignedAgent: assignedAgent!,
sharedAgent: sharedAgent!,
privateIssueId,
privateChild,
openIssue,
};
}
it("auto-grants private-root access on assignment and keeps it sticky until revoke", async () => {
const fixture = await seedFixture();
const ownerApp = await createApp(db, boardActor(fixture.companyId, fixture.ownerId));
const assigned = await request(ownerApp)
.patch(`/api/issues/${fixture.privateChild.id}`)
.send({ assigneeAgentId: fixture.assignedAgent.id });
expect(assigned.status, JSON.stringify(assigned.body)).toBe(200);
await request(ownerApp)
.patch(`/api/issues/${fixture.privateChild.id}`)
.send({ assigneeAgentId: fixture.assignedAgent.id })
.expect(200);
await request(ownerApp)
.patch(`/api/issues/${fixture.privateChild.id}`)
.send({ assigneeAgentId: null })
.expect(200);
const unassignedIssue = await db.select().from(issues).then((rows) => rows.find((row) => row.id === fixture.privateChild.id));
expect(unassignedIssue?.assigneeAgentId).toBeNull();
const grants = await db.select().from(issueAccessGrants);
expect(grants).toHaveLength(1);
expect(grants[0]).toMatchObject({
issueId: fixture.privateIssueId,
subjectType: "agent",
subjectId: fixture.assignedAgent.id,
source: "assignment",
grantedByUserId: fixture.ownerId,
revokedAt: null,
});
const readableBeforeRevoke = await request(await createApp(
db,
agentActor(fixture.companyId, fixture.assignedAgent.id),
)).get(`/api/issues/${fixture.privateIssueId}`);
expect(readableBeforeRevoke.status).toBe(200);
await request(ownerApp)
.post(`/api/issues/${fixture.privateChild.id}/access-grants/${grants[0]!.id}/revoke`)
.send({})
.expect(200);
const deniedAfterRevoke = await request(await createApp(
db,
agentActor(fixture.companyId, fixture.assignedAgent.id),
)).get(`/api/issues/${fixture.privateIssueId}`);
expect(deniedAfterRevoke.status).toBe(404);
expect(deniedAfterRevoke.body.error).toBe("Issue not found");
}, 20_000);
it("creates and enriches explicit grants idempotently while rejecting unauthorized and open shares", async () => {
const fixture = await seedFixture();
const ownerApp = await createApp(db, boardActor(fixture.companyId, fixture.ownerId));
const nonreaderApp = await createApp(db, boardActor(fixture.companyId, fixture.memberId));
await request(nonreaderApp)
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id })
.expect(404);
await request(nonreaderApp)
.patch(`/api/issues/${fixture.privateIssueId}`)
.send({ assigneeAgentId: fixture.assignedAgent.id })
.expect(404);
const openShare = await request(ownerApp)
.post(`/api/issues/${fixture.openIssue.id}/access-grants`)
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id });
expect(openShare.status).toBe(409);
expect(openShare.body.error).toContain("Open issues");
const first = await request(ownerApp)
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id });
expect(first.status, JSON.stringify(first.body)).toBe(201);
expect(first.body).toMatchObject({
issueId: fixture.privateIssueId,
source: "explicit",
subjectDisplayName: "Private Helper",
subjectInitials: "PH",
subjectAvatarUrl: null,
agentVisibility: "private",
});
const duplicate = await request(ownerApp)
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id });
expect(duplicate.status).toBe(200);
expect(duplicate.body.id).toBe(first.body.id);
await request(ownerApp)
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
.send({ subjectType: "user", subjectId: fixture.sharedUserId })
.expect(201);
const listed = await request(ownerApp).get(`/api/issues/${fixture.privateIssueId}/access-grants`);
expect(listed.status).toBe(200);
expect(listed.body).toEqual(expect.arrayContaining([
expect.objectContaining({
subjectId: fixture.sharedUserId,
subjectDisplayName: "Uma User",
subjectAvatarUrl: "https://example.test/uma.png",
subjectInitials: "UU",
agentVisibility: null,
}),
]));
expect(await db.select().from(issueAccessGrants)).toHaveLength(2);
const createdActivities = await db.select().from(activityLog);
expect(createdActivities.filter((row) => row.action === "issue_access_grant.created")).toHaveLength(2);
}, 20_000);
it("returns exact locked stubs only through visible blocker and mention edges", async () => {
const fixture = await seedFixture();
const visibleIssue = await db.insert(issues).values({
companyId: fixture.companyId,
identifier: "PRV-3",
title: "Visible coordination issue",
description: "Depends on PRV-1",
status: "blocked",
priority: "medium",
createdByUserId: fixture.memberId,
}).returning().then((rows) => rows[0]!);
await db.insert(issueRelations).values({
companyId: fixture.companyId,
issueId: fixture.privateIssueId,
relatedIssueId: visibleIssue.id,
type: "blocks",
});
await db.insert(issueReferenceMentions).values({
companyId: fixture.companyId,
sourceIssueId: visibleIssue.id,
targetIssueId: fixture.privateIssueId,
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
matchedText: "PRV-1",
});
const app = await createApp(db, boardActor(fixture.companyId, fixture.memberId));
const detail = await request(app).get(`/api/issues/${visibleIssue.id}`);
expect(detail.status, JSON.stringify(detail.body)).toBe(200);
const expectedStub = { id: fixture.privateIssueId, identifier: "PRV-1", locked: true };
expect(detail.body.blockedBy).toEqual([expectedStub]);
expect(detail.body.relatedWork.outbound[0].issue).toEqual(expectedStub);
expect(JSON.stringify(detail.body)).not.toContain("Confidential root");
const direct = await request(app).get(`/api/issues/${fixture.privateIssueId}`);
expect(direct.status).toBe(404);
const list = await request(app)
.get(`/api/companies/${fixture.companyId}/issues?includeBlockedBy=true`);
expect(list.status, JSON.stringify(list.body)).toBe(200);
expect(list.body.map((row: { id: string }) => row.id)).not.toContain(fixture.privateIssueId);
const visibleRow = list.body.find((row: { id: string }) => row.id === visibleIssue.id);
expect(visibleRow.blockedBy).toEqual([expectedStub]);
const search = await request(app)
.get(`/api/companies/${fixture.companyId}/search?q=Confidential%20root&scope=issues`);
expect(search.status).toBe(200);
expect(search.body.results.map((row: { id: string }) => row.id)).not.toContain(fixture.privateIssueId);
}, 20_000);
});
+344 -22
View File
@@ -2,12 +2,13 @@ import { createHash, randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, asc, desc, eq, inArray, isNull, notInArray, type SQL } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, notInArray, sql, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
approvals,
authUsers,
companyMemberships,
documents,
executionWorkspaces,
@@ -90,6 +91,7 @@ import {
type IssueWakeDiagnosticWakeRequest,
type IssueWakeDiagnosticsResponse,
type IssueRelationIssueSummary,
type IssueLockedStub,
type IssueCommentPresentation,
type IssueWatchdogDiscoveryKind,
type ProjectWorkspace,
@@ -170,7 +172,11 @@ import {
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
readAcceptedPlanConfirmationTarget,
} from "../services/issues.js";
import { authorizationDeniedDetails, issueReadSqlCondition } from "../services/authorization.js";
import {
authorizationDeniedDetails,
invalidateIssuePrivacyGrantCache,
issueReadSqlCondition,
} from "../services/authorization.js";
import { environmentService } from "../services/environments.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import { redactSensitiveText } from "../redaction.js";
@@ -223,6 +229,10 @@ const externalObjectSummariesSchema = z.object({
issueIds: z.array(z.string().uuid()).max(1000),
}).strict();
const revokeIssueAccessGrantSchema = z.object({}).strict().default({});
const createIssueAccessGrantSchema = z.object({
subjectType: z.enum(["user", "agent"]),
subjectId: z.string().trim().min(1),
}).strict();
const promoteLowTrustOutputSchema = z.object({
sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]),
@@ -3541,6 +3551,243 @@ export function issueRoutes(
return privacyRoot;
}
function subjectInitials(displayName: string | null) {
if (!displayName) return null;
const parts = displayName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return null;
return parts.slice(0, 2).map((part) => part[0]!.toUpperCase()).join("");
}
function issueGrantAgentVisibility(permissions: unknown): "discoverable" | "private" | null {
if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return null;
const authorizationPolicy = (permissions as Record<string, unknown>).authorizationPolicy;
if (!authorizationPolicy || typeof authorizationPolicy !== "object" || Array.isArray(authorizationPolicy)) return null;
const agentVisibility = (authorizationPolicy as Record<string, unknown>).agentVisibility;
if (!agentVisibility || typeof agentVisibility !== "object" || Array.isArray(agentVisibility)) return null;
const mode = (agentVisibility as Record<string, unknown>).mode;
return mode === "private" || mode === "discoverable" ? mode : null;
}
async function ensureActiveIssueAccessGrant(
dbOrTx: any,
input: {
issueId: string;
subjectType: "user" | "agent";
subjectId: string;
source: "explicit" | "assignment";
grantedByUserId: string | null;
grantedByAgentId: string | null;
},
) {
// No partial unique constraint is required for rollout compatibility. The
// transaction-scoped lock serializes the active-row check for this tuple,
// including the first insert where SELECT FOR UPDATE cannot lock a row.
const lockKey = `issue-access-grant:${input.issueId}:${input.subjectType}:${input.subjectId}`;
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
const active = await dbOrTx
.select()
.from(issueAccessGrants)
.where(and(
eq(issueAccessGrants.issueId, input.issueId),
eq(issueAccessGrants.subjectType, input.subjectType),
eq(issueAccessGrants.subjectId, input.subjectId),
isNull(issueAccessGrants.revokedAt),
))
.orderBy(desc(issueAccessGrants.createdAt), desc(issueAccessGrants.id))
.limit(1)
.then((rows: Array<typeof issueAccessGrants.$inferSelect>) => rows[0] ?? null);
if (active) return { grant: active, created: false as const };
const grant = await dbOrTx
.insert(issueAccessGrants)
.values(input)
.returning()
.then((rows: Array<typeof issueAccessGrants.$inferSelect>) => rows[0]!);
invalidateIssuePrivacyGrantCache({
subjectType: input.subjectType,
subjectId: input.subjectId,
issueId: input.issueId,
});
return { grant, created: true as const };
}
async function enrichIssueAccessGrants(
companyId: string,
grants: Array<typeof issueAccessGrants.$inferSelect>,
) {
const agentIds = [...new Set(grants.filter((grant) => grant.subjectType === "agent").map((grant) => grant.subjectId))];
const userIds = [...new Set(grants.filter((grant) => grant.subjectType === "user").map((grant) => grant.subjectId))];
const [agentRows, userRows] = await Promise.all([
agentIds.length === 0
? Promise.resolve([])
: db.select({
id: agents.id,
name: agents.name,
permissions: agents.permissions,
}).from(agents).where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))),
userIds.length === 0
? Promise.resolve([])
: db.select({ id: authUsers.id, name: authUsers.name, image: authUsers.image })
.from(authUsers)
.where(inArray(authUsers.id, userIds)),
]);
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
const usersById = new Map(userRows.map((user) => [user.id, user]));
return grants.map((grant) => {
const agent = grant.subjectType === "agent" ? agentsById.get(grant.subjectId) : null;
const user = grant.subjectType === "user" ? usersById.get(grant.subjectId) : null;
const subjectDisplayName = agent?.name ?? user?.name ?? null;
return {
...grant,
subjectDisplayName,
subjectAvatarUrl: user?.image ?? null,
subjectInitials: subjectInitials(subjectDisplayName),
agentVisibility: agent ? issueGrantAgentVisibility(agent.permissions) : null,
};
});
}
async function assertIssueGrantSubjectExists(
companyId: string,
subjectType: "user" | "agent",
subjectId: string,
) {
if (subjectType === "agent") {
const agent = await db
.select({ id: agents.id })
.from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, subjectId)))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!agent) throw unprocessable("Grant subject must be an agent in the issue company");
return;
}
const member = await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, subjectId),
eq(companyMemberships.status, "active"),
))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!member) throw unprocessable("Grant subject must be an active user in the issue company");
}
type IssueEdgeReference = IssueRelationIssueSummary | IssueLockedStub;
async function readableIssueEdgeIds(req: Request, companyId: string, ids: string[]) {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return new Set<string>();
const rows = await db
.select({
id: issueRows.id,
companyId: issueRows.companyId,
projectId: issueRows.projectId,
parentId: issueRows.parentId,
assigneeAgentId: issueRows.assigneeAgentId,
assigneeUserId: issueRows.assigneeUserId,
status: issueRows.status,
})
.from(issueRows)
.where(and(eq(issueRows.companyId, companyId), inArray(issueRows.id, uniqueIds)));
const decisions = await Promise.all(rows.map(async (row) => ({
id: row.id,
allowed: (await decideIssueAccess(req, row, "issue:read")).allowed,
})));
return new Set(decisions.filter((decision) => decision.allowed).map((decision) => decision.id));
}
function lockedIssueStub(issue: Pick<IssueRelationIssueSummary, "id" | "identifier">): IssueLockedStub {
return { id: issue.id, identifier: issue.identifier, locked: true };
}
async function redactIssueRelationEdges(
req: Request,
companyId: string,
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
): Promise<{ blockedBy: IssueEdgeReference[]; blocks: IssueEdgeReference[] }> {
const all = [...relations.blockedBy, ...relations.blocks];
const edgeIds = new Set<string>();
const pending = [...all];
while (pending.length > 0) {
const issue = pending.pop()!;
if (edgeIds.has(issue.id)) continue;
edgeIds.add(issue.id);
pending.push(...(issue.terminalBlockers ?? []));
}
const readableIds = await readableIssueEdgeIds(req, companyId, [...edgeIds]);
const redact = (issue: IssueRelationIssueSummary, ancestors = new Set<string>()): IssueEdgeReference => {
if (!readableIds.has(issue.id)) return lockedIssueStub(issue);
const { terminalBlockers, ...visibleFields } = issue;
if (ancestors.has(issue.id)) return visibleFields as IssueRelationIssueSummary;
const nextAncestors = new Set(ancestors).add(issue.id);
return {
...visibleFields,
...(terminalBlockers
? { terminalBlockers: terminalBlockers.map((child) => redact(child, nextAncestors)) as IssueRelationIssueSummary[] }
: {}),
};
};
return {
blockedBy: relations.blockedBy.map((issue) => redact(issue)),
blocks: relations.blocks.map((issue) => redact(issue)),
};
}
async function redactIssueReferenceEdges<T extends {
outbound: Array<{ issue: IssueRelationIssueSummary }>;
inbound: Array<{ issue: IssueRelationIssueSummary }>;
}>(req: Request, companyId: string, summary: T) {
const items = [...summary.outbound, ...summary.inbound];
const readableIds = await readableIssueEdgeIds(req, companyId, items.map((item) => item.issue.id));
const redactItems = <I extends { issue: IssueRelationIssueSummary }>(rows: I[]) => rows.map((item) => ({
...item,
issue: readableIds.has(item.issue.id) ? item.issue : lockedIssueStub(item.issue),
}));
return {
outbound: redactItems(summary.outbound),
inbound: redactItems(summary.inbound),
};
}
async function redactIssueRelationEdgesOnRows<T extends {
id: string;
blockedBy?: IssueRelationIssueSummary[];
blocks?: IssueRelationIssueSummary[];
}>(req: Request, companyId: string, rows: T[]): Promise<T[]> {
const related = rows.flatMap((row) => [...(row.blockedBy ?? []), ...(row.blocks ?? [])]);
const edgeIds = new Set<string>();
const pending = [...related];
while (pending.length > 0) {
const issue = pending.pop()!;
if (edgeIds.has(issue.id)) continue;
edgeIds.add(issue.id);
pending.push(...(issue.terminalBlockers ?? []));
}
const readableIds = await readableIssueEdgeIds(req, companyId, [...edgeIds]);
const redact = (issue: IssueRelationIssueSummary, ancestors = new Set<string>()): IssueEdgeReference => {
if (!readableIds.has(issue.id)) return lockedIssueStub(issue);
const { terminalBlockers, ...visibleFields } = issue;
if (ancestors.has(issue.id)) return visibleFields as IssueRelationIssueSummary;
const nextAncestors = new Set(ancestors).add(issue.id);
return {
...visibleFields,
...(terminalBlockers
? { terminalBlockers: terminalBlockers.map((child) => redact(child, nextAncestors)) as IssueRelationIssueSummary[] }
: {}),
};
};
return rows.map((row) => ({
...row,
...(row.blockedBy ? { blockedBy: row.blockedBy.map((issue) => redact(issue)) } : {}),
...(row.blocks ? { blocks: row.blocks.map((issue) => redact(issue)) } : {}),
})) as T[];
}
async function assertAgentIssueCommentAllowed(
req: Request,
res: Response,
@@ -5006,11 +5253,15 @@ export function issueRoutes(
sortField: sortField === "updated" ? "updated" : undefined,
sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined,
};
// readCondition is a Drizzle SQL object with cyclic internal references;
// actor identity already scopes the coordinator key, so only serializable
// request filters belong in its normalized query.
const { readCondition: _readCondition, ...requestKeyFilters } = listFilters;
const requestKey = issueListRequestKey({
req,
companyId,
normalizedQuery: {
...listFilters,
...requestKeyFilters,
view: compactView ? "compact" : undefined,
},
});
@@ -5022,9 +5273,10 @@ export function issueRoutes(
diagnostics: opts.issueListDiagnostics,
compute: async () => {
const rawResult = await svc.list(companyId, listFilters);
const result = await actorCanReadCompanyScope(req, companyId)
const visibleRows = await actorCanReadCompanyScope(req, companyId)
? rawResult
: await filterIssuesForActor(req, rawResult);
const result = await redactIssueRelationEdgesOnRows(req, companyId, visibleRows);
const issueIds = result.map((issue) => issue.id);
if (compactView) {
const [handoffStates, recoveryActionByIssue] = await Promise.all([
@@ -5336,6 +5588,7 @@ export function issueRoutes(
relations,
recoveryActionsByRelationIssue,
);
const visibleRelations = await redactIssueRelationEdges(req, issue.companyId, relationsWithRecoveryActions);
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
@@ -5377,8 +5630,8 @@ export function issueRoutes(
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
parentId: issue.parentId,
blockedBy: relationsWithRecoveryActions.blockedBy,
blocks: relationsWithRecoveryActions.blocks,
blockedBy: visibleRelations.blockedBy,
blocks: visibleRelations.blocks,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
originKind: issue.originKind,
@@ -5607,6 +5860,10 @@ export function issueRoutes(
relations,
recoveryActionsByRelationIssue,
);
const [visibleRelations, visibleReferenceSummary] = await Promise.all([
redactIssueRelationEdges(req, issue.companyId, relationsWithRecoveryActions),
redactIssueReferenceEdges(req, issue.companyId, referenceSummary),
]);
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
@@ -5630,10 +5887,10 @@ export function issueRoutes(
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
scheduledRetry,
activeRecoveryAction: revalidatedActiveRecoveryAction,
blockedBy: relationsWithRecoveryActions.blockedBy,
blocks: relationsWithRecoveryActions.blocks,
relatedWork: referenceSummary,
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
blockedBy: visibleRelations.blockedBy,
blocks: visibleRelations.blocks,
relatedWork: visibleReferenceSummary,
referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
...documentPayload,
project: compactIssueProject(project),
goal: goal ?? null,
@@ -7307,12 +7564,13 @@ export function issueRoutes(
});
if (deduplicationReason) {
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const visibleReferenceSummary = await redactIssueReferenceEdges(req, companyId, referenceSummary);
res.status(200).json({
...issue,
deduplicated: true,
deduplicationReason,
relatedWork: referenceSummary,
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
relatedWork: visibleReferenceSummary,
referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
});
return;
}
@@ -7414,10 +7672,11 @@ export function issueRoutes(
});
await queueTaskWatchdogEvaluation(issue, actor.runId);
const visibleReferenceSummary = await redactIssueReferenceEdges(req, companyId, referenceSummary);
res.status(201).json({
...issue,
relatedWork: referenceSummary,
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
relatedWork: visibleReferenceSummary,
referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
});
});
@@ -7861,9 +8120,57 @@ export function issueRoutes(
.from(issueAccessGrants)
.where(inArray(issueAccessGrants.issueId, grantIssueIds))
.orderBy(desc(issueAccessGrants.createdAt), desc(issueAccessGrants.id));
res.json(grants);
res.json(await enrichIssueAccessGrants(issue.companyId, grants));
});
router.post(
"/issues/:id/access-grants",
validate(createIssueAccessGrantSchema),
async (req, res) => {
const issue = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found");
if (!issue || !(await assertIssueReadAllowed(req, res, issue))) return;
const privacyRoot = await resolveIssuePrivacyManagementRoot(req, res, issue);
if (!privacyRoot) return;
if (privacyRoot.privacyRootIssueId === null) {
res.status(409).json({ error: "Open issues do not require access grants" });
return;
}
const subjectType = req.body.subjectType as "user" | "agent";
const subjectId = req.body.subjectId as string;
await assertIssueGrantSubjectExists(issue.companyId, subjectType, subjectId);
const actor = getActorInfo(req);
const result = await db.transaction((tx) => ensureActiveIssueAccessGrant(tx, {
issueId: privacyRoot.id,
subjectType,
subjectId,
source: "explicit",
grantedByUserId: actor.actorType === "user" ? actor.actorId : null,
grantedByAgentId: actor.agentId,
}));
if (result.created) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue_access_grant.created",
entityType: "issue_access_grant",
entityId: result.grant.id,
details: {
issueId: result.grant.issueId,
subjectType: result.grant.subjectType,
subjectId: result.grant.subjectId,
source: result.grant.source,
},
});
}
const [enriched] = await enrichIssueAccessGrants(issue.companyId, [result.grant]);
res.status(result.created ? 201 : 200).json(enriched);
},
);
router.post(
"/issues/:id/access-grants/:grantId/revoke",
validate(revokeIssueAccessGrantSchema),
@@ -7886,6 +8193,11 @@ export function issueRoutes(
res.status(404).json({ error: "Issue access grant not found" });
return;
}
invalidateIssuePrivacyGrantCache({
subjectType: grant.subjectType as "user" | "agent",
subjectId: grant.subjectId,
issueId: grant.issueId,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
@@ -7907,6 +8219,9 @@ export function issueRoutes(
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!existing) return;
const assigneeMutationRequested =
req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined;
if (assigneeMutationRequested && !(await assertIssueReadAllowed(req, res, existing))) return;
if (req.body.visibility !== undefined && !(await resolveIssuePrivacyManagementRoot(req, res, existing))) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
@@ -8447,18 +8762,19 @@ export function issueRoutes(
blockedBy?: unknown;
blocks?: unknown;
activeRecoveryAction?: unknown;
relatedWork?: Awaited<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>;
relatedWork?: unknown;
referencedIssueIdentifiers?: string[];
} = issue;
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
updatedRelations = await svc.getRelationSummaries(issue.id);
const visibleUpdatedRelations = await redactIssueRelationEdges(req, issue.companyId, updatedRelations);
issueResponse = {
...issue,
blockedByIssueIds:
issue.blockedByIssueIds ?? [...new Set(req.body.blockedByIssueIds as string[])].sort(),
blockedBy: updatedRelations.blockedBy,
blocks: updatedRelations.blocks,
blockedBy: visibleUpdatedRelations.blockedBy,
blocks: visibleUpdatedRelations.blocks,
};
}
await routinesSvc.syncRunStatusForIssue(issue.id);
@@ -8788,10 +9104,15 @@ export function issueRoutes(
commentReferenceSummaryBefore,
commentReferenceSummaryAfter,
);
const visibleCommentReferenceSummary = await redactIssueReferenceEdges(
req,
issue.companyId,
commentReferenceSummaryAfter,
);
issueResponse = {
...issueResponse,
relatedWork: commentReferenceSummaryAfter,
referencedIssueIdentifiers: commentReferenceSummaryAfter.outbound.map(
relatedWork: visibleCommentReferenceSummary,
referencedIssueIdentifiers: visibleCommentReferenceSummary.outbound.map(
(item) => item.issue.identifier ?? item.issue.id,
),
};
@@ -8846,10 +9167,11 @@ export function issueRoutes(
});
} else if (updateReferenceSummaryAfter) {
const visibleUpdateReferenceSummary = await redactIssueReferenceEdges(req, issue.companyId, updateReferenceSummaryAfter);
issueResponse = {
...issueResponse,
relatedWork: updateReferenceSummaryAfter,
referencedIssueIdentifiers: updateReferenceSummaryAfter.outbound.map(
relatedWork: visibleUpdateReferenceSummary,
referencedIssueIdentifiers: visibleUpdateReferenceSummary.outbound.map(
(item) => item.issue.identifier ?? item.issue.id,
),
};
+22
View File
@@ -1159,6 +1159,28 @@ registry.registerPath({
},
});
registry.registerPath({
method: "post",
path: "/api/issues/{id}/access-grants",
tags: ["issues"],
summary: "Grant a user or agent access to a private issue subtree",
request: {
params: z.object({ id: z.string() }),
body: jsonBody(z.object({
subjectType: z.enum(["user", "agent"]),
subjectId: z.string().min(1),
}).strict()),
},
responses: {
200: r.ok(),
201: r.ok(),
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
},
});
registry.registerPath({
method: "get",
path: "/api/openapi.json",
+13
View File
@@ -503,6 +503,19 @@ function setIssuePrivacyGrantCacheEntry(
}
}
export function invalidateIssuePrivacyGrantCache(input: {
subjectType: "user" | "agent";
subjectId: string;
issueId: string;
}) {
const prefix = `${input.subjectType}:${input.subjectId}:`;
for (const key of issuePrivacyGrantCache.keys()) {
if (key.startsWith(prefix) && key.split(":").includes(input.issueId)) {
issuePrivacyGrantCache.delete(key);
}
}
}
export function issuePrivacyMode(): "off" | "shadow" | "enforce" {
const mode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE?.trim().toLowerCase();
if (mode === "off" || mode === "enforce") return mode;
+79
View File
@@ -18,6 +18,7 @@ import {
routineRuns,
executionWorkspaces,
issueApprovals,
issueAccessGrants,
issueAttachments,
issueCreateIdempotencyKeys,
issueInboxArchives,
@@ -116,6 +117,7 @@ import { visibleIssueCondition } from "./issue-visibility.js";
import { finalizeStatusCardsForStalledGeneration } from "./status-card-finalization.js";
import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalization.js";
import { logActivity } from "./activity-log.js";
import { invalidateIssuePrivacyGrantCache } from "./authorization.js";
import { buildIssueChanges } from "./issue-change-receipt.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
@@ -2014,6 +2016,75 @@ function summarizeIssueRelationRow(row: IssueRelationSummaryRow): IssueRelationI
};
}
async function ensureAssignmentIssueAccessGrant(
dbOrTx: any,
issue: typeof issues.$inferSelect,
previous: Pick<typeof issues.$inferSelect, "assigneeAgentId" | "assigneeUserId"> | null,
actor: { agentId?: string | null; userId?: string | null },
) {
if (issue.visibility !== "private") return null;
const subject = issue.assigneeAgentId
? { subjectType: "agent" as const, subjectId: issue.assigneeAgentId }
: issue.assigneeUserId
? { subjectType: "user" as const, subjectId: issue.assigneeUserId }
: null;
if (!subject) return null;
if (
previous
&& previous.assigneeAgentId === issue.assigneeAgentId
&& previous.assigneeUserId === issue.assigneeUserId
) return null;
const grantIssueId = issue.privacyRootIssueId ?? issue.id;
const lockKey = `issue-access-grant:${grantIssueId}:${subject.subjectType}:${subject.subjectId}`;
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
const active = await dbOrTx
.select({ id: issueAccessGrants.id })
.from(issueAccessGrants)
.where(and(
eq(issueAccessGrants.issueId, grantIssueId),
eq(issueAccessGrants.subjectType, subject.subjectType),
eq(issueAccessGrants.subjectId, subject.subjectId),
isNull(issueAccessGrants.revokedAt),
))
.limit(1)
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
if (active) return null;
const grant = await dbOrTx
.insert(issueAccessGrants)
.values({
issueId: grantIssueId,
...subject,
source: "assignment",
grantedByUserId: actor.userId ?? null,
grantedByAgentId: actor.agentId ?? null,
})
.returning()
.then((rows: Array<typeof issueAccessGrants.$inferSelect>) => rows[0]!);
invalidateIssuePrivacyGrantCache({
subjectType: subject.subjectType,
subjectId: subject.subjectId,
issueId: grantIssueId,
});
await logActivity(dbOrTx as Db, {
companyId: issue.companyId,
actorType: actor.agentId ? "agent" : actor.userId ? "user" : "system",
actorId: actor.agentId ?? actor.userId ?? "issue_service",
agentId: actor.agentId ?? null,
action: "issue_access_grant.created",
entityType: "issue_access_grant",
entityId: grant.id,
details: {
issueId: grant.issueId,
subjectType: grant.subjectType,
subjectId: grant.subjectId,
source: grant.source,
},
});
return grant;
}
async function terminalExplicitBlockersByRoot(
companyId: string,
roots: IssueRelationIssueSummary[],
@@ -6648,6 +6719,10 @@ export function issueService(db: Db) {
);
const [issue] = await tx.insert(issues).values(values).returning();
await ensureAssignmentIssueAccessGrant(tx, issue, null, {
agentId: issueData.createdByAgentId ?? null,
userId: issueData.createdByUserId ?? null,
});
if (idempotencyKey) {
await tx.insert(issueCreateIdempotencyKeys).values({
companyId,
@@ -7142,6 +7217,10 @@ export function issueService(db: Db) {
.returning()
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!updated) return null;
await ensureAssignmentIssueAccessGrant(tx, updated, receiptExisting, {
agentId: actorAgentId ?? null,
userId: actorUserId ?? null,
});
if (existing.status !== updated.status) {
if (updated.status === "done" || updated.status === "cancelled") {
await finalizeSummarySlotsForTerminalIssue(tx, updated);