From 6959c3d365f8e96cce3f16f7fc47d24919a6861a Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 1 Aug 2026 17:38:46 +0000 Subject: [PATCH] fix: bound issue privacy grant cache Co-Authored-By: Paperclip --- .../__tests__/authorization-service.test.ts | 33 +++++++++++++++- server/src/services/authorization.ts | 39 ++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 9437c8dd986..40c80c11162 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -20,7 +20,14 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { authorizationService, type AuthorizationActor } from "../services/authorization.js"; +import { + __clearIssuePrivacyGrantCacheForTests, + __getIssuePrivacyGrantCacheSizeForTests, + authorizationService, + canActorReadIssuePrivacy, + ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES, + type AuthorizationActor, +} from "../services/authorization.js"; import { logger } from "../middleware/logger.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -2299,4 +2306,28 @@ describeEmbeddedPostgres("authorization service", () => { else process.env.PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS = previousTtl; } }); + + it("bounds the private issue grant cache across distinct issues", async () => { + __clearIssuePrivacyGrantCacheForTests(); + try { + const actor: AuthorizationActor = { type: "board", userId: randomUUID(), source: "session" }; + for (let index = 0; index < ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES + 5; index += 1) { + const issueId = randomUUID(); + await expect(canActorReadIssuePrivacy(db, actor, { + id: issueId, + companyId: randomUUID(), + visibility: "private", + privacyRootIssueId: issueId, + responsibleUserId: null, + createdByUserId: null, + assigneeUserId: null, + assigneeAgentId: null, + })).resolves.toBe(false); + } + + expect(__getIssuePrivacyGrantCacheSizeForTests()).toBe(ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES); + } finally { + __clearIssuePrivacyGrantCacheForTests(); + } + }); }); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 4d5ce20bdcb..95723d53ecb 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -470,8 +470,39 @@ export type IssuePrivacyRow = { assigneeAgentId: string | null; }; +export const ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES = 256; const issuePrivacyGrantCache = new Map }>(); +export function __getIssuePrivacyGrantCacheSizeForTests() { + return issuePrivacyGrantCache.size; +} + +export function __clearIssuePrivacyGrantCacheForTests() { + issuePrivacyGrantCache.clear(); +} + +function pruneExpiredIssuePrivacyGrantCache(now: number) { + for (const [key, entry] of issuePrivacyGrantCache) { + if (entry.expiresAt <= now) issuePrivacyGrantCache.delete(key); + } +} + +function setIssuePrivacyGrantCacheEntry( + key: string, + entry: { expiresAt: number; promise: Promise }, +) { + if (issuePrivacyGrantCache.size >= ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES) { + pruneExpiredIssuePrivacyGrantCache(Date.now()); + } + issuePrivacyGrantCache.delete(key); + issuePrivacyGrantCache.set(key, entry); + while (issuePrivacyGrantCache.size > ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES) { + const oldestKey = issuePrivacyGrantCache.keys().next().value as string | undefined; + if (oldestKey === undefined) return; + issuePrivacyGrantCache.delete(oldestKey); + } +} + export function issuePrivacyMode(): "off" | "shadow" | "enforce" { const mode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE?.trim().toLowerCase(); if (mode === "off" || mode === "enforce") return mode; @@ -507,7 +538,11 @@ async function actorHasIssuePrivacyGrant(db: Db, actor: AuthorizationActor, issu 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; + if (cached && cached.expiresAt > now) { + setIssuePrivacyGrantCacheEntry(cacheKey, cached); + return cached.promise; + } + if (cached) issuePrivacyGrantCache.delete(cacheKey); const promise = db .select({ id: issueAccessGrants.id }) .from(issueAccessGrants) @@ -519,7 +554,7 @@ async function actorHasIssuePrivacyGrant(db: Db, actor: AuthorizationActor, issu )) .limit(1) .then((rows) => rows.length > 0); - issuePrivacyGrantCache.set(cacheKey, { expiresAt: now + issuePrivacyCacheTtlMs(), promise }); + setIssuePrivacyGrantCacheEntry(cacheKey, { expiresAt: now + issuePrivacyCacheTtlMs(), promise }); const granted = await promise; // Positive authorization decisions must observe revocation on the next read. // Keep only negative decisions cached; grant creation paths already tolerate