fix: Access-Token wird bei deaktivierten Usern sofort ungueltig

Bisher griff der isActive-Check nur auf Routen mit @RequirePermission.
Ein deaktivierter User mit gueltigem Token konnte Routen ohne
Permission-Decorator bis zum Token-Ablauf (bis zu 15 Min) weiter
erreichen. JwtAuthGuard prueft jetzt PermissionResolverService.isUserActive
auf jedem authentifizierten Request, ueber denselben 30s-Cache wie die
Permission-Aufloesung, also ohne zusaetzliche DB-Last.
This commit is contained in:
Nick Szaunig committed 2026-08-26 14:17:49 +02:00
1 parent 5a1015c4a0
commit adfbc2ab83
4 files changed
+191 -19

No files matched your search

@@ -93,6 +93,57 @@ describe('PermissionResolverService', () => {
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
describe('isUserActive', () => {
it('returns true for an active user with an active role', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.isUserActive('user-1')).resolves.toBe(true);
});
it('returns false for a deactivated user', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: false,
role: {
status: 'ACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
await expect(resolver.isUserActive('user-1')).resolves.toBe(false);
});
it('returns false when the role is inactive', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: true,
role: {
status: 'INACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
await expect(resolver.isUserActive('user-1')).resolves.toBe(false);
});
it('returns false when the user does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
await expect(resolver.isUserActive('missing')).resolves.toBe(false);
});
it('shares the cached entry with getPermissions instead of re-querying', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
await resolver.isUserActive('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
});
it('re-queries after invalidateAll', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
@@ -3,6 +3,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { PermissionKey } from './permission-catalog';
interface CacheEntry {
isActive: boolean;
permissions: ReadonlySet<string>;
expiresAt: number;
}
@@ -17,6 +18,11 @@ const CACHE_TTL_MS = 30_000;
* invalidateUser/invalidateAll after any write to Role, Permission,
* RolePermission, or User.roleId so the change is visible immediately
* instead of waiting out the cache window.
*
* isUserActive 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.
*/
@Injectable()
export class PermissionResolverService {
@@ -25,16 +31,13 @@ export class PermissionResolverService {
constructor(private readonly prisma: PrismaService) {}
async getPermissions(userId: string): Promise<ReadonlySet<string>> {
const cached = this.cache.get(userId);
if (cached && cached.expiresAt > Date.now()) {
return cached.permissions;
}
const permissions = await this.loadPermissions(userId);
this.cache.set(userId, {
permissions,
expiresAt: Date.now() + CACHE_TTL_MS,
});
return permissions;
const entry = await this.getEntry(userId);
return entry.permissions;
}
async isUserActive(userId: string): Promise<boolean> {
const entry = await this.getEntry(userId);
return entry.isActive;
}
async hasAny(
@@ -56,7 +59,17 @@ export class PermissionResolverService {
this.cache.clear();
}
private async loadPermissions(userId: string): Promise<ReadonlySet<string>> {
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: {
@@ -71,9 +84,16 @@ export class PermissionResolverService {
},
},
});
if (!user || !user.isActive || user.role.status !== 'ACTIVE') {
return new Set();
}
return new Set(user.role.permissions.map((grant) => grant.permission.key));
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,
permissions,
expiresAt: Date.now() + CACHE_TTL_MS,
};
}
}