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.
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
...
}
]
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>
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: [],
...
}
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:
-
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.
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 devolverfalse
, en vez de detener la navegación, hace que elRouter
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 decanMatch
. - 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
}
}
Si ahora asignáramos esta guarda a una ruta con el path private-zone
:
{
path: 'private-zone',
component: PrivateZoneComponent,
canActivate: [IsLoggedInGuard],
}
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$
estrue
(el usuario ha iniciado sesión), elRouter
procederá a renderizar dicho componente en el<router-oulet>
correspondiente. - Si por el contrario el primer valor emitido por
isLoggedIn$
esfalse
, elRouter
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']))
);
}
}
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'])],
...
},
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'))
);
}
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
}
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$
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)))
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'))
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'],
},
...
},
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 | ...
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'))
);
}
}
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.
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>
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>
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>
En nuestro caso la llamaremos showForRoles
, lo que nos genera el siguiente archivo:
@Directive({
selector: '[appShowForRoles]',
})
export class ShowForRolesDirective {
constructor() {}
}
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();
}
}
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>
) {}
...
}
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();
}
...
}
- 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();
}
}
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 {}
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 {}
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>
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)
Hi akotech,
Your tips are very useful.
Thanks for sharing.
thanks João 😉
Gran post al igual que tus videos, no sabia que tenia usted aqui perfil, ya mismo lo sigo, que debo mejorar mucho en angular.
Muchas gracias Pedro. No publico tanto como me gustaría, pero también andamos por aquí.
Un saludo