import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { PermissionKey } from './permission-catalog'; interface CacheEntry { isActive: boolean; passwordChangedAt: Date | null; permissions: ReadonlySet; expiresAt: number; } const CACHE_TTL_MS = 30_000; /** * Resolves a user's effective permission set fresh from the database on * every request (not embedded in the JWT, so revoking a role or editing a * role's grants takes effect without forcing a re-login), with a short TTL * cache to keep that from becoming a query-per-request cost. Call * invalidateUser/invalidateAll after any write to Role, Permission, * RolePermission, User.roleId, or User.passwordChangedAt so the change is * visible immediately instead of waiting out the cache window. * * isTokenValid shares the same cached entry: JwtAuthGuard calls it on every * authenticated request (including routes with no @RequirePermission) so a * deactivated user's still-valid access token stops working within the * cache TTL instead of staying usable until the token expires, and so does * a token issued before the user's last password change. */ @Injectable() export class PermissionResolverService { private readonly cache = new Map(); constructor(private readonly prisma: PrismaService) {} async getPermissions(userId: string): Promise> { const entry = await this.getEntry(userId); return entry.permissions; } /** * The permissions read fresh (one primary-key lookup), never from the * cache, and stored as the cache entry. For a process that does not hear * invalidateUser of the API pods (the worker): a right revoked there must * stop mattering at once, not after the 30 s cache window. Empty for an * inactive user or role. */ async getFreshPermissions(userId: string): Promise> { const entry = await this.loadEntry(userId); this.cache.set(userId, entry); return entry.permissions; } /** * Always read fresh (one primary-key lookup) and stored as the cache * entry the PermissionsGuard then uses: a deactivation, role change or * password change made on ANOTHER replica takes effect on the very next * request here, not after the cache window. */ async isTokenValid( userId: string, tokenIssuedAtSeconds: number, ): Promise { const entry = await this.loadEntry(userId); this.cache.set(userId, entry); if (!entry.isActive) { return false; } if (!entry.passwordChangedAt) { return true; } return tokenIssuedAtSeconds * 1000 >= entry.passwordChangedAt.getTime(); } async hasAny( userId: string, keys: readonly PermissionKey[], ): Promise { if (keys.length === 0) { return true; } const permissions = await this.getPermissions(userId); return keys.some((key) => permissions.has(key)); } invalidateUser(userId: string): void { this.cache.delete(userId); } invalidateAll(): void { this.cache.clear(); } private async getEntry(userId: string): Promise { const cached = this.cache.get(userId); if (cached && cached.expiresAt > Date.now()) { return cached; } const entry = await this.loadEntry(userId); this.cache.set(userId, entry); return entry; } private async loadEntry(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { isActive: true, passwordChangedAt: true, role: { select: { status: true, permissions: { select: { permission: { select: { key: true } } }, }, }, }, }, }); const isEligible = Boolean( user && user.isActive && user.role.status === 'ACTIVE', ); const permissions = isEligible ? new Set(user!.role.permissions.map((grant) => grant.permission.key)) : new Set(); return { isActive: isEligible, passwordChangedAt: user?.passwordChangedAt ?? null, permissions, expiresAt: Date.now() + CACHE_TTL_MS, }; } }