From a0dc051f6b3dde6068357fe27c6c61eb4e847561 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 26 Sep 2025 11:12:46 -0300 Subject: [PATCH] Add temporal permissions cache handling --- .../permission/permission-service-types.ts | 3 +- .../services/permission/permission-service.ts | 91 +++++++++++++++---- backend/src/keystore/keystore.ts | 18 ++-- 3 files changed, 89 insertions(+), 23 deletions(-) diff --git a/backend/src/ee/services/permission/permission-service-types.ts b/backend/src/ee/services/permission/permission-service-types.ts index e56851061..9e5d9700b 100644 --- a/backend/src/ee/services/permission/permission-service-types.ts +++ b/backend/src/ee/services/permission/permission-service-types.ts @@ -1,5 +1,6 @@ import { MongoAbility, RawRuleOf } from "@casl/ability"; import { MongoQuery } from "@ucast/mongo2js"; +import { Knex } from "knex"; import { ActionProjectType } from "@app/db/schemas"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; @@ -283,7 +284,7 @@ export type TPermissionServiceFactory = { projectId: string; checkPermissions: ProjectPermissionSet; }) => Promise; - invalidateProjectPermissionCache: (projectId: string) => Promise; + invalidateProjectPermissionCache: (projectId: string, tx?: Knex) => Promise; invalidateUserProjectPermissionCache: (userId: string) => Promise; invalidateIdentityProjectPermissionCache: (identityId: string) => Promise; }; diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index a2581779f..b428acce5 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -3,6 +3,7 @@ import { PackRule, unpackRules } from "@casl/ability/extra"; import { requestContext } from "@fastify/request-context"; import { MongoQuery } from "@ucast/mongo2js"; import handlebars from "handlebars"; +import { Knex } from "knex"; import { ActionProjectType, @@ -87,9 +88,13 @@ export const permissionServiceFactory = ({ }); }; - const invalidateProjectPermissionCache = async (projectId: string) => { - const pattern = KeyStorePrefixes.ProjectPermissionPattern(projectId); - await keyStore.deleteItems({ pattern }); + const invalidateProjectPermissionCache = async (projectId: string, tx?: Knex) => { + const projectPermissionDalVersionKey = KeyStorePrefixes.ProjectPermissionDalVersion(projectId); + await keyStore.pgIncrementBy(projectPermissionDalVersionKey, { + incr: 1, + tx, + expiry: KeyStoreTtls.ProjectPermissionDalVersionTtl + }); }; const invalidateUserProjectPermissionCache = async (userId: string) => { @@ -102,6 +107,59 @@ export const permissionServiceFactory = ({ await keyStore.deleteItems({ pattern }); }; + const calculateProjectPermissionTtl = (membership: unknown): number => { + const now = new Date(); + let minTtl = KeyStoreTtls.ProjectPermissionCacheInSeconds; + + const getMinEndTime = (items: Array<{ temporaryAccessEndTime?: Date | null; isTemporary?: boolean }>) => { + return items + .filter((item) => item.isTemporary && item.temporaryAccessEndTime) + .map((item) => item.temporaryAccessEndTime!) + .filter((endTime) => endTime > now) + .reduce((min, endTime) => (!min || endTime < min ? endTime : min), null as Date | null); + }; + + const roleTimes: Date[] = []; + const additionalPrivilegeTimes: Date[] = []; + + if ( + membership && + typeof membership === "object" && + "roles" in membership && + Array.isArray((membership as Record).roles) + ) { + const roles = (membership as Record).roles as Array<{ + temporaryAccessEndTime?: Date | null; + isTemporary?: boolean; + }>; + const minRoleEndTime = getMinEndTime(roles); + if (minRoleEndTime) roleTimes.push(minRoleEndTime); + } + + if ( + membership && + typeof membership === "object" && + "additionalPrivileges" in membership && + Array.isArray((membership as Record).additionalPrivileges) + ) { + const additionalPrivileges = (membership as Record).additionalPrivileges as Array<{ + temporaryAccessEndTime?: Date | null; + isTemporary?: boolean; + }>; + const minAdditionalEndTime = getMinEndTime(additionalPrivileges); + if (minAdditionalEndTime) additionalPrivilegeTimes.push(minAdditionalEndTime); + } + + const allEndTimes = [...roleTimes, ...additionalPrivilegeTimes]; + if (allEndTimes.length > 0) { + const nearestEndTime = allEndTimes.reduce((min, endTime) => (!min || endTime < min ? endTime : min)); + const timeUntilExpiry = Math.floor((nearestEndTime.getTime() - now.getTime()) / 1000); + minTtl = Math.min(minTtl, Math.max(1, timeUntilExpiry)); + } + + return minTtl; + }; + const buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDTO) => { const rules = projectUserRoles .map(({ role, permissions }) => { @@ -596,13 +654,6 @@ export const permissionServiceFactory = ({ actorId = assumedPrivilegeDetailsCtx.actorId; } - const cacheKey = KeyStorePrefixes.ProjectPermission( - projectId, - actor, - actorId, - actionProjectType || ActionProjectType.Any - ); - if (actor === ActorType.SERVICE) { return getServiceTokenProjectPermission({ serviceTokenId: actorId, @@ -612,6 +663,18 @@ export const permissionServiceFactory = ({ }) as Promise>; } + const cachedProjectPermissionVersion = await keyStore.pgGetIntItem( + KeyStorePrefixes.ProjectPermissionDalVersion(projectId) + ); + const projectPermissionVersion = Number(cachedProjectPermissionVersion || 0); + const cacheKey = KeyStorePrefixes.ProjectPermission( + projectId, + projectPermissionVersion, + actor, + actorId, + actionProjectType || ActionProjectType.Any + ); + try { const cachedData = await keyStore.getItem(cacheKey); if (cachedData) { @@ -622,7 +685,6 @@ export const permissionServiceFactory = ({ [key: string]: unknown; }; }; - // Reconstruct the permission object from cached rules const permission = createMongoAbility(parsed.rules, { conditionsMatcher }); @@ -673,11 +735,8 @@ export const permissionServiceFactory = ({ membership: result.membership }; - await keyStore.setItemWithExpiry( - cacheKey, - KeyStoreTtls.ProjectPermissionCacheInSeconds, - JSON.stringify(cacheData) - ); + const ttl = calculateProjectPermissionTtl(result.membership); + await keyStore.setItemWithExpiry(cacheKey, ttl, JSON.stringify(cacheData)); } catch (error) { logger.error(error, "Failed to cache project permission"); } diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 9da4db673..17f4aa493 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -65,11 +65,16 @@ export const KeyStorePrefixes = { ActiveSSEConnections: (projectId: string, identityId: string, connectionId: string) => `sse-connections:${projectId}:${identityId}:${connectionId}` as const, - ProjectPermission: (projectId: string, actorType: string, actorId: string, actionProjectType: string) => - `project-permission:${projectId}:${actorType}:${actorId}:${actionProjectType}` as const, - ProjectPermissionPattern: (projectId: string) => `project-permission:${projectId}:*` as const, - UserProjectPermissionPattern: (userId: string) => `project-permission:*:USER:${userId}:*` as const, - IdentityProjectPermissionPattern: (identityId: string) => `project-permission:*:IDENTITY:${identityId}:*` as const, + ProjectPermission: ( + projectId: string, + version: number, + actorType: string, + actorId: string, + actionProjectType: string + ) => `project-permission:${projectId}:${version}:${actorType}:${actorId}:${actionProjectType}` as const, + ProjectPermissionDalVersion: (projectId: string) => `project-permission:${projectId}:dal-version` as const, + UserProjectPermissionPattern: (userId: string) => `project-permission:*:*:USER:${userId}:*` as const, + IdentityProjectPermissionPattern: (identityId: string) => `project-permission:*:*:IDENTITY:${identityId}:*` as const, GroupMemberProjectPermissionPattern: (projectId: string, groupId: string) => `group-member-project-permission:${projectId}:${groupId}:*` as const }; @@ -78,7 +83,8 @@ export const KeyStoreTtls = { SetSyncSecretIntegrationLastRunTimestampInSeconds: 60, SetSecretSyncLastRunTimestampInSeconds: 60, AccessTokenStatusUpdateInSeconds: 120, - ProjectPermissionCacheInSeconds: 300 // 5 minutes + ProjectPermissionCacheInSeconds: 300, // 5 minutes + ProjectPermissionDalVersionTtl: "15m" // Project permission DAL version TTL }; type TDeleteItems = {