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

@@ -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;