Skip to content

Commit de9ea65

Browse files
Merge pull request #182 from akargi/feat/RBAC
Feat: Global API Response Formatter & Role-Based Access Control (RBAC)
2 parents 3272624 + e106a5b commit de9ea65

File tree

4 files changed

+75
-58
lines changed

4 files changed

+75
-58
lines changed

src/auth/strategies/jwt.strategy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ConfigService } from '@nestjs/config';
66
export interface JwtPayload {
77
sub: string;
88
email: string;
9-
role: string;
9+
roles: string[];
1010
}
1111

1212
@Injectable()
@@ -23,7 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
2323
return {
2424
userId: payload.sub,
2525
email: payload.email,
26-
role: payload.role,
26+
roles: payload.roles || [],
2727
};
2828
}
2929
}

src/common/decorators/roles.decorator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@ export const ROLES_KEY = 'roles';
2525
* \@Delete('posts/:id')
2626
* deletePost() {}
2727
*/
28-
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
28+
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { RolesGuard } from '../guards/roles.guard';
2+
import { Roles } from '../decorators/roles.decorator';
3+
4+
describe('RolesGuard', () => {
5+
let guard: RolesGuard;
6+
let reflector: any;
7+
8+
beforeEach(() => {
9+
reflector = {
10+
getAllAndOverride: jest.fn(),
11+
};
12+
guard = new RolesGuard(reflector);
13+
});
14+
15+
it('should allow access if no roles are required', () => {
16+
reflector.getAllAndOverride.mockReturnValue(undefined);
17+
const context = {
18+
getHandler: () => {},
19+
getClass: () => {},
20+
switchToHttp: () => ({ getRequest: () => ({ user: { roles: ['admin'] } }) }),
21+
};
22+
expect(guard.canActivate(context as any)).toBe(true);
23+
});
24+
25+
it('should deny access if user has no roles', () => {
26+
reflector.getAllAndOverride.mockReturnValue(['admin']);
27+
const context = {
28+
getHandler: () => {},
29+
getClass: () => {},
30+
switchToHttp: () => ({ getRequest: () => ({ user: {} }) }),
31+
};
32+
expect(guard.canActivate(context as any)).toBe(false);
33+
});
34+
35+
it('should allow access if user has required role', () => {
36+
reflector.getAllAndOverride.mockReturnValue(['admin']);
37+
const context = {
38+
getHandler: () => {},
39+
getClass: () => {},
40+
switchToHttp: () => ({ getRequest: () => ({ user: { roles: ['admin', 'moderator'] } }) }),
41+
};
42+
expect(guard.canActivate(context as any)).toBe(true);
43+
});
44+
45+
it('should deny access if user does not have required role', () => {
46+
reflector.getAllAndOverride.mockReturnValue(['admin']);
47+
const context = {
48+
getHandler: () => {},
49+
getClass: () => {},
50+
switchToHttp: () => ({ getRequest: () => ({ user: { roles: ['user'] } }) }),
51+
};
52+
expect(guard.canActivate(context as any)).toBe(false);
53+
});
54+
55+
it('should support multiple roles per endpoint', () => {
56+
reflector.getAllAndOverride.mockReturnValue(['admin', 'moderator']);
57+
const context = {
58+
getHandler: () => {},
59+
getClass: () => {},
60+
switchToHttp: () => ({ getRequest: () => ({ user: { roles: ['moderator'] } }) }),
61+
};
62+
expect(guard.canActivate(context as any)).toBe(true);
63+
});
64+
});

src/common/guards/roles.guard.ts

Lines changed: 8 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,75 +2,28 @@ import {
22
Injectable,
33
CanActivate,
44
ExecutionContext,
5-
ForbiddenException,
6-
Logger,
7-
UnauthorizedException,
85
} from '@nestjs/common';
96
import { Reflector } from '@nestjs/core';
10-
import { Request } from 'express';
11-
import { Role, ROLES_KEY } from '../decorators/roles.decorator';
7+
import { ROLES_KEY } from '../decorators/roles.decorator';
128

13-
interface JwtUser {
14-
id: string | number;
15-
email?: string;
16-
role: Role;
17-
}
18-
19-
/**
20-
* #158 – RolesGuard
21-
*
22-
* Must be used together with an AuthGuard that populates `request.user`
23-
* from a validated JWT. The guard:
24-
*
25-
* 1. Skips routes that have no @Roles() decorator (public by default).
26-
* 2. Rejects unauthenticated requests with 401.
27-
* 3. Rejects authenticated requests whose role is not in the allowed list with 403.
28-
*
29-
* Registration (choose one):
30-
* - Globally: app.useGlobalGuards(new RolesGuard(reflector)) in main.ts
31-
* - Per-module: providers: [RolesGuard] and add @UseGuards(JwtAuthGuard, RolesGuard)
32-
*/
339
@Injectable()
3410
export class RolesGuard implements CanActivate {
35-
private readonly logger = new Logger(RolesGuard.name);
36-
37-
constructor(private readonly reflector: Reflector) {}
11+
constructor(private reflector: Reflector) {}
3812

3913
canActivate(context: ExecutionContext): boolean {
40-
// Collect roles from the handler first, then fall back to the controller level
41-
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
14+
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
4215
context.getHandler(),
4316
context.getClass(),
4417
]);
45-
46-
// No @Roles() decorator → route is unrestricted at the role level
4718
if (!requiredRoles || requiredRoles.length === 0) {
4819
return true;
4920
}
50-
51-
const request = context.switchToHttp().getRequest<Request & { user?: JwtUser }>();
21+
const request = context.switchToHttp().getRequest();
5222
const user = request.user;
53-
54-
if (!user) {
55-
throw new UnauthorizedException(
56-
'Authentication is required to access this resource.',
57-
);
23+
if (!user || !user.roles) {
24+
return false;
5825
}
59-
60-
const hasRole = requiredRoles.includes(user.role);
61-
62-
if (!hasRole) {
63-
this.logger.warn(
64-
`Access denied: user ${user.id} (role="${user.role}") attempted to access ` +
65-
`[${context.getClass().name}::${context.getHandler().name}] ` +
66-
`which requires role(s): [${requiredRoles.join(', ')}]`,
67-
);
68-
throw new ForbiddenException(
69-
`You do not have permission to perform this action. ` +
70-
`Required role(s): ${requiredRoles.join(' or ')}.`,
71-
);
72-
}
73-
74-
return true;
26+
// Support multiple roles per endpoint
27+
return requiredRoles.some(role => user.roles.includes(role));
7528
}
7629
}

0 commit comments

Comments
 (0)