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

View File

@@ -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<boolean>;
invalidateProjectPermissionCache: (projectId: string) => Promise<void>;
invalidateProjectPermissionCache: (projectId: string, tx?: Knex) => Promise<void>;
invalidateUserProjectPermissionCache: (userId: string) => Promise<void>;
invalidateIdentityProjectPermissionCache: (identityId: string) => Promise<void>;
};

View File

@@ -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<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 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<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 {
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<ProjectPermissionSet>(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");
}

View File

@@ -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 = {