From cd86192ca2dd0c3dbd8c3382d0b1f5aa040b7e71 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 1 Aug 2026 17:02:07 +0000 Subject: [PATCH] feat: add issue privacy ACL foundation Co-Authored-By: Paperclip --- doc/DEVELOPING.md | 10 ++ doc/SPEC-implementation.md | 21 ++- .../0198_issue_privacy_foundation.sql | 58 +++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + packages/db/src/schema/issue_access_grants.ts | 36 ++++ packages/db/src/schema/issues.ts | 5 + .../tests/plugin.spec.ts | 2 + .../plugin-llm-wiki/tests/plugin.spec.ts | 2 + packages/plugins/sdk/src/testing.ts | 2 + packages/shared/src/index.ts | 4 + packages/shared/src/types/index.ts | 4 + packages/shared/src/types/issue.ts | 21 +++ packages/shared/src/validators/issue.test.ts | 7 + packages/shared/src/validators/issue.ts | 3 + .../__tests__/authorization-service.test.ts | 115 ++++++++++++- .../__tests__/company-search-service.test.ts | 58 +++++++ ...ue-agent-mutation-ownership-routes.test.ts | 12 +- server/src/__tests__/issues-service.test.ts | 26 +++ server/src/routes/issues.ts | 127 +++++++++++++- server/src/services/authorization.ts | 161 +++++++++++++++++- server/src/services/company-search-extract.ts | 7 +- server/src/services/company-search.ts | 7 +- server/src/services/issues.ts | 34 ++++ 24 files changed, 709 insertions(+), 21 deletions(-) create mode 100644 packages/db/src/migrations/0198_issue_privacy_foundation.sql create mode 100644 packages/db/src/schema/issue_access_grants.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 821f864da3e..68ae1a8119f 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -330,6 +330,16 @@ PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run No Docker or external database is required for this mode. +## Issue Privacy Rollout + +`PAPERCLIP_ISSUE_PRIVACY_MODE` controls the opt-in private-issue read predicate: + +- `shadow` (default): log structured would-deny decisions but preserve existing reads +- `enforce`: return private issues only to implicit principals and active grantees +- `off`: skip the predicate and shadow logging + +Grant lookups use a five-second cache by default; override it with `PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS` when testing revocation timing. + ## Storage in Dev (Auto-Handled) For local development, the default storage provider is `local_disk`, which persists uploaded images/attachments at: diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 1065ea17274..503616a4930 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -34,7 +34,7 @@ These decisions close open questions from `SPEC.md` for V1. | Company model | Company is first-order; all business entities are company-scoped | | Board | Single human board operator per deployment | | Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting | -| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy | +| Visibility | Issues remain company-open by default. Opt-in `private` issues use task ACLs; deployment exposure flags remain independent of work-object privacy. | | Communication | Tasks + comments only (no separate chat system) | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | | Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree and resolve only eligible task-level plan confirmations; it is not board authority, active-run output monitoring, or general liveness recovery. | @@ -226,6 +226,8 @@ Routine execution issues add a routine-scoped env overlay after project env and - `project_workspace_id` uuid fk `project_workspaces.id` null - `goal_id` uuid fk `goals.id` null - `parent_id` uuid fk `issues.id` null +- `visibility` enum-like text: `open | private`, default `open` +- `privacy_root_issue_id` uuid fk `issues.id` null; private descendants point at their private subtree root - `title` text not null - `description` text null - `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled` @@ -268,6 +270,19 @@ Invariants: - `author_user_id` uuid fk `users.id` null - `body` text not null +## 7.7.1 `issue_access_grants` + +- `issue_id` uuid fk `issues.id` not null +- `subject_type` enum-like text: `user | agent` +- `subject_id` text not null +- `source` enum-like text: `explicit | assignment | project` +- `granted_by_user_id` text null +- `granted_by_agent_id` uuid fk `agents.id` null +- `created_at` timestamptz not null +- `revoked_at` timestamptz null + +An issue is readable when it is open, the actor is an implicit principal (`responsible_user_id`, `created_by_user_id`, `assignee_user_id`, or current `assignee_agent_id`), or the actor has an unrevoked grant on the issue/private subtree root. Instance administrators retain platform-wide access; company owner/admin roles do not imply private-issue read access. + ## 7.8 `heartbeat_runs` - `id` uuid pk @@ -549,7 +564,7 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and ## 9.4 Permission Terminology and Default Visibility Rule -Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface. +Paperclip keeps company-scoped visibility as the default. The task-ACL foundation adds opt-in private issues while preserving `open` for all existing and unspecified issues. The approved term set is: @@ -863,6 +878,8 @@ All endpoints are under `/api` and return JSON. - `POST /companies/:companyId/issues` - `GET /issues/:issueId` - `PATCH /issues/:issueId` +- `GET /issues/:issueId/access-grants` +- `POST /issues/:issueId/access-grants/:grantId/revoke` - `GET /issues/:issueId/documents` - `GET /issues/:issueId/documents/:key` - `PUT /issues/:issueId/documents/:key` diff --git a/packages/db/src/migrations/0198_issue_privacy_foundation.sql b/packages/db/src/migrations/0198_issue_privacy_foundation.sql new file mode 100644 index 00000000000..1de17d50d0c --- /dev/null +++ b/packages/db/src/migrations/0198_issue_privacy_foundation.sql @@ -0,0 +1,58 @@ +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "visibility" text DEFAULT 'open' NOT NULL; +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "privacy_root_issue_id" uuid; + +DO $$ BEGIN + ALTER TABLE "issues" + ADD CONSTRAINT "issues_visibility_check" CHECK ("visibility" IN ('open', 'private')); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "issues" + ADD CONSTRAINT "issues_privacy_root_issue_id_issues_id_fk" + FOREIGN KEY ("privacy_root_issue_id") REFERENCES "public"."issues"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE INDEX IF NOT EXISTS "issues_company_privacy_root_idx" + ON "issues" USING btree ("company_id", "privacy_root_issue_id"); + +CREATE TABLE IF NOT EXISTS "issue_access_grants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "issue_id" uuid NOT NULL, + "subject_type" text NOT NULL, + "subject_id" text NOT NULL, + "source" text NOT NULL, + "granted_by_user_id" text, + "granted_by_agent_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone, + CONSTRAINT "issue_access_grants_subject_type_check" CHECK ("subject_type" IN ('user', 'agent')), + CONSTRAINT "issue_access_grants_source_check" CHECK ("source" IN ('explicit', 'assignment', 'project')) +); + +DO $$ BEGIN + ALTER TABLE "issue_access_grants" + ADD CONSTRAINT "issue_access_grants_issue_id_issues_id_fk" + FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") + ON DELETE CASCADE ON UPDATE NO ACTION; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "issue_access_grants" + ADD CONSTRAINT "issue_access_grants_granted_by_agent_id_agents_id_fk" + FOREIGN KEY ("granted_by_agent_id") REFERENCES "public"."agents"("id") + ON DELETE SET NULL ON UPDATE NO ACTION; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE INDEX IF NOT EXISTS "issue_access_grants_subject_active_issue_idx" + ON "issue_access_grants" USING btree ("subject_type", "subject_id", "revoked_at", "issue_id"); +CREATE INDEX IF NOT EXISTS "issue_access_grants_issue_idx" + ON "issue_access_grants" USING btree ("issue_id", "created_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index f52642d2271..1c410fb5c3c 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1373,6 +1373,13 @@ "when": 1785175200000, "tag": "0197_decisions_v1", "breakpoints": true + }, + { + "idx": 198, + "version": "7", + "when": 1785542400000, + "tag": "0198_issue_privacy_foundation", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 87ab8c38afb..65dd2e29cfa 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -37,6 +37,7 @@ export { projectGoals } from "./project_goals.js"; export { goals } from "./goals.js"; export { folders } from "./folders.js"; export { issues } from "./issues.js"; +export { issueAccessGrants } from "./issue_access_grants.js"; export { issueWatchdogs } from "./issue_watchdogs.js"; export { issuePlanDecompositions } from "./issue_plan_decompositions.js"; export { issueRecoveryActions } from "./issue_recovery_actions.js"; diff --git a/packages/db/src/schema/issue_access_grants.ts b/packages/db/src/schema/issue_access_grants.ts new file mode 100644 index 00000000000..034ca9d2a66 --- /dev/null +++ b/packages/db/src/schema/issue_access_grants.ts @@ -0,0 +1,36 @@ +import { sql } from "drizzle-orm"; +import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { issues } from "./issues.js"; + +export const issueAccessGrants = pgTable( + "issue_access_grants", + { + id: uuid("id").primaryKey().defaultRandom(), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + subjectType: text("subject_type").notNull(), + subjectId: text("subject_id").notNull(), + source: text("source").notNull(), + grantedByUserId: text("granted_by_user_id"), + grantedByAgentId: uuid("granted_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + }, + (table) => ({ + subjectTypeCheck: check( + "issue_access_grants_subject_type_check", + sql`${table.subjectType} in ('user', 'agent')`, + ), + sourceCheck: check( + "issue_access_grants_source_check", + sql`${table.source} in ('explicit', 'assignment', 'project')`, + ), + predicateIdx: index("issue_access_grants_subject_active_issue_idx").on( + table.subjectType, + table.subjectId, + table.revokedAt, + table.issueId, + ), + issueIdx: index("issue_access_grants_issue_idx").on(table.issueId, table.createdAt), + }), +); diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index 5be902a3252..82467625b3a 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -9,6 +9,7 @@ import { jsonb, index, uniqueIndex, + check, } from "drizzle-orm/pg-core"; import { agents } from "./agents.js"; import { projects } from "./projects.js"; @@ -29,6 +30,8 @@ export const issues = pgTable( projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }), goalId: uuid("goal_id").references(() => goals.id), parentId: uuid("parent_id").references((): AnyPgColumn => issues.id), + visibility: text("visibility").notNull().default("open"), + privacyRootIssueId: uuid("privacy_root_issue_id").references((): AnyPgColumn => issues.id), title: text("title").notNull(), description: text("description"), status: text("status").notNull().default("backlog"), @@ -77,6 +80,7 @@ export const issues = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ + visibilityCheck: check("issues_visibility_check", sql`${table.visibility} in ('open', 'private')`), companyStatusIdx: index("issues_company_status_idx").on(table.companyId, table.status), companyHarnessKindIdx: index("issues_company_harness_kind_idx").on(table.companyId, table.harnessKind), assigneeStatusIdx: index("issues_company_assignee_status_idx").on( @@ -91,6 +95,7 @@ export const issues = pgTable( ), responsibleUserIdx: index("issues_company_responsible_user_idx").on(table.companyId, table.responsibleUserId), parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId), + privacyRootIdx: index("issues_company_privacy_root_idx").on(table.companyId, table.privacyRootIssueId), projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId), originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId), projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId), diff --git a/packages/plugins/examples/plugin-orchestration-smoke-example/tests/plugin.spec.ts b/packages/plugins/examples/plugin-orchestration-smoke-example/tests/plugin.spec.ts index 7ac7b8e519f..dd879eeef07 100644 --- a/packages/plugins/examples/plugin-orchestration-smoke-example/tests/plugin.spec.ts +++ b/packages/plugins/examples/plugin-orchestration-smoke-example/tests/plugin.spec.ts @@ -15,6 +15,8 @@ function issue(input: Partial & Pick projectWorkspaceId: null, goalId: null, parentId: null, + visibility: "open", + privacyRootIssueId: null, title, description: null, status: "todo", diff --git a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts index 37eea47747d..36d8a7c2b68 100644 --- a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts +++ b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts @@ -566,6 +566,8 @@ function paperclipIssue(overrides: Partial = {}): Issue { projectWorkspaceId: null, goalId: null, parentId: null, + visibility: "open", + privacyRootIssueId: null, title: "Design event ingestion controls", description: "Decide which Paperclip issues, comments, and documents can be ingested into the wiki.", status: "todo", diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index e3255c8058b..8838f3c6f13 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1597,6 +1597,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { projectWorkspaceId: null, goalId: input.goalId ?? null, parentId: input.parentId ?? null, + visibility: "open", + privacyRootIssueId: null, title: input.title, description: input.description ?? null, status: input.status ?? "todo", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f7296e2bd7a..9926507a5e3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -857,6 +857,10 @@ export type { ExternalObjectSummaryItem, CompactIssue, Issue, + IssueVisibility, + IssueAccessGrant, + IssueAccessGrantSubjectType, + IssueAccessGrantSource, IssueChangeReceiptEntry, IssueChanges, IssueAssigneeAdapterOverrides, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index bb4ead602cf..bc1cabd1359 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -559,6 +559,10 @@ export type { IssueSubtreeDiagnosticNode, IssueSubtreeDiagnosticEdge, IssueSubtreeDiagnosticsResponse, + IssueVisibility, + IssueAccessGrant, + IssueAccessGrantSubjectType, + IssueAccessGrantSource, IssueBlockerAttention, IssueBlockerAttentionReason, IssueBlockerAttentionState, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 1fdab004f73..8550718ffd7 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -40,6 +40,22 @@ import type { export type { IssueWorkMode }; +export type IssueVisibility = "open" | "private"; +export type IssueAccessGrantSubjectType = "user" | "agent"; +export type IssueAccessGrantSource = "explicit" | "assignment" | "project"; + +export interface IssueAccessGrant { + id: string; + issueId: string; + subjectType: IssueAccessGrantSubjectType; + subjectId: string; + source: IssueAccessGrantSource; + grantedByUserId: string | null; + grantedByAgentId: string | null; + createdAt: Date; + revokedAt: Date | null; +} + export interface IssueAncestorProject { id: string; name: string; @@ -734,6 +750,9 @@ export interface Issue { projectWorkspaceId: string | null; goalId: string | null; parentId: string | null; + /** Present on current API responses; optional for compatibility with older plugin payloads. */ + visibility?: IssueVisibility; + privacyRootIssueId?: string | null; ancestors?: IssueAncestor[]; title: string; description: string | null; @@ -818,6 +837,8 @@ export type CompactIssue = Pick< | "projectWorkspaceId" | "goalId" | "parentId" + | "visibility" + | "privacyRootIssueId" | "title" | "description" | "status" diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 57650ba0df6..557aeb744a5 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -13,6 +13,13 @@ import { import { createAgentSchema } from "./agent.js"; describe("issue validators", () => { + it("accepts open/private visibility on create and update", () => { + expect(createIssueSchema.parse({ title: "Private", visibility: "private" }).visibility).toBe("private"); + expect(createIssueSchema.parse({ title: "Open" }).visibility).toBe("open"); + expect(updateIssueSchema.parse({}).visibility).toBeUndefined(); + expect(updateIssueSchema.parse({ visibility: "private" }).visibility).toBe("private"); + expect(updateIssueSchema.safeParse({ visibility: "secret" }).success).toBe(false); + }); it("passes real line breaks through unchanged", () => { const parsed = createIssueSchema.parse({ title: "Follow up PR", diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 0510d45ea95..22c837a6259 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -427,6 +427,7 @@ const createIssueBaseSchema = z.object({ projectWorkspaceId: z.string().uuid().optional().nullable(), goalId: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), + visibility: z.enum(["open", "private"]).optional().default("open"), blockedByIssueIds: z.array(z.string().uuid()).optional(), unblockDescriptor: z.object({ owner: z.union([ @@ -535,7 +536,9 @@ export const updateIssueSchema = createIssueBaseSchema.omit({ createdByUserId: true, responsibleUserId: true, watchdog: true, + visibility: true, }).partial().extend({ + visibility: z.enum(["open", "private"]).optional(), requestDepth: issueRequestDepthInputSchema.optional(), assigneeAgentId: z.string().trim().min(1).optional().nullable(), comment: multilineTextSchema.pipe(z.string().min(1)).optional(), diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 1045d00aa3a..30e09cd7226 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, authUsers, @@ -7,6 +8,7 @@ import { companyMemberships, createDb, instanceUserRoles, + issueAccessGrants, issueComments, issues, principalPermissionGrants, @@ -18,7 +20,8 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { authorizationService } from "../services/authorization.js"; +import { authorizationService, type AuthorizationActor } from "../services/authorization.js"; +import { logger } from "../middleware/logger.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -75,6 +78,11 @@ async function createIssue( projectId?: string | null; parentId?: string | null; assigneeAgentId?: string | null; + assigneeUserId?: string | null; + createdByUserId?: string | null; + responsibleUserId?: string | null; + visibility?: "open" | "private"; + privacyRootIssueId?: string | null; originKind?: string | null; originId?: string | null; } = {}, @@ -90,6 +98,11 @@ async function createIssue( projectId: input.projectId ?? null, parentId: input.parentId ?? null, assigneeAgentId: input.assigneeAgentId ?? null, + assigneeUserId: input.assigneeUserId ?? null, + createdByUserId: input.createdByUserId ?? null, + responsibleUserId: input.responsibleUserId ?? null, + visibility: input.visibility ?? "open", + privacyRootIssueId: input.privacyRootIssueId ?? null, originKind: input.originKind ?? "manual", originId: input.originId ?? null, }) @@ -177,6 +190,7 @@ describeEmbeddedPostgres("authorization service", () => { await db.delete(principalPermissionGrants); await db.delete(companyMemberships); await db.delete(instanceUserRoles); + await db.delete(issueAccessGrants); await db.delete(issues); await db.delete(agents); await db.delete(projects); @@ -2188,4 +2202,101 @@ describeEmbeddedPostgres("authorization service", () => { resource: { type: "company", companyId: company.id }, })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); }); + + it("enforces private issue implicit principals, grants, revocation, and subtree inheritance", async () => { + const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + const previousTtl = process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS; + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS = "0"; + try { + const company = await createCompany(db, "IssuePrivacy"); + const ownerId = await createUser(db); + const memberId = await createUser(db); + const granteeId = await createUser(db); + await db.insert(companyMemberships).values([ownerId, memberId, granteeId].map((principalId) => ({ + companyId: company.id, + principalType: "user", + principalId, + status: "active", + membershipRole: "operator", + }))); + const assignedAgent = await createAgent(db, company.id); + const otherAgent = await createAgent(db, company.id); + const authz = authorizationService(db); + const userActor = (userId: string): AuthorizationActor => ({ type: "board", userId, source: "session" }); + const agentActor = (agentId: string): AuthorizationActor => ({ + type: "agent", + agentId, + companyId: company.id, + source: "agent_key", + }); + const decide = (actor: AuthorizationActor, issue: Awaited>) => authz.decide({ + actor, + action: "issue:read", + resource: { type: "issue", companyId: company.id, issueId: issue.id }, + }); + + const openIssue = await createIssue(db, company.id); + await expect(decide(userActor(memberId), openIssue)).resolves.toMatchObject({ allowed: true }); + + const privateRootId = randomUUID(); + const privateRoot = await createIssue(db, company.id, { + id: privateRootId, + visibility: "private", + privacyRootIssueId: privateRootId, + responsibleUserId: ownerId, + assigneeAgentId: assignedAgent.id, + }); + await expect(decide(userActor(memberId), privateRoot)).resolves.toMatchObject({ + allowed: false, + reason: "deny_issue_private", + }); + await expect(decide(agentActor(otherAgent.id), privateRoot)).resolves.toMatchObject({ + allowed: false, + reason: "deny_issue_private", + }); + await expect(decide(userActor(ownerId), privateRoot)).resolves.toMatchObject({ allowed: true }); + await expect(decide(agentActor(assignedAgent.id), privateRoot)).resolves.toMatchObject({ allowed: true }); + + const grant = await db.insert(issueAccessGrants).values({ + issueId: privateRoot.id, + subjectType: "user", + subjectId: granteeId, + source: "explicit", + grantedByUserId: ownerId, + }).returning().then((rows) => rows[0]!); + await expect(decide(userActor(granteeId), privateRoot)).resolves.toMatchObject({ allowed: true }); + + const child = await createIssue(db, company.id, { + parentId: privateRoot.id, + visibility: "private", + privacyRootIssueId: privateRoot.id, + }); + await expect(decide(userActor(granteeId), child)).resolves.toMatchObject({ allowed: true }); + + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id)); + await expect(decide(userActor(granteeId), privateRoot)).resolves.toMatchObject({ + allowed: false, + reason: "deny_issue_private", + }); + + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow"; + const warn = vi.spyOn(logger, "warn").mockImplementation(() => logger); + await expect(decide(userActor(memberId), privateRoot)).resolves.toMatchObject({ allowed: true }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + authzMode: "shadow", + issueId: privateRoot.id, + reason: "deny_issue_private", + }), + "issue privacy would deny read", + ); + warn.mockRestore(); + } finally { + if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; + if (previousTtl === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS; + else process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS = previousTtl; + } + }); }); diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index 0db84cccede..cda0b18d825 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -8,6 +8,7 @@ import { documents, issueComments, issueDocuments, + issueAccessGrants, issueLabels, issues, labels, @@ -23,6 +24,8 @@ import { companySearchBranchFetchLimit, companySearchService, } from "../services/company-search.js"; +import { issueReadSqlCondition } from "../services/authorization.js"; +import { issueService } from "../services/issues.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -87,6 +90,7 @@ describeEmbeddedPostgres("companySearchService", () => { await db.delete(documents); await db.delete(issueComments); await db.delete(issueLabels); + await db.delete(issueAccessGrants); await db.delete(issues); await db.delete(labels); await db.delete(projects); @@ -723,4 +727,58 @@ describeEmbeddedPostgres("companySearchService", () => { expect(ids, `query=${query} should not match unrelated issue`).not.toContain(rejected); } }); + + it("pushes private issue filtering into list, count, and company search queries", async () => { + const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; + try { + const companyId = await createCompany(); + const ownerId = `owner-${randomUUID()}`; + const otherId = `other-${randomUUID()}`; + const granteeId = `grantee-${randomUUID()}`; + const openId = await createIssue(companyId, { title: "privacy needle open" }); + const privateId = randomUUID(); + await createIssue(companyId, { + id: privateId, + title: "privacy needle private", + visibility: "private", + privacyRootIssueId: privateId, + responsibleUserId: ownerId, + }); + await db.insert(issueAccessGrants).values({ + issueId: privateId, + subjectType: "user", + subjectId: granteeId, + source: "explicit", + grantedByUserId: ownerId, + }); + + const issuesSvc = issueService(db); + const conditionFor = (userId: string) => issueReadSqlCondition(db, { + type: "board", + userId, + source: "session", + }); + const otherCondition = await conditionFor(otherId); + const ownerCondition = await conditionFor(ownerId); + const granteeCondition = await conditionFor(granteeId); + + await expect(issuesSvc.list(companyId, { readCondition: otherCondition })).resolves.toMatchObject([ + { id: openId }, + ]); + await expect(issuesSvc.count(companyId, { readCondition: otherCondition })).resolves.toBe(1); + await expect(issuesSvc.count(companyId, { readCondition: ownerCondition })).resolves.toBe(2); + await expect(issuesSvc.count(companyId, { readCondition: granteeCondition })).resolves.toBe(2); + + const result = await svc.search( + companyId, + companySearchQuerySchema.parse({ q: "privacy needle", scope: "issues" }), + { issueReadCondition: otherCondition }, + ); + expect(result.results.map((row) => row.id)).toEqual([openId]); + } finally { + if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; + } + }); }); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 15381dc270a..9f226f53244 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -831,8 +831,8 @@ describe("agent issue mutation checkout ownership", () => { const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/comments`); - expect(res.status, JSON.stringify(res.body)).toBe(403); - expect(res.body.error).toBe("Issue is outside this actor's authorization boundary"); + expect(res.status, JSON.stringify(res.body)).toBe(404); + expect(res.body.error).toBe("Issue not found"); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); }); @@ -847,8 +847,8 @@ describe("agent issue mutation checkout ownership", () => { const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/interactions`); - expect(res.status, JSON.stringify(res.body)).toBe(403); - expect(res.body.error).toBe("Issue is outside this actor's authorization boundary"); + expect(res.status, JSON.stringify(res.body)).toBe(404); + expect(res.body.error).toBe("Issue not found"); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); expect(mockIssueThreadInteractionService.listForIssue).not.toHaveBeenCalled(); }); @@ -893,8 +893,8 @@ describe("agent issue mutation checkout ownership", () => { const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/comments/comment-1`); - expect(res.status, JSON.stringify(res.body)).toBe(403); - expect(res.body.error).toBe("Issue is outside this actor's authorization boundary"); + expect(res.status, JSON.stringify(res.body)).toBe(404); + expect(res.body.error).toBe("Issue not found"); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); expect(mockIssueService.getComment).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index e3069cdb6a8..4a34a19dcf4 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2611,6 +2611,32 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { expect(child.responsibleUserId).toBe(responsibleUserId); }); + it("creates children under a private issue in the same private subtree", async () => { + const companyId = randomUUID(); + const parentIssueId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Private subtree company", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + await db.insert(issues).values({ + id: parentIssueId, + companyId, + title: "Private root", + visibility: "private", + privacyRootIssueId: parentIssueId, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + title: "Inherited private child", + visibility: "open", + }); + + expect(child.visibility).toBe("private"); + expect(child.privacyRootIssueId).toBe(parentIssueId); + }); + it("only honors explicit responsibleUserId for trusted issue create callers", async () => { const companyId = randomUUID(); const creatorUserId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e314a6d42f5..16c6aee1971 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2,7 +2,7 @@ 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 } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, notInArray, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -13,6 +13,7 @@ import { executionWorkspaces, heartbeatRuns, issueApprovals, + issueAccessGrants, issueComments, issueDocuments, issueExecutionDecisions, @@ -20,6 +21,7 @@ import { issueThreadInteractions, issues as issueRows, issueWorkProducts, + instanceUserRoles, pipelineCaseIssueLinks, pipelineCases, pipelineStages, @@ -168,7 +170,7 @@ import { ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, readAcceptedPlanConfirmationTarget, } from "../services/issues.js"; -import { authorizationDeniedDetails } from "../services/authorization.js"; +import { authorizationDeniedDetails, issueReadSqlCondition } from "../services/authorization.js"; import { environmentService } from "../services/environments.js"; import { environmentRuntimeService } from "../services/environment-runtime.js"; import { redactSensitiveText } from "../redaction.js"; @@ -220,6 +222,7 @@ const inboxArchiveBodySchema = z.object({ const externalObjectSummariesSchema = z.object({ issueIds: z.array(z.string().uuid()).max(1000), }).strict(); +const revokeIssueAccessGrantSchema = z.object({}).strict().default({}); const promoteLowTrustOutputSchema = z.object({ sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]), @@ -276,8 +279,16 @@ type RecoveryRevalidationTrigger = | "work_product" | "read_projection"; type CompanySearchService = { - extract(companyId: string, query: CompanySearchExtractQuery): Promise; - search(companyId: string, query: CompanySearchQuery): Promise; + extract( + companyId: string, + query: CompanySearchExtractQuery, + options?: { issueReadCondition?: SQL }, + ): Promise; + search( + companyId: string, + query: CompanySearchQuery, + options?: { issueReadCondition?: SQL }, + ): Promise; }; type ActivityIssueRelationSummary = { id: string; @@ -2159,6 +2170,8 @@ function toCompactIssue(issue: any): CompactIssue { projectWorkspaceId: issue.projectWorkspaceId, goalId: issue.goalId, parentId: issue.parentId, + visibility: issue.visibility, + privacyRootIssueId: issue.privacyRootIssueId, title: issue.title, description: issue.description, status: issue.status, @@ -3463,7 +3476,49 @@ export function issueRoutes( async function assertIssueReadAllowed(req: Request, res: Response, issue: Parameters[1]) { const decision = await decideIssueAccess(req, issue, "issue:read"); if (decision.allowed) return true; - res.status(403).json({ error: "Issue is outside this actor's authorization boundary" }); + res.status(404).json({ error: "Issue not found" }); + return false; + } + + async function assertCanManageIssuePrivacy( + req: Request, + res: Response, + issue: { companyId: string; responsibleUserId: string | null; createdByUserId: string | null; assigneeUserId: string | null }, + ) { + if (req.actor.type !== "board" || !req.actor.userId) { + res.status(403).json({ error: "Only a responsible user, issue owner, or administrator can manage issue privacy" }); + return false; + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; + const instanceAdmin = req.actor.source !== "cloud_tenant" + ? await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and( + eq(instanceUserRoles.userId, req.actor.userId), + eq(instanceUserRoles.role, "instance_admin"), + )) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + if (instanceAdmin) return true; + if ( + issue.responsibleUserId === req.actor.userId + || issue.createdByUserId === req.actor.userId + || issue.assigneeUserId === req.actor.userId + ) return true; + const membership = await db + .select({ role: companyMemberships.membershipRole }) + .from(companyMemberships) + .where(and( + eq(companyMemberships.companyId, issue.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, req.actor.userId), + eq(companyMemberships.status, "active"), + )) + .then((rows) => rows[0] ?? null); + if (membership && (membership.role === "owner" || membership.role === "admin")) return true; + res.status(403).json({ error: "Only a responsible user, issue owner, or administrator can manage issue privacy" }); return false; } @@ -4732,7 +4787,9 @@ export function issueRoutes( }); return; } - const result = await getSearchService().extract(companyId, parsedQuery.data); + const result = await getSearchService().extract(companyId, parsedQuery.data, { + issueReadCondition: await issueReadSqlCondition(db, req.actor), + }); res.json(result); }); @@ -4774,7 +4831,9 @@ export function issueRoutes( }); return; } - const result = await getSearchService().search(companyId, query); + const result = await getSearchService().search(companyId, query, { + issueReadCondition: await issueReadSqlCondition(db, req.actor), + }); res.json(result); }); @@ -4893,6 +4952,7 @@ export function issueRoutes( const offset = parsedOffset ?? 0; const listFilters: IssueFilters = { + readCondition: await issueReadSqlCondition(db, req.actor), attention: attention === "blocked" ? "blocked" : undefined, status: req.query.status as string | string[] | undefined, assigneeAgentId, @@ -5088,6 +5148,7 @@ export function issueRoutes( } const blockedCountFilters = { + readCondition: await issueReadSqlCondition(db, req.actor), attention: "blocked", status: req.query.status as string | string[] | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, @@ -7770,10 +7831,62 @@ export function issueRoutes( res.json(result); }); + router.get("/issues/:id/access-grants", 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; + if (!(await assertCanManageIssuePrivacy(req, res, issue))) return; + const grantIssueIds = [...new Set([issue.id, issue.privacyRootIssueId ?? issue.id])]; + const grants = await db + .select() + .from(issueAccessGrants) + .where(inArray(issueAccessGrants.issueId, grantIssueIds)) + .orderBy(desc(issueAccessGrants.createdAt), desc(issueAccessGrants.id)); + res.json(grants); + }); + + router.post( + "/issues/:id/access-grants/:grantId/revoke", + validate(revokeIssueAccessGrantSchema), + 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; + if (!(await assertCanManageIssuePrivacy(req, res, issue))) return; + const grantIssueIds = [...new Set([issue.id, issue.privacyRootIssueId ?? issue.id])]; + const [grant] = await db + .update(issueAccessGrants) + .set({ revokedAt: new Date() }) + .where(and( + eq(issueAccessGrants.id, req.params.grantId as string), + inArray(issueAccessGrants.issueId, grantIssueIds), + isNull(issueAccessGrants.revokedAt), + )) + .returning(); + if (!grant) { + res.status(404).json({ error: "Issue access grant not found" }); + return; + } + const actor = getActorInfo(req); + 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.revoked", + entityType: "issue_access_grant", + entityId: grant.id, + details: { issueId: grant.issueId, subjectType: grant.subjectType, subjectId: grant.subjectId }, + }); + res.json(grant); + }, + ); + router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!existing) return; + if (req.body.visibility !== undefined && !(await assertCanManageIssuePrivacy(req, res, existing))) return; assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, existing, req.body))) return; diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 5c228fd7d84..1eb561a24db 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -7,6 +7,7 @@ import { heartbeatRuns, instanceUserRoles, issueComments, + issueAccessGrants, issues, principalPermissionGrants, projects, @@ -119,6 +120,7 @@ export type AuthorizationDecision = { | "deny_no_grant" | "deny_policy_restricted" | "deny_low_trust_boundary" + | "deny_issue_private" | "deny_scope" | "deny_unsupported_action"; grant?: { @@ -457,6 +459,118 @@ const responsibleUserSnapshotCache = new Map< { expiresAt: number; promise: Promise } >(); +export type IssuePrivacyRow = { + id: string; + companyId: string; + visibility: string; + privacyRootIssueId: string | null; + responsibleUserId: string | null; + createdByUserId: string | null; + assigneeUserId: string | null; + assigneeAgentId: string | null; +}; + +const issuePrivacyGrantCache = new Map }>(); + +export function issuePrivacyMode(): "off" | "shadow" | "enforce" { + const mode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE?.trim().toLowerCase(); + if (mode === "off" || mode === "enforce") return mode; + return "shadow"; +} + +function issuePrivacyCacheTtlMs() { + const raw = process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS?.trim(); + if (!raw) return 5_000; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5_000; +} + +function issuePrivacyPrincipal(actor: AuthorizationActor) { + if (actor.type === "board" && actor.userId) return { type: "user" as const, id: actor.userId }; + if (actor.type === "agent" && actor.agentId) return { type: "agent" as const, id: actor.agentId }; + return null; +} + +function actorIsImplicitIssuePrincipal(actor: AuthorizationActor, issue: IssuePrivacyRow) { + const principal = issuePrivacyPrincipal(actor); + if (!principal) return false; + if (principal.type === "agent") return issue.assigneeAgentId === principal.id; + return issue.responsibleUserId === principal.id + || issue.createdByUserId === principal.id + || issue.assigneeUserId === principal.id; +} + +async function actorHasIssuePrivacyGrant(db: Db, actor: AuthorizationActor, issue: IssuePrivacyRow) { + const principal = issuePrivacyPrincipal(actor); + if (!principal) return false; + const rootId = issue.privacyRootIssueId ?? issue.id; + const cacheKey = `${principal.type}:${principal.id}:${issue.id}:${rootId}`; + const now = Date.now(); + const cached = issuePrivacyGrantCache.get(cacheKey); + if (cached && cached.expiresAt > now) return cached.promise; + const promise = db + .select({ id: issueAccessGrants.id }) + .from(issueAccessGrants) + .where(and( + eq(issueAccessGrants.subjectType, principal.type), + eq(issueAccessGrants.subjectId, principal.id), + isNull(issueAccessGrants.revokedAt), + inArray(issueAccessGrants.issueId, [...new Set([issue.id, rootId])]), + )) + .limit(1) + .then((rows) => rows.length > 0); + issuePrivacyGrantCache.set(cacheKey, { expiresAt: now + issuePrivacyCacheTtlMs(), promise }); + return promise; +} + +/** Canonical row-level issue privacy predicate, also consumed by run-derived checks. */ +export async function canActorReadIssuePrivacy(db: Db, actor: AuthorizationActor, issue: IssuePrivacyRow) { + if (issue.visibility !== "private") return true; + if (actorIsImplicitIssuePrincipal(actor, issue)) return true; + return actorHasIssuePrivacyGrant(db, actor, issue); +} + +/** SQL equivalent of canActorReadIssuePrivacy for list/count/search query pushdown. */ +export async function issueReadSqlCondition(db: Db, actor: AuthorizationActor): Promise> { + if (issuePrivacyMode() !== "enforce") return sql`true`; + if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql`true`; + if ( + actor.type === "board" + && !actor.ignoreInstanceAdmin + && actor.source !== "cloud_tenant" + && Boolean(await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and( + eq(instanceUserRoles.userId, actor.userId ?? ""), + eq(instanceUserRoles.role, "instance_admin"), + )) + .limit(1) + .then((rows) => rows[0] ?? null)) + ) return sql`true`; + + const principal = issuePrivacyPrincipal(actor); + const implicit = principal?.type === "agent" + ? eq(issues.assigneeAgentId, principal.id) + : principal?.type === "user" + ? or( + eq(issues.responsibleUserId, principal.id), + eq(issues.createdByUserId, principal.id), + eq(issues.assigneeUserId, principal.id), + )! + : sql`false`; + const grant = principal + ? sql`exists ( + select 1 from ${issueAccessGrants} + where ${issueAccessGrants.subjectType} = ${principal.type} + and ${issueAccessGrants.subjectId} = ${principal.id} + and ${issueAccessGrants.revokedAt} is null + and ${issueAccessGrants.issueId} in (${issues.id}, coalesce(${issues.privacyRootIssueId}, ${issues.id})) + )` + : sql`false`; + return sql`(${issues.visibility} = 'open' or ${implicit} or ${grant})`; +} + function responsibleUserSnapshotTtlMs() { const raw = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_CACHE_TTL_MS?.trim(); if (!raw) return 5_000; @@ -2177,7 +2291,50 @@ export function authorizationService(db: Db) { scope?: Record | null; }): Promise { const agentDecision = await decideBase(input); - return applyResponsibleUserIntersection(input, agentDecision); + const intersectedDecision = await applyResponsibleUserIntersection(input, agentDecision); + if ( + input.action !== "issue:read" + || input.resource.type !== "issue" + || !input.resource.issueId + || !intersectedDecision.allowed + || intersectedDecision.reason === "allow_instance_admin" + || intersectedDecision.reason === "allow_local_board" + || issuePrivacyMode() === "off" + ) return intersectedDecision; + + const issue = await db + .select({ + id: issues.id, + companyId: issues.companyId, + visibility: issues.visibility, + privacyRootIssueId: issues.privacyRootIssueId, + responsibleUserId: issues.responsibleUserId, + createdByUserId: issues.createdByUserId, + assigneeUserId: issues.assigneeUserId, + assigneeAgentId: issues.assigneeAgentId, + }) + .from(issues) + .where(and(eq(issues.id, input.resource.issueId), eq(issues.companyId, input.resource.companyId))) + .then((rows) => rows[0] ?? null); + if (!issue || await canActorReadIssuePrivacy(db, input.actor, issue)) return intersectedDecision; + + const denied = deny({ + action: input.action, + reason: "deny_issue_private", + explanation: "Issue is private and the actor is not an implicit principal or active grantee.", + }); + logger.warn({ + authzMode: issuePrivacyMode(), + action: input.action, + companyId: issue.companyId, + issueId: issue.id, + privacyRootIssueId: issue.privacyRootIssueId, + actorType: input.actor.type, + actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null, + actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null, + reason: denied.reason, + }, "issue privacy would deny read"); + return issuePrivacyMode() === "shadow" ? intersectedDecision : denied; } return { diff --git a/server/src/services/company-search-extract.ts b/server/src/services/company-search-extract.ts index e8c59d17f15..81c491ace36 100644 --- a/server/src/services/company-search-extract.ts +++ b/server/src/services/company-search-extract.ts @@ -151,7 +151,11 @@ function extractMatches(sources: ExtractSource[], query: CompanySearchExtractQue export function companySearchExtractService(db: Db) { return { - extract: async (companyId: string, query: CompanySearchExtractQuery): Promise => { + extract: async ( + companyId: string, + query: CompanySearchExtractQuery, + options?: { issueReadCondition?: SQL }, + ): Promise => { const containsPattern = `%${escapeLikePattern(query.contains)}%`; const urlPattern = urlContainsPattern(query.contains); const scopeConditions: SQL[] = []; @@ -198,6 +202,7 @@ export function companySearchExtractService(db: Db) { visibleIssueCondition(), or(...scopeConditions)!, ]; + if (options?.issueReadCondition) conditions.push(options.issueReadCondition); if (query.status.length > 0) conditions.push(inArray(issues.status, query.status)); const updatedWithin = updatedWithinStart(query.updatedWithin); if (updatedWithin) conditions.push(gte(issues.updatedAt, updatedWithin)); diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 4de7430cc90..0e77f9a8bd8 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -558,7 +558,11 @@ export function companySearchService(db: Db) { const extractService = companySearchExtractService(db); return { extract: extractService.extract, - search: async (companyId: string, query: CompanySearchQuery): Promise => { + search: async ( + companyId: string, + query: CompanySearchQuery, + options?: { issueReadCondition?: SQL }, + ): Promise => { const normalizedQuery = normalizeQuery(query.q); const hasSearchText = normalizedQuery.length > 0; const tokens = tokenizeQuery(normalizedQuery); @@ -929,6 +933,7 @@ export function companySearchService(db: Db) { FROM issues WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()} + AND ${options?.issueReadCondition ?? sql`true`} ${matchedWhere} ) ${sql.join(branches, sql` UNION ALL `)} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 4acf9bec04b..eaca0e2f655 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -517,6 +517,7 @@ export interface IssueFilters { includeLiveDescendantSummary?: boolean; hasPlanDocument?: boolean; lowTrustBoundary?: LowTrustBoundary & { companyId: string }; + readCondition?: SQL; q?: string; limit?: number; offset?: number; @@ -2611,6 +2612,8 @@ const issueListSelect = { projectWorkspaceId: issues.projectWorkspaceId, goalId: issues.goalId, parentId: issues.parentId, + visibility: issues.visibility, + privacyRootIssueId: issues.privacyRootIssueId, title: issues.title, description: sql` CASE @@ -3629,6 +3632,7 @@ async function blockedInboxIssueConditions( visibleIssueCondition(), notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]), ]; + if (filters?.readCondition) conditions.push(filters.readCondition); const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; @@ -4894,6 +4898,7 @@ export function issueService(db: Db) { } const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()]; + if (filters?.readCondition) conditions.push(filters.readCondition); const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId); assertValidAssigneeAgentFilter(assigneeAgentFilter); const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit) @@ -5144,6 +5149,7 @@ export function issueService(db: Db) { } const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()]; + if (filters?.readCondition) conditions.push(filters.readCondition); const statuses = parseStatusFilter(filters?.status); if (statuses.length === 1) conditions.push(eq(issues.status, statuses[0]!)); else if (statuses.length > 1) conditions.push(inArray(issues.status, statuses)); @@ -6580,8 +6586,31 @@ export function issueService(db: Db) { trustExplicitResponsibleUserId: trustExplicitResponsibleUserId === true, }); + let visibility = issueData.visibility ?? "open"; + let privacyRootIssueId: string | null = null; + if (issueData.parentId) { + const parentPrivacy = await tx + .select({ + visibility: issues.visibility, + privacyRootIssueId: issues.privacyRootIssueId, + }) + .from(issues) + .where(and(eq(issues.id, issueData.parentId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (parentPrivacy?.visibility === "private") { + visibility = "private"; + privacyRootIssueId = parentPrivacy.privacyRootIssueId ?? issueData.parentId; + } + } + if (visibility === "private" && !privacyRootIssueId) { + issueData.id ??= randomUUID(); + privacyRootIssueId = issueData.id; + } + const values = { ...issueData, + visibility, + privacyRootIssueId, responsibleUserId, requestDepth: clampIssueRequestDepth(issueData.requestDepth), originKind: issueData.originKind ?? "manual", @@ -6936,6 +6965,11 @@ export function issueService(db: Db) { ...issueData, updatedAt: new Date(), }; + if (issueData.visibility === "private") { + patch.privacyRootIssueId = existing.privacyRootIssueId ?? existing.id; + } else if (issueData.visibility === "open") { + patch.privacyRootIssueId = null; + } if (existing.status !== "blocked" && issueData.status === "blocked") { patch.blockedTransitionAt = patch.updatedAt; patch.blockedOwnerNotifiedAt = null;