Files
ApiCore/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts
T
Nick SzaunigandClaude Opus 5.5 16cc557462
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s
feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- Phenology reports API with corrections and revokes; recompute per
  apiary via PhenologyRecomputeRequested, allowBackward for corrections;
  season changes are announced in the morning digest (changes since the
  previous digest, each once)
- Season autopilot A4-A10 with per-apiary sync (Apiary before Colony
  locks) and WinterCheckRecorded
- Honey batch ledger at runtime: initial amount, BOTTLING bookings,
  manual movements, one gram rounding, untracked bottlings never book
- Telegram delivery: planning, Berlin delivery times incl. DST, quiet
  hours, permission re-check and stale skip at send time, rate limits,
  cleanup of old deliveries
- ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 21:13:23 +02:00

133 lines
4.2 KiB
TypeScript

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<string>;
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<string, CacheEntry>();
constructor(private readonly prisma: PrismaService) {}
async getPermissions(userId: string): Promise<ReadonlySet<string>> {
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<ReadonlySet<string>> {
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<boolean> {
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<boolean> {
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<CacheEntry> {
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<CacheEntry> {
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<string>();
return {
isActive: isEligible,
passwordChangedAt: user?.passwordChangedAt ?? null,
permissions,
expiresAt: Date.now() + CACHE_TTL_MS,
};
}
}