mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
fix: bound issue privacy grant cache
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user