fix: bound issue privacy grant cache

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-01 22:54:01 +00:00
1 parent 221b1cdbe4
commit 6959c3d365
2 files changed
+69 -3

No files matched your search

@@ -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();
}
});
});
+37 -2
View File
@@ -470,8 +470,39 @@ export type IssuePrivacyRow = {
assigneeAgentId: string | null;
};
export const ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES = 256;
const issuePrivacyGrantCache = new Map<string, { expiresAt: number; promise: Promise<boolean> }>();
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<boolean> },
) {
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