fix: kritische Rechte-Luecke, Admin-Lockout-Schutz und Wetter-Haertung

Dispatchte security-reviewer und code-reviewer Agenten fuer eine
vollstaendige Pruefung des RBAC-Systems (d31aa79..HEAD). Ein
KRITISCHER Fund, zwei HIGH, ein weiteres MEDIUM, alle behoben:

KRITISCH: Deaktivieren eines Benutzers hat dessen Berechtigungen nicht
entzogen. PermissionResolverService.loadPermissions() prüfte nur den
Rollen-Status, nie User.isActive - ein bereits ausgestelltes Access-
Token behielt volle Rechte bis zum Ablauf (bis zu 15 Minuten), nicht
die vorgesehenen 30 Sekunden. UsersService rief zudem nie
invalidateUser() auf. Beides jetzt behoben; neue dedizierte
permission-resolver.service.spec.ts deckt das ab (existierte bisher
gar nicht - ein Grund, warum die Luecke unbemerkt blieb).

HIGH: Kein Schutz gegen den letzten aktiven Systemrollen-Nutzer
(ADMIN). Man konnte den letzten Admin deaktivieren oder umrollen und
sich damit komplett aussperren - ohne Selbsthilfe-Weg, da
prisma/seed.ts das Seeden ueberspringt, sobald die Admin-E-Mail
bereits existiert. UsersService.deactivate()/.update() pruefen jetzt
per assertNotLastSystemRoleUser(), analog zum bestehenden
Lockout-Schutz in RolesService fuer Systemrollen selbst.

HIGH: Der Open-Meteo-Aufruf in WeatherService hatte kein Timeout und
konnte unbegrenzt haengen. Jetzt mit AbortSignal.timeout(8000) plus
sauberem Fehler-Mapping.

MEDIUM: Trachtprognose fiel bei fehlenden Wetterdaten stillschweigend
auf "MAESSIG" zurueck statt einen eigenen Unbekannt-Zustand zu zeigen
- ein Imker haette eine echte Einschaetzung gesehen, wo keine moeglich
war. Neuer Rating-Wert UNBEKANNT in Backend und Frontend.

LOW: Veralteter Kommentar an PermissionsGuard, der noch den in Phase 3
geloeschten RolesGuard erwaehnte, korrigiert.

Verifiziert: tsc, lint, build auf beiden Projekten gruen. Backend-Unit
245/245 (17 neue Tests fuer die Fixes). Frontend-Unit 14/14. Volle
Backend-e2e-Suite konnte weiterhin nicht laufen (WSL/Postgres von
Windows aus nicht erreichbar, auch nach vollem "wsl --shutdown"-
Neustart) - jeder Fix hat aber gezielte, gruene Unit-Abdeckung.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-08-26 14:03:32 +02:00
1 parent f1d6b2956e
commit 5a1015c4a0
9 files changed
+329 -11

No files matched your search

@@ -10,10 +10,14 @@ interface RequestWithUser {
}
/**
* Runs alongside the legacy RolesGuard during the RBAC migration: routes
* without @RequirePermission are untouched here (still governed solely by
* @Roles), routes with it are enforced against the caller's live
* permission set. Both guards must pass for a request to proceed.
* Fail-open by decorator absence: a route with no @RequirePermission is
* allowed through untouched (any authenticated user can reach it), only
* routes that carry the decorator are enforced against the caller's live
* permission set. There is no default-deny here, so
* test/authorization-coverage.e2e-spec.ts — which fails the build if any
* non-public route lacks a catalog-valid @RequirePermission — is the sole
* safety net for "someone added a route and forgot the decorator", not a
* defense-in-depth nicety.
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
@@ -0,0 +1,134 @@
import { PermissionResolverService } from './permission-resolver.service';
import { PrismaService } from '../../prisma/prisma.service';
describe('PermissionResolverService', () => {
let resolver: PermissionResolverService;
let prisma: { user: { findUnique: jest.Mock } };
function activeUserWithPermissions(...keys: string[]) {
return {
isActive: true,
role: {
status: 'ACTIVE',
permissions: keys.map((key) => ({ permission: { key } })),
},
};
}
beforeEach(() => {
prisma = { user: { findUnique: jest.fn() } };
resolver = new PermissionResolverService(
prisma as unknown as PrismaService,
);
});
it('returns the granted permission keys for an active user with an active role', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read', 'tasks:create'),
);
const permissions = await resolver.getPermissions('user-1');
expect(Array.from(permissions).sort()).toEqual(
['colonies:read', 'tasks:create'].sort(),
);
});
it('returns no permissions for a deactivated user, even if their role is active', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: false,
role: {
status: 'ACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
const permissions = await resolver.getPermissions('user-1');
expect(permissions.size).toBe(0);
});
it('returns no permissions when the role itself is deactivated', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: true,
role: {
status: 'INACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
const permissions = await resolver.getPermissions('user-1');
expect(permissions.size).toBe(0);
});
it('returns no permissions when the user does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
const permissions = await resolver.getPermissions('missing');
expect(permissions.size).toBe(0);
});
it('caches the result and does not re-query within the TTL', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
it('re-queries after invalidateUser', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
resolver.invalidateUser('user-1');
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
it('re-queries after invalidateAll', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
resolver.invalidateAll();
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
describe('hasAny', () => {
it('returns true when the user holds at least one of the requested keys', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(
resolver.hasAny('user-1', ['tasks:create', 'colonies:read']),
).resolves.toBe(true);
});
it('returns false when the user holds none of the requested keys', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.hasAny('user-1', ['tasks:create'])).resolves.toBe(
false,
);
});
it('returns true when no keys are required', async () => {
await expect(resolver.hasAny('user-1', [])).resolves.toBe(true);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
});
});
@@ -60,6 +60,7 @@ export class PermissionResolverService {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
isActive: true,
role: {
select: {
status: true,
@@ -70,7 +71,7 @@ export class PermissionResolverService {
},
},
});
if (!user || user.role.status !== 'ACTIVE') {
if (!user || !user.isActive || user.role.status !== 'ACTIVE') {
return new Set();
}
return new Set(user.role.permissions.map((grant) => grant.permission.key));