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

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