fix: PermissionsGuard schliesst jetzt statt bei fehlendem Decorator zu oeffnen

PermissionsGuard erlaubte bisher jede Route ohne @RequirePermission
automatisch fuer jeden eingeloggten Nutzer (fail-open) und verliess sich
allein auf test/authorization-coverage.e2e-spec.ts, um eine vergessene
Decorator-Angabe vor dem Deploy zu erwischen - der Guard selbst hatte
kein Default-Deny.

Neuer Decorator @AnyAuthenticatedUser() macht das bisher stillschweigend
erlaubte Verhalten explizit fuer die 12 Routen, die es tatsaechlich
brauchen (ProfileController komplett, NotificationsController komplett,
AuthController.me - alle bereits vorher auf der Allowlist des e2e-Tests).
PermissionsGuard verweigert jetzt jede Route, die weder
@RequirePermission noch @AnyAuthenticatedUser traegt - eine vergessene
Decorator-Angabe schlaegt jetzt zur Laufzeit fehl (403), nicht mehr nur
im Test. Der e2e-Test prueft jetzt denselben Decorator-Vertrag statt
einer separat gepflegten Allowlist, kann also nicht mehr vom
Guard-Verhalten abweichen.

Neue Unit-Tests fuer PermissionsGuard decken beide Codepfade und das
Fail-Closed-Verhalten ab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-02 14:44:28 +02:00
1 parent b7ff92abd3
commit 19d5e5d65c
7 files changed
+151 -36

No files matched your search

@@ -12,6 +12,7 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from './decorators/public.decorator';
import { AnyAuthenticatedUser } from './decorators/any-authenticated-user.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import type { AuthenticatedUser } from './types/authenticated-user';
import { LoginDto } from './dto/login.dto';
@@ -92,6 +93,7 @@ export class AuthController {
}
@ApiBearerAuth()
@AnyAuthenticatedUser()
@Get('me')
me(@CurrentUser() user: AuthenticatedUser): Promise<CurrentUserProfile> {
return this.authService.getCurrentUserProfile(user.id);
@@ -0,0 +1,16 @@
import { SetMetadata } from '@nestjs/common';
export const ANY_AUTHENTICATED_USER_KEY = 'any-authenticated-user';
/**
* Explicit opt-out of permission checking for a route that is meant to be
* reachable by any logged-in user regardless of role - it acts only on the
* caller's own data (profile, own notifications) or is the auth bootstrap
* endpoint every authenticated user must reach. Required by
* PermissionsGuard, which fails closed on any route carrying neither this
* nor @RequirePermission: a route is either explicitly public, explicitly
* permission-gated, or explicitly opted into "any authenticated user" -
* there is no fourth, silent option.
*/
export const AnyAuthenticatedUser = () =>
SetMetadata(ANY_AUTHENTICATED_USER_KEY, true);
@@ -0,0 +1,88 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard } from './permissions.guard';
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
describe('PermissionsGuard', () => {
let guard: PermissionsGuard;
let reflectorValues: Map<string, unknown>;
let reflector: { getAllAndOverride: jest.Mock };
let permissionResolver: { hasAny: jest.Mock };
function contextWithUser(user: { id: string } | undefined): ExecutionContext {
const request = { user };
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
beforeEach(() => {
reflectorValues = new Map();
reflector = {
getAllAndOverride: jest.fn((key: string) => reflectorValues.get(key)),
};
permissionResolver = { hasAny: jest.fn() };
guard = new PermissionsGuard(
reflector as unknown as Reflector,
permissionResolver as unknown as PermissionResolverService,
);
});
it('denies a route that carries neither @RequirePermission nor @AnyAuthenticatedUser (fails closed)', async () => {
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(false);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('checks the caller permission set when @RequirePermission is present', async () => {
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
permissionResolver.hasAny.mockResolvedValue(true);
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(true);
expect(permissionResolver.hasAny).toHaveBeenCalledWith('user-1', [
'apiaries:read',
]);
});
it('denies when the caller lacks all required permissions', async () => {
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
permissionResolver.hasAny.mockResolvedValue(false);
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(false);
});
it('denies a @RequirePermission route when there is no authenticated user on the request', async () => {
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
false,
);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('allows an authenticated user through a route marked @AnyAuthenticatedUser', async () => {
reflectorValues.set(ANY_AUTHENTICATED_USER_KEY, true);
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(true);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('denies @AnyAuthenticatedUser when there is no user on the request', async () => {
reflectorValues.set(ANY_AUTHENTICATED_USER_KEY, true);
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
false,
);
});
});
@@ -1,5 +1,6 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator';
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { PermissionKey } from '../permissions/permission-catalog';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
@@ -10,14 +11,15 @@ interface RequestWithUser {
}
/**
* 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.
* Fails closed by decorator absence: a non-public route reaches this guard
* only after JwtAuthGuard confirms a valid, active session, and from here it
* is denied unless it carries either @RequirePermission (checked against the
* caller's live permission set) or the explicit @AnyAuthenticatedUser opt-out
* for routes that are meant to be reachable by any logged-in user regardless
* of role. A route with neither is a bug - forgetting to decorate a new
* route now fails shut at runtime instead of silently granting access to
* every authenticated user and relying solely on
* test/authorization-coverage.e2e-spec.ts to catch it before deploy.
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
@@ -30,14 +32,20 @@ export class PermissionsGuard implements CanActivate {
const requiredPermissions = this.reflector.getAllAndOverride<
PermissionKey[]
>(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user) {
return false;
if (requiredPermissions && requiredPermissions.length > 0) {
if (!user) {
return false;
}
return this.permissionResolver.hasAny(user.id, requiredPermissions);
}
return this.permissionResolver.hasAny(user.id, requiredPermissions);
const isAnyAuthenticatedUser = this.reflector.getAllAndOverride<boolean>(
ANY_AUTHENTICATED_USER_KEY,
[context.getHandler(), context.getClass()],
);
return Boolean(isAnyAuthenticatedUser && user);
}
}