diff --git a/ApiCore-Backend/src/auth/guards/permissions.guard.spec.ts b/ApiCore-Backend/src/auth/guards/permissions.guard.spec.ts index d75dd59..28bbd7d 100644 --- a/ApiCore-Backend/src/auth/guards/permissions.guard.spec.ts +++ b/ApiCore-Backend/src/auth/guards/permissions.guard.spec.ts @@ -3,6 +3,7 @@ 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 { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { PermissionResolverService } from '../permissions/permission-resolver.service'; describe('PermissionsGuard', () => { @@ -32,6 +33,15 @@ describe('PermissionsGuard', () => { ); }); + it('allows a @Public route through with no user on the request (e.g. health checks, login)', async () => { + reflectorValues.set(IS_PUBLIC_KEY, true); + + await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe( + true, + ); + expect(permissionResolver.hasAny).not.toHaveBeenCalled(); + }); + it('denies a route that carries neither @RequirePermission nor @AnyAuthenticatedUser (fails closed)', async () => { await expect( guard.canActivate(contextWithUser({ id: 'user-1' })), diff --git a/ApiCore-Backend/src/auth/guards/permissions.guard.ts b/ApiCore-Backend/src/auth/guards/permissions.guard.ts index 31c78fa..94f9de1 100644 --- a/ApiCore-Backend/src/auth/guards/permissions.guard.ts +++ b/ApiCore-Backend/src/auth/guards/permissions.guard.ts @@ -1,6 +1,7 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator'; import { PermissionKey } from '../permissions/permission-catalog'; import { PermissionResolverService } from '../permissions/permission-resolver.service'; @@ -11,14 +12,17 @@ interface RequestWithUser { } /** - * 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 + * Fails closed by decorator absence: a route reaches this guard whether or + * not it's @Public (JwtAuthGuard only skips authentication for those, it + * doesn't remove this guard from the chain - NestJS APP_GUARD entries all + * run, there's no early-exit between them), so @Public is checked here too, + * the same way JwtAuthGuard checks it. A non-public route is denied unless + * it carries either @RequirePermission (checked against the caller's live + * permission set) or the explicit @AnyAuthenticatedUser opt-out for routes + * meant to be reachable by any logged-in user regardless of role. A route + * with none of the three 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() @@ -29,6 +33,14 @@ export class PermissionsGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + const requiredPermissions = this.reflector.getAllAndOverride< PermissionKey[] >(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);