DEV Community

Cover image for Control de Acceso Basado en Roles(RBAC) en Angular
akotech
akotech

Posted on

Control de Acceso Basado en Roles(RBAC) en Angular

El Control de Acceso Basado en Roles o RBAC por sus siglas en inglés, es uno de los paradigmas de seguridad más comunes en las aplicaciones de hoy en día. El principio básico de este consiste en otorgar a cada usuario únicamente los permisos imprescindibles para desarrollar las funciones asociadas a la posición o rol que cumplen dentro de la organización.

RBAC en el Desarrollo Web

Cuando hablamos de RBAC en el contexto del desarrollo web hay que hacer, eso sí, una distinción clara entre lo que esto significa en el backend y lo que significa en el frontend.

backend vs frontend

En el contexto del backend hablamos propiamente de ese sistema de seguridad en el que un usuario solo podrá acceder a ciertos recursos si está debidamente identificado y tiene otorgados los permisos para acceder a ellos.

Pero en el contexto del frontend debido a que el código fuente de la aplicación es accesible por el usuario, no podemos hablar de RBAC como un sistema de seguridad ya que sería relativamente sencillo sobrepasarlo para todo aquel usuario con un mínimo de conocimiento que supiera donde mirar. Por ello, en el frontend tenemos que considerarlo más como una forma de mejorar la experiencia del usuario (UX) que como un sistema de seguridad.

⚠️ Aplicar en el frontend las técnicas de RBAC que vamos a ver en este artículo, NO EXIMEN de tener que aplicar una protección real y efectiva en el backend.

RBAC en Angular

En este artículo hablaremos de dos elementos que tenemos disponibles para aplicar los principios de RBAC en Angular:

  • Las Guardas del Router para limitar el acceso a las diferentes rutas de nuestra aplicación.
  • y Las Directivas Estructurales para mostrar y ocultar los diferentes elementos de la UI en función de rol del usuario.

Este contenido también está disponible con un ejemplo práctico específico en el siguiente video.


Guardas del Router

Uno de los elementos principales que nos permitirán aplicar RBAC en Angular, son las guardas del Router. Las guardas son como una especie de puntos de control que podemos añadir en las rutas de nuestra aplicación para permitir, detener o redirigir la navegación en función de unos criterios que definamos.

const routes = [
  { 
    path: 'private-zone',
    canActivate: [IsLoggedInGuard], // <-- solo los usuarios que hayan iniciado sesión pueden acceder a la zona privada
    ...
  }
]
Enter fullscreen mode Exit fullscreen mode

En sí mismas, las guardas no son más que simples funciones que tienen que devolver uno de los siguientes 3 valores:

  • true: para permitir que continúe el proceso de la navegación.
  • false: para detenerlo en seco.
  • O un objeto UrlTree: que como veremos más adelante representa una ruta alternativa a la que redirigir al usuario.

Estos valores los podrá devolver la función de la guarda de manera directa, o también en forma de Observable o Promesa, dependiendo de los requerimientos del proceso.

myGuard(): boolean | UrlTree | Observable<boolean | UrlTree> | Promise<boolean | UrlTree>
Enter fullscreen mode Exit fullscreen mode

Existen 5 tipos de guardas que podremos establecer en cada una de las rutas de nuestra aplicación:

{
  path: 'some-path',
  canMatch: [],
  canLoad: [],
  canActivate: [],
  canActivateChild: [],
  canDeactivate: [],
  ... 
}
Enter fullscreen mode Exit fullscreen mode

Para entender las diferencias que hay entre ellas debemos hablar primero de los pasos que sigue el Router a la hora de procesar las rutas de nuestra aplicación.

Proceso de Rutas

A la hora de procesar una petición de navegación, el Router de Angular procesa las rutas que hemos definido en nuestra aplicación siguiendo 3 pasos principales:

Etapas de proceso de rutas

  • El primero es el Route Matching, que es el proceso en el que compara la ruta de la navegación solicitada con el path de cada unas de las rutas que hemos definido en nuestra aplicación en busca de coincidencias.

    // https://myapp.com/dashboard
    
    const routes = [
      { path: 'dashboard', ... },
      ...
    ];
    

  • Si durante el Route Matching una de las rutas que devuelve coincidencia está usando loadChildren para cargar un módulo extra, el Router procederá a cargar dicho módulo en la aplicación.

    const routes = [
      { 
        path: 'orders', 
        loadChildren: () => 
          import('./orders/orders.module').then((m) => m.OrdersModule),
      },
      ...
    ];
    

⚠️ Este paso solo se ejecutará si dicho módulo no ha sido ya previamente cargado.

  • Y por último, una vez encontrada la coincidencia total para la navegación solicitada, se produce la activación de las rutas, que no es más que el renderizado de los componentes de cada uno de los segmentos en sus <router-outlet> correspondientes.

Tipos de Guardas

Bien pues los diferentes tipos de guardas nos permiten establecer esos puntos de control en la frontera de cada uno de estos procesos.

Tipos de Guardas

Como hemos anteriormente, en Angular tenemos actualmente 5 tipos de guardas disponibles que podremos establecer en cada una de las rutas de nuestra aplicación:

  • canMatch(v14.1+): Nos permite controlar dinámicamente si el Router puede usar o no una ruta en el proceso del Route Matching. (En esta guarda devolver false, en vez de detener la navegación, hace que el Router ignore esa ruta como si no existiera).
  • canLoad(deprecated): Nos permite controlar si el usuario tiene permiso o no para cargar el módulo indicado en la propiedad loadChildren de la ruta. Esta guarda ha sido recientemente marcada como obsoleta (v15) en favor de canMatch.
  • canActivate: Nos permite controlar si el usuario puede renderizar o no el componente asociado a la ruta.
  • canActivateChild: Nos permite controlar si el usuario puede renderizar o no los componentes de las rutas hijas de la ruta actual.
  • canDeactivate: Nos permite controlar si el usuario puede abandonar o no la ruta actual. Lo que nos permitiría por ejemplo impedir que un usuario abandone una ruta en la que tenga cambios sin guardar.

Creando nuestra primera Guarda

Para definir la lógica de una guarda, actualmente disponemos de dos opciones en Angular:

  • Implementando la interfaz del tipo de guarda requerido en la clase de un servicio inyectable.

    @Injectable({ providedIn: 'root'})
    export class MyGuard implements CanActivate { 
      ...
    
      canActivate(...): boolean | UrlTree | ... {
        // lógica de la guarda
      }
    }
    
    // Y pasando esta clase en el array de la propiedad canActivate de la ruta a aplicar
    const routes = [
      { path: 'some-path', canActivate: [ MyGuard ] }
    ]
    

  • O definiendo la lógica como una guarda funcional (v14.2+)

    export function myGuard(...): boolean | UrlTree | ... {
      //lógica de la guarda
    }
    
    // O también
    
    export const myGuard: CanActivateFn = (...) => {
      //lógica de la guarda
    }
    
    // Y pasando esa función o const al array de la propiedad de la ruta
    const routes = [
      { path: 'some-path', canActivate: [ myGuard ] }
    ]
    

isLoggedInGuard

Por ejemplo, una de las parejas de roles más comunes en las aplicaciones, es la pareja de Invitado / Usuario, donde la diferencia entre ambos roles es el hecho de que el usuario haya iniciado o no sesión.

Podríamos pues limitar el acceso a ciertas rutas solo para usuarios que hayan iniciado sesión creando la siguiente guarda.

//auth.service
export class AuthService {
  // expone estado de inicio de sesión en forma de Observable
  isLoggedIn$: Observable<boolean>; 
  ...
}


//is-logged-in.guard
@Injectable({ providedIn: 'root' })
export class IsLoggedInGuard implements CanActivate {
  constructor(private authService: AuthService) {}

  canActivate(): Observable<boolean | UrlTree> {
    return this.authService.isLoggedIn$; //<-- devolvemos el observable del AuthService
  }
}
Enter fullscreen mode Exit fullscreen mode

Si ahora asignáramos esta guarda a una ruta con el path private-zone:

{
  path: 'private-zone',
  component: PrivateZoneComponent,
  canActivate: [IsLoggedInGuard],
}
Enter fullscreen mode Exit fullscreen mode

A la hora de acceder a esta ruta, antes de renderizar el componente asociado el Router ejecutará la guarda asociada y, en este caso, se subscribirá automáticamente al observable retornado y esperará a que este emita su primer valor.

  • Si el primer valor emitido por el observable isLoggedIn$ es true (el usuario ha iniciado sesión), el Router procederá a renderizar dicho componente en el <router-oulet> correspondiente.
  • Si por el contrario el primer valor emitido por isLoggedIn$ es false, el Router detendrá el proceso de navegación en seco, sin ningún tipo de feedback para el usuario.

Para el caso en el que un usuario que no ha iniciado sesión intente acceder a una sección privada, en vez de detener la navegación en seco, una mejor solución en términos de UX, sería redirigirlo a la ruta /login para que pueda completar ese inicio de sesión.

Esto como hemos dicho anteriormente, lo podemos conseguir devolviendo desde la guarda un objeto UrlTree con la ruta alternativa.

Por lo que podríamos modificar nuestra guarda para conseguir esta funcionalidad de la siguiente manera:

//is-logged-in.guard
@Injectable({ providedIn: 'root' })
export class IsLoggedInGuard implements CanActivate {
  constructor(
    private authService: AuthService, 
    private router: Router // <-- inyectamos el Router
  ) {}

  canActivate(): Observable<boolean | UrlTree> {
    return this.authService.isLoggedIn$.pipe(
      //usamos el operador map para transformar false en el UrlTree para la ruta /login
      map((isLoggedIn) => isLoggedIn || this.router.createUrlTree(['/login'])) 
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Ahora si el observable isLoggedIn$ emite inicialmente false, transformamos ese valor en el objeto UrlTree de la ruta /login usando el método createUrlTree() del Router, lo que hará que el usuario sea redireccionado a dicha ruta.

hasRoleGuard

Ya hemos visto como crear una guarda básica para limitar el acceso a una ruta cuando una condición estática se cumpla o no. Pero, ¿cómo podemos crear una guarda en donde los parámetros de esa condición sean dinámicos?

{
  path: 'some-section',
  canActivate: [hasRole(['Manager', 'Accountant'])],
  ...
},
{
  path: 'other-section',
  canActivate: [hasRole(['Manager', 'Clerk'])],
  ...
},
Enter fullscreen mode Exit fullscreen mode


Como guarda funcional (v14.2+)
Con las relativamente nuevas guardas funcionales, esto lo podemos conseguir fácilmente de la siguiente manera:

//user.roles
export type Role = 'Clerk' | 'Accountant' | 'Manager';


// has-role.guard
export function hasRole(allowedRoles: Role[]) {
  return () =>
    inject(AuthService).user$.pipe(
      map((user) => Boolean(user && allowedRoles.includes(user.role))),
      tap((hasRole) => hasRole === false && alert('Acceso Denegado'))
    );
}
Enter fullscreen mode Exit fullscreen mode

1.- Lo primero que hemos hecho es crear una función factoría que acepta como argumento un array con los roles permitidos, a partir de los cuales crea y devuelve la función de la guarda para esos roles.

export function hasRole(allowedRoles: Role[]) {
  return Guarda_funcional_creada_para_allowedRoles_proporcionados
}
Enter fullscreen mode Exit fullscreen mode


2.- Para crear esa guarda hemos primero inyectado el AuthService usando la función inject del framework y de ese servicio hemos extraído la información del usuario actual a través de una propiedad user$.

inject(AuthService).user$
Enter fullscreen mode Exit fullscreen mode


3.- A partir de la información de ese usuario y los roles proporcionados, hemos usado el operador map para realizar el chequeo de la guarda. Chequeando si hay un usuario logeado y si además el rol de ese usuario está incluido en los proporcionados en el array de roles permitidos.

map((user) => Boolean(user && allowedRoles.includes(user.role)))
Enter fullscreen mode Exit fullscreen mode


4.- Y por último, para no fallar silenciosamente en caso de que el chequeo anterior devuelva false, hemos añadido un tap para mostrar una alerta en la aplicación usando en este caso el método alert del navegador para simplificar el ejemplo.

tap((hasRole) => hasRole === false && alert('Acceso Denegado'))
Enter fullscreen mode Exit fullscreen mode


Como servicio inyectable
Como vemos la implementación de este tipo de guardas en las últimas versiones de Angular es muy sencilla. Esto no es así de simple para versiones anteriores, en las que tenemos que utilizar la inyección de dependencias para pasar la clase que tiene definido el método de la guarda, ya que en ese caso no le podemos pasar directamente el array de roles permitidos.

Para este tipo de casos, tendremos que apoyarnos en la propiedad data de las rutas para definir ese array de roles permitidos.

{
  path: 'some-section',
  canActivate: [HasRoleGuard],
  data: {
    allowedRoles: ['Manager', 'Accountant'],
  },
  ...
},
Enter fullscreen mode Exit fullscreen mode

Y para ahora acceder a esta información desde el método de la guarda tendremos que hacer uso de uno de los parámetros de dicho método. En este caso la firma del método canActivate es la siguiente:

canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree | ...
Enter fullscreen mode Exit fullscreen mode

Por lo que para acceder a esa información de la ruta, tendremos que usar el primer parámetro route para acceder a la propiedad data de la ruta. La guarda quedaría pues de la siguiente manera:

@Injectable({ providedIn: 'root' })
export class HasRoleGuard implements CanActivate {
  // inyectamos el AuthService en el constructor
  constructor(private authService: AuthService) {}

  canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
    // extraemos la información de la propiedad data de la ruta. 
    const allowedRoles = route.data?.['allowedRoles'];

    // usamos el user del AuthService y los roles extraídos de data para 
    // implementar nuevamente la lógica de la guarda
    return this.authService.user$.pipe(
      map((user) => Boolean(user && allowedRoles.includes(user.role))),
      tap((hasRole) => hasRole === false && alert('Acceso Denegado'))
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Como vemos, esta implementación es algo más compleja con el inconveniente adicional de ser más propensa a errores, ya que usando la propiedad data de la ruta no tenemos ningún tipo de Intellisense en el editor ni protección de tipo de TypeScript que nos obligue/ayude a definir esa información extra que necesita la guarda.

Debido a esto, para definir guardas que requiera de algún parámetro extra dinámico, como este de los roles, es recomendable definirlas como guarda funcional. (Siempre y cuando eso sí estemos usando una versión de Angular igual o posterior a la v14.2)


Directivas Estructurales

Ya hemos visto como limitar el acceso a las diferentes rutas de nuestra aplicación usando las guardas. El segundo elemento que tenemos disponible para mejorar la UX en el contexto de RBAC son las directivas estructurales.

Las directivas estructurales nos permiten renderizar el contenido asociado de manera condicional. Este es el tipo de directivas al que pertenecen las directivas *ngIf o *ngFor.

Por ejemplo imaginemos que tenemos una aplicación en la que podemos tener usuarios con tres roles diferentes ('Clerk' | 'Accountant' | 'Manager'). Y en el header de dicha aplicación tenemos una serie de enlaces de navegación a las diferentes secciones de la misma.

header con tres enlaces

Imaginemos también que usando las guardas ya hemos limitado el acceso a la sección de Contabilidad para que solo puedan acceder a ella los usuarios con los roles de Accountant o Manager. Aunque esto es suficiente a la hora limitar que un usuario fuera de esos roles acceda a dicha sección, una mejor solución es directamente no mostrar ese enlace a aquellos usuarios que no tengan permiso para acceder a la misma.

Aunque esto lo podríamos conseguir haciendo uso de la directiva *ngIf de la siguiente forma:

//header.component.ts
export class HeaderComponent {
  constructor(private authService: AuthService) {}
  ...

  userRoleIn(allowedRoles: Role[]): Observable<boolean> {
    return this.authService.user$.pipe(
      map((user) => Boolean(user && allowedRoles.includes(user.role))),
    );
  }
}


//header.component.html
<a *ngIf="userRoleIn(['Accountant', 'Manager']) | async" routerLink="/accounting">Contabilidad</a>
Enter fullscreen mode Exit fullscreen mode

Esto nos obligaría a tener que repetir esta misma lógica una y otra vez en los diferentes componentes de la aplicación cada vez que quisiéramos mostrar u ocultar cierto elemento de la interfaz en base al rol del usuario.

Una mejor solución, por tanto, es crear nuestra propia directiva estructural que encapsule toda esta lógica del chequeo de roles y usarla directamente sobre el elemento del template pasándole únicamente el array de roles permitidos.

<a *showForRoles="['Accountant', 'Manager']" routerLink="/accounting">Contabilidad</a>
Enter fullscreen mode Exit fullscreen mode

Veamos como podemos podemos conseguir esto.

Creando la directiva personalizada *showForRoles

Lo primero que tenemos que hacer es crear la directiva. Para ello simplemente tenemos que ejecutar el comando de la cli:

ng generate directive <nombre_de_la_directiva>
Enter fullscreen mode Exit fullscreen mode

En nuestro caso la llamaremos showForRoles , lo que nos genera el siguiente archivo:

@Directive({
  selector: '[appShowForRoles]',
})
export class ShowForRolesDirective {
  constructor() {}
}
Enter fullscreen mode Exit fullscreen mode

Una vez tenemos nuestra directiva creada, para ejecutar la lógica de los roles necesitamos traer a ella:

  • El listado de roles permitidos. Lo que podemos conseguir añadiendo un @Input con el mismo nombre de la directiva, lo que nos permitirá pasar el listado de roles en la asignación a la hora de usarla.

    //show-for-roles.directive
    @Directive({
      selector: '[appShowForRoles]',
    })
    export class ShowForRolesDirective {
      @Input('appShowForRoles') allowedRoles?: Role[];
      ...
    }
    
    // template en el que la usemos
    <a *appShowForRoles="['Accountant', 'Manager']" ...>
    
  • Y la información del usuario. Para lo que simplemente tenemos que inyectar el AuthService en su constructor.

    export class ShowForRolesDirective {
      ...
      constructor(private authService: AuthService) {}
    }
    

Y una vez tenemos los datos necesarios para ejecutar la lógica, solo nos queda mover esa lógica del chequeo de roles a la directiva.

@Directive({
  selector: '[appShowForRoles]',
})
export class ShowForRolesDirective implements OnInit, OnDestroy {
  @Input('appShowForRoles') allowedRoles?: Role[];
  private sub?: Subscription;

  constructor(private authService: AuthService) {}

  ngOnInit(): void {
    this.sub = this.authService.user$.pipe(
      map((user) => Boolean(user && this.allowedRoles?.includes(user.role))),
    ).subscribe();
  }

  ngOnDestroy(): void {
    this.sub?.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

Como en este caso nuestra lógica tiene como base el Observable del usuario del AuthService, estamos definiendo la lógica y subscribiéndonos en el lifecycle hook de ngOnInit. Sin olvidarnos de cancelar dicha subscripción en el ngOnDestroy .

Una vez tenemos la lógica del chequeo implementada, como esta va a ser una directiva estructural, solo nos queda añadir la lógica para renderizar o eliminar explícitamente el elemento en el que la hayamos aplicado.

Para ello necesitamos inyectar en el constructor dos dependencias más, ViewContainerRef y TemplateRef, las cuales nos permiten obtener la referencias al ng-template generado por el asterisco * delante de la directiva y el contenedor de la vista asociado a este.

export class ShowForRolesDirective implements OnInit, OnDestroy {
  ...
  constructor(
    private authService: AuthService,
    private viewContainerRef: ViewContainerRef,
    private templateRef: TemplateRef<any>
  ) {}
  ...
}
Enter fullscreen mode Exit fullscreen mode

Y con estas dos referencias, lo único que tenemos que hacer para completar nuestra directiva es añadir un operador tap a continuación del map para ejecutar la lógica de renderizado.

export class ShowForRolesDirective implements OnInit, OnDestroy {
  ...
  ngOnInit(): void {
    this.sub = this.authService.user$.pipe(
      map((user) => Boolean(user && this.allowedRoles?.includes(user.role))),
      tap((hasRole) =>
          hasRole
            ? this.viewContainerRef.createEmbeddedView(this.templateRef)
            : this.viewContainerRef.clear()
      )
    ).subscribe();
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode
  • Si el usuario tiene el rol, usamos el método createEmbeddedView del contenedor para renderizar el template.
  • Y si no lo tiene, llamamos al método clear del contenedor, para asegurarnos que el contenedor no tiene nada renderizado, cosa que podría pasar si el usuario ve degradados sus permisos a lo largo del uso de la aplicación.

Y con esto ya tendríamos nuestra directiva terminada, cuyo código completo quedaría de la siguiente manera:

@Directive({
  selector: '[appShowForRoles]',
})
export class ShowForRolesDirective implements OnInit, OnDestroy {
  @Input('appShowForRoles') allowedRoles?: Role[];
  private sub?: Subscription;

  constructor(
    private authService: AuthService,
    private viewContainerRef: ViewContainerRef,
    private templateRef: TemplateRef<any>
  ) {}
  ngOnInit(): void {
    this.sub = this.authService.user$
      .pipe(
        map((user) => Boolean(user && this.allowedRoles?.includes(user.role))),
        distinctUntilChanged(), // <--- incluido para ejecutar la lógica de renderizado solo si cambia resultado de la condicion anterior.
        tap((hasRole) =>
          hasRole
            ? this.viewContainerRef.createEmbeddedView(this.templateRef)
            : this.viewContainerRef.clear()
        )
      )
      .subscribe();
  }
  ngOnDestroy(): void {
    this.sub?.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

Una vez tenemos lista nuestra directiva solo nos queda declararla y exportarla desde un módulo de nuestra aplicación. (o marcarla como standalone en v14+)

@NgModule({
  declarations: [..., ShowForRolesDirective],
  ...
  exports: [..., ShowForRolesDirective],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

A continuación importar este módulo en el módulo que declare el componente en el que la queramos usar.

@NgModule({
  declarations: [HeaderComponent, ... ],
  imports: [..., AuthModule],
})
export class MyOtherModule {}
Enter fullscreen mode Exit fullscreen mode

Y por último ya podríamos usar nuestra directiva aplicándola directamente en el elemento que queramos mostrar condicionalmente, indicando en la asignación el array con el listado de roles para los que mostrar dicho elemento.

<a *appShowForRoles="['Accountant', 'Manager']" routerLink="/accounting">Contabilidad</a>
Enter fullscreen mode Exit fullscreen mode

Conclusiones Finales

Aunque RBAC en el frontend no podemos considerarlo como un sistema de seguridad como tal usando las guardas, como hemos visto, podemos conseguir un nivel básico de protección contra el usuario común limitándoles el acceso a las diferentes rutas de la aplicación.
Y haciendo uso de las directivas estructurales podemos proporcionarles adicionalmente una mejor experiencia de usuario, mostrándoles únicamente aquellos elementos y/o enlaces con los que su rol les permita interactuar.


Si deseas apoyar la creación de más contenido de este tipo, puedes hacerlo a través nuestro Paypal

Top comments (4)

Collapse
 
jangelodev profile image
João Angelo

Hi akotech,
Your tips are very useful.
Thanks for sharing.

Collapse
 
akotech profile image
akotech

thanks João 😉

Collapse
 
pterpmnta profile image
Pedro Pimienta M.

Gran post al igual que tus videos, no sabia que tenia usted aqui perfil, ya mismo lo sigo, que debo mejorar mucho en angular.

Collapse
 
akotech profile image
akotech

Muchas gracias Pedro. No publico tanto como me gustaría, pero también andamos por aquí.
Un saludo