Add temporal permissions cache handling

This commit is contained in:
Carlos Monastyrski
2025-09-26 11:12:46 -03:00
parent 98896d4118
commit a0dc051f6b
3 changed files with 89 additions and 23 deletions
@@ -1,5 +1,6 @@
import { MongoAbility, RawRuleOf } from "@casl/ability"; import { MongoAbility, RawRuleOf } from "@casl/ability";
import { MongoQuery } from "@ucast/mongo2js"; import { MongoQuery } from "@ucast/mongo2js";
import { Knex } from "knex";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
@@ -283,7 +284,7 @@ export type TPermissionServiceFactory = {
projectId: string; projectId: string;
checkPermissions: ProjectPermissionSet; checkPermissions: ProjectPermissionSet;
}) => Promise<boolean>; }) => Promise<boolean>;
invalidateProjectPermissionCache: (projectId: string) => Promise<void>; invalidateProjectPermissionCache: (projectId: string, tx?: Knex) => Promise<void>;
invalidateUserProjectPermissionCache: (userId: string) => Promise<void>; invalidateUserProjectPermissionCache: (userId: string) => Promise<void>;
invalidateIdentityProjectPermissionCache: (identityId: string) => Promise<void>; invalidateIdentityProjectPermissionCache: (identityId: string) => Promise<void>;
}; };
@@ -3,6 +3,7 @@ import { PackRule, unpackRules } from "@casl/ability/extra";
import { requestContext } from "@fastify/request-context"; import { requestContext } from "@fastify/request-context";
import { MongoQuery } from "@ucast/mongo2js"; import { MongoQuery } from "@ucast/mongo2js";
import handlebars from "handlebars"; import handlebars from "handlebars";
import { Knex } from "knex";
import { import {
ActionProjectType, ActionProjectType,
@@ -87,9 +88,13 @@ export const permissionServiceFactory = ({
}); });
}; };
const invalidateProjectPermissionCache = async (projectId: string) => { const invalidateProjectPermissionCache = async (projectId: string, tx?: Knex) => {
const pattern = KeyStorePrefixes.ProjectPermissionPattern(projectId); const projectPermissionDalVersionKey = KeyStorePrefixes.ProjectPermissionDalVersion(projectId);
await keyStore.deleteItems({ pattern }); await keyStore.pgIncrementBy(projectPermissionDalVersionKey, {
incr: 1,
tx,
expiry: KeyStoreTtls.ProjectPermissionDalVersionTtl
});
}; };
const invalidateUserProjectPermissionCache = async (userId: string) => { const invalidateUserProjectPermissionCache = async (userId: string) => {
@@ -102,6 +107,59 @@ export const permissionServiceFactory = ({
await keyStore.deleteItems({ pattern }); 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<string, unknown>).roles)
) {
const roles = (membership as Record<string, unknown>).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<string, unknown>).additionalPrivileges)
) {
const additionalPrivileges = (membership as Record<string, unknown>).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 buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDTO) => {
const rules = projectUserRoles const rules = projectUserRoles
.map(({ role, permissions }) => { .map(({ role, permissions }) => {
@@ -596,13 +654,6 @@ export const permissionServiceFactory = ({
actorId = assumedPrivilegeDetailsCtx.actorId; actorId = assumedPrivilegeDetailsCtx.actorId;
} }
const cacheKey = KeyStorePrefixes.ProjectPermission(
projectId,
actor,
actorId,
actionProjectType || ActionProjectType.Any
);
if (actor === ActorType.SERVICE) { if (actor === ActorType.SERVICE) {
return getServiceTokenProjectPermission({ return getServiceTokenProjectPermission({
serviceTokenId: actorId, serviceTokenId: actorId,
@@ -612,6 +663,18 @@ export const permissionServiceFactory = ({
}) as Promise<TProjectPermissionRT<T>>; }) as Promise<TProjectPermissionRT<T>>;
} }
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 { try {
const cachedData = await keyStore.getItem(cacheKey); const cachedData = await keyStore.getItem(cacheKey);
if (cachedData) { if (cachedData) {
@@ -622,7 +685,6 @@ export const permissionServiceFactory = ({
[key: string]: unknown; [key: string]: unknown;
}; };
}; };
// Reconstruct the permission object from cached rules
const permission = createMongoAbility<ProjectPermissionSet>(parsed.rules, { const permission = createMongoAbility<ProjectPermissionSet>(parsed.rules, {
conditionsMatcher conditionsMatcher
}); });
@@ -673,11 +735,8 @@ export const permissionServiceFactory = ({
membership: result.membership membership: result.membership
}; };
await keyStore.setItemWithExpiry( const ttl = calculateProjectPermissionTtl(result.membership);
cacheKey, await keyStore.setItemWithExpiry(cacheKey, ttl, JSON.stringify(cacheData));
KeyStoreTtls.ProjectPermissionCacheInSeconds,
JSON.stringify(cacheData)
);
} catch (error) { } catch (error) {
logger.error(error, "Failed to cache project permission"); logger.error(error, "Failed to cache project permission");
} }
+12 -6
View File
@@ -65,11 +65,16 @@ export const KeyStorePrefixes = {
ActiveSSEConnections: (projectId: string, identityId: string, connectionId: string) => ActiveSSEConnections: (projectId: string, identityId: string, connectionId: string) =>
`sse-connections:${projectId}:${identityId}:${connectionId}` as const, `sse-connections:${projectId}:${identityId}:${connectionId}` as const,
ProjectPermission: (projectId: string, actorType: string, actorId: string, actionProjectType: string) => ProjectPermission: (
`project-permission:${projectId}:${actorType}:${actorId}:${actionProjectType}` as const, projectId: string,
ProjectPermissionPattern: (projectId: string) => `project-permission:${projectId}:*` as const, version: number,
UserProjectPermissionPattern: (userId: string) => `project-permission:*:USER:${userId}:*` as const, actorType: string,
IdentityProjectPermissionPattern: (identityId: string) => `project-permission:*:IDENTITY:${identityId}:*` as const, 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) => GroupMemberProjectPermissionPattern: (projectId: string, groupId: string) =>
`group-member-project-permission:${projectId}:${groupId}:*` as const `group-member-project-permission:${projectId}:${groupId}:*` as const
}; };
@@ -78,7 +83,8 @@ export const KeyStoreTtls = {
SetSyncSecretIntegrationLastRunTimestampInSeconds: 60, SetSyncSecretIntegrationLastRunTimestampInSeconds: 60,
SetSecretSyncLastRunTimestampInSeconds: 60, SetSecretSyncLastRunTimestampInSeconds: 60,
AccessTokenStatusUpdateInSeconds: 120, AccessTokenStatusUpdateInSeconds: 120,
ProjectPermissionCacheInSeconds: 300 // 5 minutes ProjectPermissionCacheInSeconds: 300, // 5 minutes
ProjectPermissionDalVersionTtl: "15m" // Project permission DAL version TTL
}; };
type TDeleteItems = { type TDeleteItems = {