mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat: add issue privacy ACL foundation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0f12721ee9
commit
cd86192ca2
24 files changed
+709
-21
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -857,6 +857,10 @@ export type {
|
||||
ExternalObjectSummaryItem,
|
||||
CompactIssue,
|
||||
Issue,
|
||||
IssueVisibility,
|
||||
IssueAccessGrant,
|
||||
IssueAccessGrantSubjectType,
|
||||
IssueAccessGrantSource,
|
||||
IssueChangeReceiptEntry,
|
||||
IssueChanges,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
|
||||
@@ -559,6 +559,10 @@ export type {
|
||||
IssueSubtreeDiagnosticNode,
|
||||
IssueSubtreeDiagnosticEdge,
|
||||
IssueSubtreeDiagnosticsResponse,
|
||||
IssueVisibility,
|
||||
IssueAccessGrant,
|
||||
IssueAccessGrantSubjectType,
|
||||
IssueAccessGrantSource,
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 `)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user