mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
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:
1 parent
571851faf5
commit
f83dc827b9
3 files changed
+316
-9
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user