feat: enforce issue privacy on plugin issue reads

Plugins are non-member principals, so the host services backing the
issues.read capability must apply the canonical issue-visibility
predicate. This is the leak surface a Slack notifier/digest plugin
would consume (PAP-16091, follow-up to PAP-16050).

- issues.list: push issueReadSqlCondition into the query
- issues.get / listComments / listAttachments / getAttachmentContent:
  gate on canActorReadIssuePrivacy for the (parent) issue
- getOrchestrationSummary: drop private subtree descendants and redact
  blocker/blocks edges pointing at private issues; private root 404s
- synthetic { type: "none" } actor => public-only scope, no
  local-implicit / instance-admin bypass; pass-through in off/shadow
- 8 non-member leak tests + doc/ISSUE-PRIVACY.md plugin section and
  residual-disclosure note

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-08-01 23:19:05 +00:00
1 parent 571851faf5
commit f83dc827b9
3 files changed
+316 -9

No files matched your search

+30
View File
@@ -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.
@@ -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<string>();
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 });
});
});
});
+159 -8
View File
@@ -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<typeof toIssuePrivacyRow>[0] | null | undefined,
): Promise<boolean> => {
if (!issue) return false;
if (issuePrivacyMode() !== "enforce") return true;
return canActorReadIssuePrivacy(db, pluginIssuePrivacyActor, toIssuePrivacyRow(issue));
};
const pluginReadableIssueIds = async (
issueList: Array<Parameters<typeof toIssuePrivacyRow>[0]>,
): Promise<Set<string>> => {
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<typeof setTimeout> }>();
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<string>();
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) {