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:
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user