Implement resource-based authorization with Angular

Photo by Chris Ried on Unsplash

Implement resource-based authorization with Angular

I wrote about my approach for resource-based authorization and possible implementation in ASP.NET Core. This article will continue the story and show how this can be implemented in Angular.

After the last article, we have a protected REST API in our ASP.NET Core backend. Technically we are fine with that. From a user's perspective, it is not very handy to run into unauthorized operations. To make this more user friendly we need a way to also check these permissions in our frontend. In the last few projects, I was involved in, we used Angular. So this article will target Angular even though the concept is also applicable to other frontend technologies such as React or ViewJS.

Loading the permissions from the backend

To be able to perform a permission check in the frontend, we need to load the authorization policies and user permissions from the backend.

First, we create an ASP.NET API controller returning the permissions of the logged-in user.

[ApiController]
[Route("api/v1/authorization-permissions")]
public class ResourcePermissionsController : ControllerBase
{
    [HttpGet]
    public ActionResult<Permission[]> GetPermissions()
    {
        var permissions = User.GetPermissions();
        // I usually map the entities to dto's, but  removed this here for simplicity
        return Ok(permissions);
    }
}

Then we create a service in the Angular frontend that can load the permissions.

export type PermissionAction = 'Read' | 'Write' | 'ReadRestricted' | 'ReadConfidential';

export interface Permission {
  resourceId: string[];
  actions: PermissionAction[];
}

@Injectable({
  providedIn: 'root',
})
export class AuthorizeService {
  constructor(private http: HttpClient) {}

  public getResourcePermissions(): Observable<Permission[]> {
    return this.http.get<Permission[]>(`${environment.apiBaseUrl}/authorize-permissions`);
  }
}

I usually use NgRX in my Angular applications, load the data in an effect, and store it in the NgRX state. I'm not going to go deeper into that topic because it is not important to the topic in this post.

Important is only, that you load the permissions at the correct time. Do you have any permissions to query for unauthenticated users, or are you forced to be logged in? In the applications I implemented, a user gets automatically logged in by single sign-on over an OAuth identity provider. So we loaded the user permission after the user had been signed in.

Loading the policies from the backend

As mentioned before, we also need to load the authorization policies into the Angular frontend. This is done similarly to the permissions.

We create an ASP.NET API controller returning the authorization policies. To mention is, that we need to allow to request them without the user being logged in because we are going to load them at the startup of the Angular application.

public class AuthorizePolicyDto
{
    public string? Name { get; set; }

    public ICollection<RequiredPermissionDto> Permissions { get; set; } = new Collection<RequiredPermissionDto>();
}

public class RequiredPermissionDto
{
    public ICollection<string> ResourceId { get; set; } = new Collection<string>();

    public PermissionAction Action { get; set; }
}

[ApiController]
[AllowAnonymous] // important, because we call this endpoint before the user has beed logged in
[Route("api/v1/authorization-policies")]
public class AuthorizationPoliciesController : ControllerBase
{
    public AuthorizationPoliciesController()
    {
        _mapper = mapper;
    }

    [HttpGet]
    public ActionResult<AuthorizePolicyDto[]> GetPolicies()
    {
        var policies = Enumeration.GetAllValues<AuthorizePolicy>();
        return Ok(MapToDtos(policies));
    }

    // in this case we map to dto's because the frontend needs it in a different format
    private static IEnumerable<AuthorizePolicyDto> MapToDtos(IEnumerable<AuthorizePolicy> policies)
    {
        return policies.Select(p => new AuthorizePolicyDto
            {
                Action = p.Action,
                ResourceId = p.ResourceId.Split('/', StringSplitOptions.None),
            });
    }
}

And then again, we load the policies into the Angular frontend.

export interface AuthorizePolicy {
  name: AuthorizePolicies;
  permissions: RequiredPermission[];
}

export interface RequiredPermission {
  resourceId: string[];
  action: PermissionAction;
}

// we extend the Authorize Service
@Injectable({
  providedIn: 'root',
})
export class AuthorizeService {
  constructor(private http: HttpClient) {}

  public getAuthorisationPolicies(): Observable<AuthorizePolicy[]> {
    return this.http.get<AuthorizePolicy[]>(`${environment.apiBaseUrl}/authorization-policies`);
  }
}

The authorization policies need to be loaded latest with the user permissions. You could load them together. We separated it and loaded the Policies in the Angular APP_INITIALIZER routine.

Checking the user permissions

Now we have all the data that is required to check the permissions in the frontend. We'll extend the AuthorizeService with the logic required to check the permissions against the policies. Again, I'm using NgRX, but you can use anything to store the permissions and policies in the front end.

// we extend the Authorize Service
@Injectable({
  providedIn: 'root',
})
export class AuthorizeService {
  constructor(private http: HttpClient, private store$: Store) {}

  public isAuthorized(policyName: AuthorizePolicies, values: Map<string, string>): Observable<boolean> {
    return combineLatest([
      this.store$.pipe(select(selectResourcePermissions)),
      this.store$.pipe(select(selectAuthorizationPolicies)),
    ]).pipe(
      // check if the permissions have been loaded
      filter(([permissions]) => permissions.status === 'SUCCEEDED' || permissions.status === 'FAILED'),

      // get the policy with the provided key
      map(([permissions, policies]) => {
        if (!policies.some((p) => p.name === policyName)) {
          throw new Error(`No policy with name '${policyName}' has been found`);
        }

        return {
          permissions,
          policy: policies.find((p) => p.name === policyName),
        };
      }),

      // check if the user is authorized
      map(({ permissions, policy }) => this.authorizeUser(permissions.data, policy, values)),
      first(),
    );
  }

  private authorizeUser(permissions: Permission[], policy: AuthorizePolicy, values: Map<string, string>): boolean {
    const isAuthorized =
      policy.permissions
        // replace variables in the policy
        .map((p) => AuthorizeService.substitutePermission(policy.name, p, values))

        // check policies against the permissions
        .map((p) => this.hasPermission(permissions, p))

        // are all required permissions granted?
        .findIndex((granted) => !granted) < 0;

    return isAuthorized;
  }

  private static substitutePermission(
    policy: string,
    permission: RequiredPermission,
    values: Map<string, string>,
  ): RequiredPermission {
    return {
      ...permission,
      resourceId: permission.resourceId.map((r) => {
        if (/^{.*}/g.test(r)) {
          const variableName = r.replace('{', '').replace('}', '');
          if (!values.has(variableName)) {
            throw new Error(`A value for the variable '${variableName}' is missing for policy ${policy}.`);
          }
          return values.get(variableName);
        }
        return r;
      }),
    };
  }

  private hasPermission(permissions: Permission[], requiredPermission: RequiredPermission): boolean {
    // check the every user permission if it matches the required permission
    const hasPermission =
      permissions.map((p) => AuthorizeService.matchPermission(p, requiredPermission)).findIndex((matched) => matched) >=
      0;

    return hasPermission;
  }

  private static matchPermission(permission: Permission, requiredPermission: RequiredPermission): boolean {
    if (permission.actions.findIndex((a) => a === requiredPermission.action) < 0) {
      return false;
    }
    for (let i = 0; i < requiredPermission.resourceId.length; i++) {
      if (permission.resourceId.length <= i) {
        return false;
      }
      if (permission.resourceId[i] === '**') {
        return true;
      }
      if (permission.resourceId[i] !== '*' && permission.resourceId[i] !== requiredPermission.resourceId[i]) {
        return false;
      }
    }
    if (permission.resourceId.length !== requiredPermission.resourceId.length) {
      return false;
    }
    return true;
  }
}

Are we done with that? Not yet. This is only the service that allows us to check the permission in any place within our Angular app. We can inject it into controllers, services, route guards, or directives to check the permissions.

Authorize Angular routes

We usually do a check in the routing. This can be done with a route guard which prevents the user from opening a page in the Angular app.

Note: we usually combine a check for authenticated AND authorized, I skip the authentication check because this depends on your way how authenticating a user.

export interface RouteData {
  authorize?: AuthorizeRouteData;
}

export const createAuthorizeRouteData = (policies: AuthorizePolicies[]): RouteData => ({ authorize: { policies } });

@Injectable({
  providedIn: 'root',
})
export class AuthGuard implements CanActivate, CanActivateChild {
  constructor(private oauthService: OAuthService, private store: Store, private authorizeService: AuthorizeService) {}

  async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
    return await this.isAuthorized(route);
  }

  async canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
    return await this.isAuthorized(childRoute);
  }

  private async isAuthorized(route: ActivatedRouteSnapshot): Promise<boolean> {
    // the required policy is defined in the Angular route data
    const routeData: RouteData = route.data;

    // skip it when no permission is required
    if (!routeData.authorize) {
      return true;
    }

    // extract the authorize policy variables from the route parameters
    const values = extractParams(route.root);

    // interate through the policies and check the user permissions
    for (const policy of routeData.authorize.policies) {
      const isAuthorized = await this.authorizeService.isAuthorized(policy, values).toPromise();
      if (!isAuthorized) {
        this.store.dispatch(go({ path: ['/auth', 'unauthorized'] }));
        return false;
      }
    }
    return true;
  }
}

As you have seen, the authorization policy variables get extracted from the route parameters. To do this we wrote a few helper methods.

export const extractParams = (
  route: ActivatedRouteSnapshot,
  params: Map<string, string> = new Map<string, string>(),
): Map<string, string> => {
  if (route.paramMap) {
    addParamMap(params, route.paramMap);
  } else {
    addParams(params, route.params);
  }
  for (const child of route.children) {
    extractParams(child, params);
  }
  return params;
};

export const addParams = (params: Map<string, string>, values: { [index: string]: string }): Map<string, string> => {
  if (values) {
    for (const key of Object.keys(values)) {
      params.set(key, values[key]);
    }
  }
  return params;
};

export const addParamMap = (params: Map<string, string>, values: ParamMap): Map<string, string> => {
  if (values) {
    for (const key of values.keys) {
      params.set(key, values.get(key));
    }
  }
  return params;
};

To make this work, the parent route parameters must be inherited in order that all parameters are present in the current route.

const routes: Routes = [
  // your routes
];

export const routingConfiguration: ExtraOptions = {
  paramsInheritanceStrategy: 'always',
};

@NgModule({
  imports: [RouterModule.forRoot(routes, routingConfiguration)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

With that in place, a specific route can be guarded with one or more authorization policies.

const routes: Routes = [
  {
    path: 'employees/:employeeId',
    component: EmployeeComponent,
    data: createAuthorizeRouteData(['EMPLOYEE_READ']),
    canActivate: [AuthGuard],
  },
];

Authorize HTML elements with an Angular directive

The last thing I want to show you is how we show, resp. hide HTML elements if you are authorized to see them or not. Instead of always injecting the AuthorizeService into the controllers, we implemented a simple structural directive that does the check for us.

There are two ways to pass the authorization policy variables to the directive:

  • by leveraging the route parameters as we did in the AuthGuard

  • by passing them directly into the directive

export interface AuthorizeOptions {
  values: { [index: string]: string };
}

@Directive({ selector: '[appShowAuthorized]' })
export class ShowAuthorizedDirective implements OnInit, OnDestroy {
  @Input('appShowAuthorized') authorizePolicies: AuthorizePolicies;
  @Input() appShowAuthorizedOptions: AuthorizeOptions;

  private unsubscribe$ = new Subject();

  constructor(
    private authorizeService: AuthorizeService,
    private store$: Store<RootState>,
    private templateRef: TemplateRef<unknown>,
    private viewContainer: ViewContainerRef,
  ) {}

  ngOnInit(): void {
    if (!this.authorizePolicies) {
      this.viewContainer.clear();
      return;
    }

    this.store$
      .pipe(
        select((state) => state.router),
        // prevent the directive to get a different route config than the initial one on navigating away
        scan((acc: RouterReducerState<SerializedRouterStateSnapshot>, val) => {
          if (!acc || routeConfigEquals(acc.state.root, val.state.root)) {
            return val;
          }

          return acc;
        }, <RouterReducerState<SerializedRouterStateSnapshot>>null),
        filter((router) => !!router),
        map((router) => extractParams(router.state.root)),
        map((params) => addParams(params, this.appShowAuthorizedOptions?.values)),
        mergeMap((params) => this.authorizeService.isAuthorized(this.authorizePolicies, params)),
        takeUntil(this.unsubscribe$),
      )
      .subscribe((isAuthorized) => {
        if (isAuthorized) {
          this.viewContainer.clear();
          this.viewContainer.createEmbeddedView(this.templateRef);
        } else {
          this.viewContainer.clear();
        }
      });
  }

  ngOnDestroy(): void {
    this.unsubscribe$.next();
  }
}

This directive can be used directly in an HTML element.

<div *appShowAuthorize="'EMPLOYEE_READ'; options: { values: { employeeId: specificEmployeeId } }">
    <......></......>
</div>

Summary

Wow, that was quite a bit... After explaining our approach to a resource-based authorization model and implementing it in ASP.NET Core I showed you how to implement it in an Angular application. It is basically always the same concept and logic but implemented in a different language and framework. I hope it can help you build your authorization framework in your own application. If you have questions, please feel free to add a comment.