From d92ef1e4b4841e12d613d2fba3a111eb33159b50 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 1 Aug 2026 18:21:03 +0000 Subject: [PATCH] feat: add private issue sharing grants Co-Authored-By: Paperclip --- doc/SPEC-implementation.md | 1 + packages/shared/src/index.ts | 2 + packages/shared/src/types/index.ts | 2 + packages/shared/src/types/issue.ts | 17 + .../issue-access-grants-routes.test.ts | 349 +++++++++++++++++ server/src/routes/issues.ts | 366 ++++++++++++++++-- server/src/routes/openapi.ts | 22 ++ server/src/services/authorization.ts | 13 + server/src/services/issues.ts | 79 ++++ 9 files changed, 829 insertions(+), 22 deletions(-) create mode 100644 server/src/__tests__/issue-access-grants-routes.test.ts diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e0ac06b07e8..bfdd569ee24 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -881,6 +881,7 @@ All endpoints are under `/api` and return JSON. - `GET /issues/:issueId` - `PATCH /issues/:issueId` - `GET /issues/:issueId/access-grants` +- `POST /issues/:issueId/access-grants` - `POST /issues/:issueId/access-grants/:grantId/revoke` - `GET /issues/:issueId/documents` - `GET /issues/:issueId/documents/:key` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9926507a5e3..e9b59efa82b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -859,8 +859,10 @@ export type { Issue, IssueVisibility, IssueAccessGrant, + IssueAccessGrantAgentVisibility, IssueAccessGrantSubjectType, IssueAccessGrantSource, + IssueLockedStub, IssueChangeReceiptEntry, IssueChanges, IssueAssigneeAdapterOverrides, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index bc1cabd1359..51ca70bfaaa 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -561,8 +561,10 @@ export type { IssueSubtreeDiagnosticsResponse, IssueVisibility, IssueAccessGrant, + IssueAccessGrantAgentVisibility, IssueAccessGrantSubjectType, IssueAccessGrantSource, + IssueLockedStub, IssueBlockerAttention, IssueBlockerAttentionReason, IssueBlockerAttentionState, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 8550718ffd7..0e1e45252bd 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -43,6 +43,7 @@ export type { IssueWorkMode }; export type IssueVisibility = "open" | "private"; export type IssueAccessGrantSubjectType = "user" | "agent"; export type IssueAccessGrantSource = "explicit" | "assignment" | "project"; +export type IssueAccessGrantAgentVisibility = "discoverable" | "private"; export interface IssueAccessGrant { id: string; @@ -54,6 +55,22 @@ export interface IssueAccessGrant { grantedByAgentId: string | null; createdAt: Date; revokedAt: Date | null; + subjectDisplayName: string | null; + subjectAvatarUrl: string | null; + subjectInitials: string | null; + agentVisibility: IssueAccessGrantAgentVisibility | null; +} + +/** + * Existence-only projection used when a readable issue references an issue the + * current principal cannot read. Edge projections may return this exact shape + * in place of an IssueRelationIssueSummary; direct reads and list/search rows + * never return locked stubs. + */ +export interface IssueLockedStub { + id: string; + identifier: string | null; + locked: true; } export interface IssueAncestorProject { diff --git a/server/src/__tests__/issue-access-grants-routes.test.ts b/server/src/__tests__/issue-access-grants-routes.test.ts new file mode 100644 index 00000000000..aab10d0da73 --- /dev/null +++ b/server/src/__tests__/issue-access-grants-routes.test.ts @@ -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; + +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> | 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); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 057d558a53c..b65a59a6a3f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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).authorizationPolicy; + if (!authorizationPolicy || typeof authorizationPolicy !== "object" || Array.isArray(authorizationPolicy)) return null; + const agentVisibility = (authorizationPolicy as Record).agentVisibility; + if (!agentVisibility || typeof agentVisibility !== "object" || Array.isArray(agentVisibility)) return null; + const mode = (agentVisibility as Record).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) => rows[0] ?? null); + if (active) return { grant: active, created: false as const }; + + const grant = await dbOrTx + .insert(issueAccessGrants) + .values(input) + .returning() + .then((rows: Array) => 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, + ) { + 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(); + 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): 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(); + 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()): 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; + 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 = (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(req: Request, companyId: string, rows: T[]): Promise { + const related = rows.flatMap((row) => [...(row.blockedBy ?? []), ...(row.blocks ?? [])]); + const edgeIds = new Set(); + 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()): 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>; + relatedWork?: unknown; referencedIssueIdentifiers?: string[]; } = issue; let updatedRelations: Awaited> | 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, ), }; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 4524c684f58..7d46b656cab 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 95723d53ecb..04b00841373 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -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; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index eaca0e2f655..23818291e9c 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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 | 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) => 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) => 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);