feat: add issue privacy ACL foundation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-01 22:54:01 +00:00
1 parent 0f12721ee9
commit cd86192ca2
24 files changed
+709 -21

No files matched your search

+10
View File
@@ -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:
+19 -2
View File
@@ -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`
@@ -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");
@@ -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
}
]
}
+1
View File
@@ -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";
@@ -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),
}),
);
+5
View File
@@ -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),
@@ -15,6 +15,8 @@ function issue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "title">
projectWorkspaceId: null,
goalId: null,
parentId: null,
visibility: "open",
privacyRootIssueId: null,
title,
description: null,
status: "todo",
@@ -566,6 +566,8 @@ function paperclipIssue(overrides: Partial<Issue> = {}): 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",
+2
View File
@@ -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",
+4
View File
@@ -857,6 +857,10 @@ export type {
ExternalObjectSummaryItem,
CompactIssue,
Issue,
IssueVisibility,
IssueAccessGrant,
IssueAccessGrantSubjectType,
IssueAccessGrantSource,
IssueChangeReceiptEntry,
IssueChanges,
IssueAssigneeAdapterOverrides,
+4
View File
@@ -559,6 +559,10 @@ export type {
IssueSubtreeDiagnosticNode,
IssueSubtreeDiagnosticEdge,
IssueSubtreeDiagnosticsResponse,
IssueVisibility,
IssueAccessGrant,
IssueAccessGrantSubjectType,
IssueAccessGrantSource,
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
+21
View File
@@ -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"
@@ -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",
+3
View File
@@ -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(),
@@ -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<ReturnType<typeof createIssue>>) => 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;
}
});
});
@@ -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;
}
});
});
@@ -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();
});
@@ -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();
+120 -7
View File
@@ -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<CompanySearchExtractResponse>;
search(companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse>;
extract(
companyId: string,
query: CompanySearchExtractQuery,
options?: { issueReadCondition?: SQL<boolean> },
): Promise<CompanySearchExtractResponse>;
search(
companyId: string,
query: CompanySearchQuery,
options?: { issueReadCondition?: SQL<boolean> },
): Promise<CompanySearchResponse>;
};
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<typeof decideIssueAccess>[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;
+159 -2
View File
@@ -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<ResponsibleUserSnapshot> }
>();
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<string, { expiresAt: number; promise: Promise<boolean> }>();
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<SQL<boolean>> {
if (issuePrivacyMode() !== "enforce") return sql<boolean>`true`;
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql<boolean>`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<boolean>`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<boolean>`false`;
const grant = principal
? sql<boolean>`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<boolean>`false`;
return sql<boolean>`(${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<string, unknown> | null;
}): Promise<AuthorizationDecision> {
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 {
@@ -151,7 +151,11 @@ function extractMatches(sources: ExtractSource[], query: CompanySearchExtractQue
export function companySearchExtractService(db: Db) {
return {
extract: async (companyId: string, query: CompanySearchExtractQuery): Promise<CompanySearchExtractResponse> => {
extract: async (
companyId: string,
query: CompanySearchExtractQuery,
options?: { issueReadCondition?: SQL<boolean> },
): Promise<CompanySearchExtractResponse> => {
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));
+6 -1
View File
@@ -558,7 +558,11 @@ export function companySearchService(db: Db) {
const extractService = companySearchExtractService(db);
return {
extract: extractService.extract,
search: async (companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse> => {
search: async (
companyId: string,
query: CompanySearchQuery,
options?: { issueReadCondition?: SQL<boolean> },
): Promise<CompanySearchResponse> => {
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<boolean>`true`}
${matchedWhere}
)
${sql.join(branches, sql` UNION ALL `)}
+34
View File
@@ -517,6 +517,7 @@ export interface IssueFilters {
includeLiveDescendantSummary?: boolean;
hasPlanDocument?: boolean;
lowTrustBoundary?: LowTrustBoundary & { companyId: string };
readCondition?: SQL<boolean>;
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<string | null>`
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;