diff --git a/doc/ISSUE-PRIVACY.md b/doc/ISSUE-PRIVACY.md index b8a06619274..4e70036cf02 100644 --- a/doc/ISSUE-PRIVACY.md +++ b/doc/ISSUE-PRIVACY.md @@ -55,6 +55,35 @@ content. > workspaces and appropriately trusted agents for sensitive work; task ACLs are > not a sandbox or data-loss-prevention system. +## Plugins and notification/digest surfaces + +Plugins (including any Slack notifier or digest plugin) are **non-member +principals**: they hold no company membership, task grant, or private-project +access. The host services that back the `issues.read` capability — +`issues.list`, `issues.get`, `issues.listComments`, `issues.listAttachments`, +`issues.getAttachmentContent`, and `issues.getOrchestrationSummary` — apply the +canonical visibility predicate against a synthetic non-member actor, so a +plugin sees only company-open work. A private task is filtered from list +results, returns `null`/empty on direct read, and reports "not found" as an +orchestration root. Orchestration summaries additionally drop private subtree +descendants and redact blocker/blocks edges that point at a private task, so +its title, status, assignee, cost, and run metadata never enter an outbound +payload. This is why a digest broadcast to a shared channel — whose audience is +non-members by definition — cannot carry private issue content. + +There is intentionally **no plugin oversight exception**: unlike the +company-wide audit log, no plugin read path bypasses the predicate. A plugin +that must surface private work would need an explicit, separately designed +oversight capability; none exists today. + +> **Residual metadata disclosure.** A plugin still learns aggregate, +> content-free signals about private work that mirror the accepted +> dashboard/count disclosure: `issues.getOrchestrationSummary` returns +> company-wide `openBudgetIncidents` and a roll-up `costs`/token total for the +> readable subtree, and readable public tasks reveal that they have *some* +> redacted (locked) blocker without its identity. No private title, body, +> comment, attachment, identifier, or relationship target is exposed. + ## Rollout modes `PAPERCLIP_ISSUE_PRIVACY_MODE=enforce` is the default. `shadow` records @@ -87,6 +116,7 @@ it protects: | Blocker and mention identifier-only stubs | `issue-access-grants-routes.test.ts` | | Attachment content | `issue-attachment-routes.test.ts` | | Private-project list and direct read | `projects-list-archived-routes.test.ts` | +| Plugin issue reads (list, get, comments, attachments, orchestration) | `plugin-orchestration-apis.test.ts` | Adding a new task-derived read surface requires a non-member fixture in this gate before the surface can ship. diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index e7226267b19..6ea2a654945 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { and, eq } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { activityLog, agentWakeupRequests, @@ -1187,4 +1187,130 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { services.issues.getAttachmentContent({ attachmentId, companyId, maxBytes: 1_000_000 }), ).rejects.toThrow("over the"); }); + + // PAP-16091 / PAP-16050: a plugin is a non-member principal. Every issue-read + // path the host exposes (which a Slack notifier/digest plugin would fan out + // to shared channels) must apply the canonical visibility predicate so private + // issue content and relationship metadata never reach non-members. + describe("non-member plugin issue-read privacy (PAP-16091)", () => { + let previousMode: string | undefined; + beforeEach(() => { + previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + }); + afterEach(() => { + if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; + }); + + async function seedOpenAndPrivate() { + const { companyId, agentId } = await seedCompanyAndAgent(); + const openIssueId = randomUUID(); + const privateIssueId = randomUUID(); + await db.insert(issues).values([ + { id: openIssueId, companyId, title: "Public status", status: "todo", priority: "medium", visibility: "open" }, + { id: privateIssueId, companyId, title: "Confidential HR matter", status: "todo", priority: "medium", visibility: "private" }, + ]); + return { companyId, agentId, openIssueId, privateIssueId }; + } + + it("omits private issues from issues.list", async () => { + const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + const listed = await services.issues.list({ companyId }); + const ids = listed.map((issue) => issue.id); + expect(ids).toContain(openIssueId); + expect(ids).not.toContain(privateIssueId); + }); + + it("returns null from issues.get for a private issue", async () => { + const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect(services.issues.get({ issueId: privateIssueId, companyId })).resolves.toBeNull(); + // Sanity: the same path still returns readable (open) issues. + await expect(services.issues.get({ issueId: openIssueId, companyId })).resolves.toMatchObject({ id: openIssueId }); + }); + + it("returns no comments from issues.listComments for a private issue", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + await db.insert(issueComments).values({ + id: randomUUID(), companyId, issueId: privateIssueId, body: "secret decision rationale", + }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect(services.issues.listComments({ issueId: privateIssueId, companyId })).resolves.toEqual([]); + }); + + it("returns no attachments from issues.listAttachments for a private issue", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const assetId = randomUUID(); + await db.insert(assets).values({ + id: assetId, companyId, provider: "local_disk", objectKey: "secret", contentType: "image/png", byteSize: 10, sha256: "z", + }); + await db.insert(issueAttachments).values({ id: randomUUID(), companyId, issueId: privateIssueId, assetId }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect(services.issues.listAttachments({ issueId: privateIssueId, companyId })).resolves.toEqual([]); + }); + + it("returns null from issues.getAttachmentContent for a private issue's attachment", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const assetId = randomUUID(); + await db.insert(assets).values({ + id: assetId, companyId, provider: "local_disk", objectKey: "secret2", contentType: "text/plain", byteSize: 4, sha256: "y", + }); + const attachmentId = randomUUID(); + await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId: privateIssueId, assetId }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect( + services.issues.getAttachmentContent({ attachmentId, companyId, maxBytes: 1_000_000 }), + ).resolves.toBeNull(); + }); + + it("excludes a private descendant and redacts private blocker edges in getOrchestrationSummary", async () => { + const { companyId } = await seedCompanyAndAgent(); + const rootId = randomUUID(); + const publicChildId = randomUUID(); + const privateChildId = randomUUID(); + const privateBlockerId = randomUUID(); + await db.insert(issues).values([ + { id: rootId, companyId, title: "Root", status: "todo", priority: "medium", visibility: "open" }, + { id: publicChildId, companyId, parentId: rootId, title: "Public child", status: "todo", priority: "medium", visibility: "open" }, + { id: privateChildId, companyId, parentId: rootId, title: "Private child", status: "todo", priority: "medium", visibility: "private" }, + { id: privateBlockerId, companyId, title: "Private blocker", status: "todo", priority: "medium", visibility: "private" }, + ]); + // privateBlocker blocks the public root -> root.blockedBy includes it pre-redaction. + await db.insert(issueRelations).values({ + companyId, issueId: privateBlockerId, relatedIssueId: rootId, type: "blocks", + }); + + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + const summary = await services.issues.getOrchestrationSummary({ companyId, issueId: rootId, includeSubtree: true }); + + // Private descendant never enters the subtree. + expect(new Set(summary.subtreeIssueIds)).toEqual(new Set([rootId, publicChildId])); + // No relation edge anywhere references the private blocker or private child. + const edgeIds = new Set(); + for (const relation of Object.values(summary.relations)) { + for (const edge of [...relation.blockedBy, ...relation.blocks]) edgeIds.add(edge.id); + } + expect(edgeIds.has(privateBlockerId)).toBe(false); + expect(edgeIds.has(privateChildId)).toBe(false); + }); + + it("reports a private root issue as not-found from getOrchestrationSummary", async () => { + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + await expect( + services.issues.getOrchestrationSummary({ companyId, issueId: privateIssueId, includeSubtree: true }), + ).rejects.toThrow("Issue not found"); + }); + + it("is pass-through when privacy mode is off (enforce flip is the single switch)", async () => { + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "off"; + const { companyId, privateIssueId } = await seedOpenAndPrivate(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); + const ids = (await services.issues.list({ companyId })).map((issue) => issue.id); + expect(ids).toContain(privateIssueId); + await expect(services.issues.get({ issueId: privateIssueId, companyId })).resolves.toMatchObject({ id: privateIssueId }); + }); + }); }); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 0c2d10e2e26..ebabf541d17 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -27,7 +27,7 @@ import type { PluginIssueOrchestrationSummary, PluginExecutionWorkspaceMetadata, } from "@paperclipai/plugin-sdk"; -import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared"; +import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, IssueRelationIssueSummary, PermissionKey, PrincipalType } from "@paperclipai/shared"; import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; import { companyService } from "./companies.js"; import { agentService } from "./agents.js"; @@ -76,7 +76,14 @@ import { isIP } from "node:net"; import { logger } from "../middleware/logger.js"; import { getTelemetryClient } from "../telemetry.js"; import { accessService } from "./access.js"; -import { authorizationService, type AuthorizationActor } from "./authorization.js"; +import { + authorizationService, + canActorReadIssuePrivacy, + issuePrivacyMode, + issueReadSqlCondition, + type AuthorizationActor, + type IssuePrivacyRow, +} from "./authorization.js"; import { redactEventPayload, sanitizeRecord } from "../redaction.js"; // --------------------------------------------------------------------------- @@ -550,6 +557,61 @@ export function buildHostServices( const interactions = issueThreadInteractionService(db); const scopedBus = eventBus.forPlugin(pluginKey); + // --- Issue privacy for plugin-facing reads (PAP-16091 / PAP-16050) --------- + // Plugins are non-member principals: they hold no company membership, issue + // access grant, or private-project access, so the canonical issue-visibility + // predicate resolves them to the same scope as any other non-member — public + // issues only. Notification/digest plugins (e.g. a Slack notifier) fan issue + // data out to shared channels whose audience is, by definition, non-members, + // so private issue content and relationship metadata must never reach them. + // + // A synthetic { type: "none" } actor produces exactly that scope: it yields a + // null privacy principal (no implicit/grant/project-member match) and does not + // trip the local-implicit / instance-admin bypasses inside the predicate. In + // "off"/"shadow" modes these helpers are pass-through, matching every other + // enforcement surface so the enforce flip stays the single behavioral switch. + const pluginIssuePrivacyActor: AuthorizationActor = { type: "none" }; + const toIssuePrivacyRow = (issue: { + id: string; + companyId: string; + visibility?: string | null; + privacyRootIssueId?: string | null; + responsibleUserId?: string | null; + createdByUserId?: string | null; + assigneeUserId?: string | null; + assigneeAgentId?: string | null; + projectId?: string | null; + }): IssuePrivacyRow => ({ + id: issue.id, + companyId: issue.companyId, + visibility: issue.visibility ?? "open", + privacyRootIssueId: issue.privacyRootIssueId ?? null, + responsibleUserId: issue.responsibleUserId ?? null, + createdByUserId: issue.createdByUserId ?? null, + assigneeUserId: issue.assigneeUserId ?? null, + assigneeAgentId: issue.assigneeAgentId ?? null, + projectId: issue.projectId ?? null, + }); + const pluginCanReadIssue = async ( + issue: Parameters[0] | null | undefined, + ): Promise => { + if (!issue) return false; + if (issuePrivacyMode() !== "enforce") return true; + return canActorReadIssuePrivacy(db, pluginIssuePrivacyActor, toIssuePrivacyRow(issue)); + }; + const pluginReadableIssueIds = async ( + issueList: Array[0]>, + ): Promise> => { + if (issuePrivacyMode() !== "enforce") return new Set(issueList.map((issue) => issue.id)); + const decisions = await Promise.all( + issueList.map(async (issue) => ({ + id: issue.id, + allowed: await canActorReadIssuePrivacy(db, pluginIssuePrivacyActor, toIssuePrivacyRow(issue)), + })), + ); + return new Set(decisions.filter((decision) => decision.allowed).map((decision) => decision.id)); + }; + // Track active session event subscriptions for cleanup const activeSubscriptions = new Set<{ unsubscribe: () => void; timer: ReturnType }>(); let disposed = false; @@ -1685,13 +1747,21 @@ export function buildHostServices( const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); assertReadableOriginFilter(params.originKind); - return applyWindow((await issues.list(companyId, params as any)) as Issue[], params); + // Push the non-member visibility predicate into the list query so private + // issues are filtered at the SQL layer before they can reach a plugin. + const readCondition = await issueReadSqlCondition(db, pluginIssuePrivacyActor); + return applyWindow( + (await issues.list(companyId, { ...(params as any), readCondition })) as Issue[], + params, + ); }, async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = await issues.getById(params.issueId); - return (inCompany(issue, companyId) ? issue : null) as Issue | null; + if (!inCompany(issue, companyId)) return null; + if (!(await pluginCanReadIssue(issue))) return null; + return issue as Issue; }, async create(params) { const companyId = ensureCompanyId(params.companyId); @@ -2082,12 +2152,85 @@ export function buildHostServices( const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); - const subtreeIssueIds = params.includeSubtree + // A private root is invisible to a non-member plugin: report not-found + // rather than leak its existence or any subtree relationship metadata. + if (!(await pluginCanReadIssue(rootIssue))) { + throw new Error("Issue not found"); + } + const rawSubtreeIssueIds = params.includeSubtree ? await collectIssueSubtreeIds(companyId, rootIssue.id) : [rootIssue.id]; - const relationPairs = await Promise.all( + // Drop any private descendant the plugin may not read so its relations, + // approvals, runs, costs, and budget blocks never enter the summary. + const subtreePrivacyRows = await db + .select({ + id: issuesTable.id, + companyId: issuesTable.companyId, + visibility: issuesTable.visibility, + privacyRootIssueId: issuesTable.privacyRootIssueId, + responsibleUserId: issuesTable.responsibleUserId, + createdByUserId: issuesTable.createdByUserId, + assigneeUserId: issuesTable.assigneeUserId, + assigneeAgentId: issuesTable.assigneeAgentId, + projectId: issuesTable.projectId, + }) + .from(issuesTable) + .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, rawSubtreeIssueIds))); + const readableSubtreeIds = await pluginReadableIssueIds(subtreePrivacyRows); + readableSubtreeIds.add(rootIssue.id); + const subtreeIssueIds = rawSubtreeIssueIds.filter((id) => readableSubtreeIds.has(id)); + const rawRelationPairs = await Promise.all( subtreeIssueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const), ); + // Blocker edges can point at private issues outside the readable subtree + // (e.g. a public task blocked by a private one). Their title/status/ + // assignee are relationship metadata a non-member must not see, so drop + // any edge whose target the plugin cannot read, recursing into nested + // terminal blockers. + const edgeIds = new Set(); + const collectEdgeIds = (edges: IssueRelationIssueSummary[]) => { + for (const edge of edges) { + edgeIds.add(edge.id); + if (edge.terminalBlockers?.length) collectEdgeIds(edge.terminalBlockers); + } + }; + for (const [, relation] of rawRelationPairs) { + collectEdgeIds(relation.blockedBy); + collectEdgeIds(relation.blocks); + } + const edgePrivacyRows = edgeIds.size === 0 + ? [] + : await db + .select({ + id: issuesTable.id, + companyId: issuesTable.companyId, + visibility: issuesTable.visibility, + privacyRootIssueId: issuesTable.privacyRootIssueId, + responsibleUserId: issuesTable.responsibleUserId, + createdByUserId: issuesTable.createdByUserId, + assigneeUserId: issuesTable.assigneeUserId, + assigneeAgentId: issuesTable.assigneeAgentId, + projectId: issuesTable.projectId, + }) + .from(issuesTable) + .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, [...edgeIds]))); + const readableEdgeIds = await pluginReadableIssueIds(edgePrivacyRows); + const redactEdges = (edges: IssueRelationIssueSummary[]): IssueRelationIssueSummary[] => + edges + .filter((edge) => readableEdgeIds.has(edge.id)) + .map((edge) => ( + edge.terminalBlockers?.length + ? { ...edge, terminalBlockers: redactEdges(edge.terminalBlockers) } + : edge + )); + const relationPairs = rawRelationPairs.map(([issueId, relation]) => [ + issueId, + { + ...relation, + blockedBy: redactEdges(relation.blockedBy), + blocks: redactEdges(relation.blocks), + }, + ] as const); const approvalRows = ( await Promise.all( subtreeIssueIds.map(async (issueId) => { @@ -2156,7 +2299,10 @@ export function buildHostServices( async listComments(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - if (!inCompany(await issues.getById(params.issueId), companyId)) return []; + const issue = await issues.getById(params.issueId); + if (!inCompany(issue, companyId)) return []; + // Comment visibility follows the ACL of the issue they were posted on. + if (!(await pluginCanReadIssue(issue))) return []; return (await issues.listComments(params.issueId)) as IssueComment[]; }, async createComment(params) { @@ -2337,7 +2483,9 @@ export function buildHostServices( async listAttachments(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - if (!inCompany(await issues.getById(params.issueId), companyId)) return []; + const issue = await issues.getById(params.issueId); + if (!inCompany(issue, companyId)) return []; + if (!(await pluginCanReadIssue(issue))) return []; return (await issues.listAttachments(params.issueId)) as any; }, async getAttachmentContent(params) { @@ -2347,6 +2495,9 @@ export function buildHostServices( // Unknown and cross-company ids are deliberately indistinguishable to // the plugin: both return null (no existence oracle across companies). if (!attachment || attachment.companyId !== companyId) return null; + // A private issue's attachment content must not reach a non-member + // plugin: gate on the parent issue's visibility, same null response. + if (!(await pluginCanReadIssue(await issues.getById(attachment.issueId)))) return null; const maxBytes = typeof params.maxBytes === "number" && params.maxBytes > 0 ? params.maxBytes : null; if (maxBytes !== null && attachment.byteSize > maxBytes) {