Control de Acceso Basado en Roles(RBAC) en Angular
Tags: angular, spanish
Published: 2023-01-09 09:39:59

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1r2c4wnn9qjx3fan65re.png)

**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._ {% embed https://youtu.be/d-QRbhqVzgI %}

---

## 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.

```typescript
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.

```typescript
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:

```typescript
{
  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 <u>3 pasos principales</u>:

![Etapas de proceso de rutas](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xfa4qwqgapcqjrikiknd.png)

- 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.
    
    ```typescript
    // 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.
    
    ```typescript
    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 <u>renderizado de los componentes</u> 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sgnhb3niqjlq9bboyvug.png)

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.
    
    ```typescript
    @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+`)
    
    ```typescript
    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.

```typescript
//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`: 

```typescript
{
  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$` 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:

```typescript
//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?

```typescript
{
  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: 

```typescript
//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. 

```typescript
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$`. 

```typescript
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.

```typescript
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.
    
```typescript
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.

```typescript
{
  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:

```typescript
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:

```typescript
@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.

![header con tres enlaces](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jwompzrkllrygw5l5ild.png)

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:

```typescript
//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. 

```typescript
<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:

```typescript
@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.
    
    ```typescript
    //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.
    
    ```typescript
    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. 

```typescript
@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.

```typescript
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.

```typescript
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:

```typescript
@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+`*)

```typescript
@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.

```typescript
@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.

```typescript
<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](https://www.paypal.com/donate/?hosted_button_id=8HJNHL8VTB8ZG)_