Day#5: Continuation: Securing Routes with JWT and Role-Based Access Control

TL;DR: Continuing development on the backend platform — this time focusing on securing routes with JWT, implementing @Roles decorators, handling role propagation from the database, and creating a custom @Public annotation for open endpoints.


🔐 Global Auth with Fine-Grained Access Control

After getting the core system working, the next challenge was enforcing proper access control across the API. This involved:


🧱 Strategy: JWT + Roles + Global Guards

The approach includes two guards used together:

And both are added globally via:

app.useGlobalGuards(
  app.get(JwtAuthGuard),
  app.get(RolesGuard)
);

This way, developers don’t need to manually add @UseGuards() to every controller — only @Roles() where needed.


🛡️ Role Propagation from the Database

User roles are not hardcoded. Instead, they are pulled from the database at login and attached to the JWT payload:

async validateUser(email: string, password: string) {
  const partner = await this.partnerService.findByEmailWithRoles(email);
  if (!partner) throw new UnauthorizedException();

  const roles = partner.systemRoles?.map(r => r.name) ?? [];

  const valid = await bcrypt.compare(password, partner.passwordHash);
  if (!valid) throw new UnauthorizedException();

  const { passwordHash, ...rest } = partner;
  return { ...rest, roles };
}

When the user logs in, the JWT token contains their id, email, and roles:

async login(user: any) {
  const payload = {
    sub: user.id,
    email: user.email,
    roles: user.roles,
  };
  return {
    access_token: this.jwtService.sign(payload),
  };
}

This structure is then used by the JwtStrategy to restore context:

async validate(payload: any) {
  return {
    id: payload.sub,
    email: payload.email,
    roles: payload.roles,
  };
}

🏷️ The @Public() Decorator

To mark an endpoint as open (not requiring authentication), a custom decorator is introduced:

export const Public = () => SetMetadata('isPublic', true);

In the guards, we check for this flag:

const isPublic = this.reflector.get<boolean>('isPublic', context.getHandler());
if (isPublic) return true;

This enables simple public routes:

@Public()
@Get('ping')
ping() {
  return { ping: 'pong' };
}

🔒 Using @Roles() with Guards

Routes that require authorization can now be protected like this:

@Roles('admin', 'roles_r')
@Get('secure')
getSecureData() {
  return { secret: 'classified' };
}

The RolesGuard fetches the required roles from metadata and checks if the user has at least one of them.


🧪 Testing with Swagger and Postman

Swagger is configured with @ApiBearerAuth() globally. During testing:

  1. Call POST /auth/login with valid credentials.
  2. Copy the returned token.
  3. In Swagger UI, click Authorize, and enter:
Bearer <token>

The system now authenticates and authorizes users correctly.


🧠 Lessons Learned


Next up: implementing hierarchical access control and scoping data visibility across nested entities.