preloader
學習

Create Mechanism of Role Resource Permission of Nebular and Ngxadmin

Create Mechanism of Role Resource Permission of Nebular and Ngxadmin

Nebular is a UI libary from Akveo company for Angular. It can be customized for meeting your needs and has many components and visual themes. It also has authenticate and security modules. NgxAdmin is an admin dashboard built based on Nebular. NgxAdmin is also customizable.

Key tip: I use isGranted() of NbAccessChecker from Nebular’s security package, canLoad and canActivate directive from Angular framework to achieve features inside this article.

I had been requested to create mechanisms of switching modules' visibility and activation for login-accounts with legacy code of a product from a stakeholder, which were built on Nebular and starter-kit branch of NgxAdmin. After taking trial and efforts of up to couple of months, I have finished implementation of the features and then meet requirements. The product is started to develop from an early version at 2.0.0-rc9 of NgxAdmin and Nebular. Nebular’s security package provides two categories of access control mechanism with role, resource and permission concept. First one is that whole mechanism fully integrated and controlled by backend API, the other one is frontend side offloads part of mechanism from backend-side. Considering rushed schedule of rolling out for products and unsure whether there has successful cases for first one at issue threads of the repository at GitHub.com or not, I chose to the latter.

Brief about requirements

There are around 4-level permissions ranging from guest, user, manager and admin. Guest can login and it only can see welcome page. Login users and related manager can see content and entries of modules they have purchased and see only entries at side-menu about not purchased modules if admin wants to attract them to buy ones and only set their permission towards modules to be visible and inactivated. In a module, there may exist some content or entries that a manager can access but its user cannot access. For example, we assume there has 2 modules: moduleA and moduleB can be purchased and the extra module is admin-permission-setting for admin, which is not purchased, hidden and prohibit accessed by user and manager-role level account. All modules are lazy-loaded with Angular. Admin role can access all content and entries.

admin manager user guest
welcome page O O O O
module A
entry A1 O O O
entry A2 O O
entry A3 O
Module B
entry B1 O O O
entry B2 O O O
entry B3 O O
admin-permission-setting module
entry C1 O

table for relationship between modules and roles

Notice: If a manager and its user are set for activated and visible for module A and B, above table describe entries' accessibility for them. It also provide information for admin and guest role.

Following are described and tested at environment of Angular 7.2.4 and Nebular 3.3.0. The structure of files and folders is similar with starter-kit branch of NgxAdmin at GitHub, so you can refer it.

List of files for usage and described at below:

  • src/app/@core/core.module.ts -> already exists in ngx-admin’s starter-kit branch
  • src/app/services/role-provider.service.ts -> I create for this feature
  • src/app/pages/page-menu.ts -> already exists in ngx-admin’s starter-kit branch
  • src/app/pages/pages-routing.module.ts -> already exists in ngx-admin starter-kit branch
  • src/app/pages/pages.component.ts -> already exists in ngx-admin starter-kit branch
  • src/app/pages/moduleA/moduleA-routing.module.ts -> already exists in product legacy code
  • src/app/services/entry-checking-with-role-permission.service.ts -> I create for this feature
  • src/app/guards/entry-routing-acl.guard.ts -> I create for this feature

For core.module.ts

Under accessControl of NbSecuritryModule.forRoot({}), it’s for defining roles, visibility and activation of modules. Excerpt content of core.module.ts for roles:

NbSecurityModule.forRoot({
  accessControl: {
    // ...(skipped content )
    guest: {
      view: ['welcome'],
      load: ['welcome'],
    },
    user: {
      parent: 'guest',
    },
    manager: {
      parent: 'user',
      edit: ['moduleA', 'moduleB'],
    },
    admin: {
      parent: 'manager',
      admin: ['moduleA'],
      view: '*',
      load: '*',
    },
  },
}).providers;

From above content, guest is a role name, it has two permissions about view and load. I define view permission for a role here that describes an entry of side-menu or a resource is visible. I define load permission for a role here that describes a resource is enable. Notice: Correspond value to view/load/admin permission can be string-type or array of string. This information comes from tutorial of Nebular security.

A role can only inherit from single other role. So from above content, inherit chain of roles is guest be inherit to user role, manager inherit from user role, and admin inherit from manager.

Notice: view, load, admin and edit at permission section of roles and moduleA, moduleB and welcome at corresponded value with permissions, which are defined above example, can be defined by yourself. There is no other constraints for usage of Nebular security package except keyword: parent in attribute’s name and asterisk symbol in attribute’s value.

For section of admin role, values of its view and load permissions are both asterisk symbol. Asterisk symbol means all resources of this web system. It’s convenient for the situation of this web system is adding new modules in the future, and there is no need to change values for correspond permissions. If value here changes to array of string-type, it’s needed to add new value to the array when the web system is adding new modules or resources in the future.

Manager role has edit permission and corresponded values of resources: moduleA and moduleB for presenting it can access entries of A2 and B3 that user role can’t. Similarly, Admin role has admin permission and corresponded value of resources: moduleA for presenting it can access the entry of A3 that manager role can’t. You must understand that, mentioned relationship table and content of data parameter inside page-menu.ts file together. I will state page-menu.ts later.

Excerpt content of core.module.ts for visibility and activation of modules:


// in accessControl section of NbSecurityModule.forRoot()
moduleAShow: {
    view: ['moduleA'],
},
moduleAActivate: {
    load: ['moduleA'],
},
moduleBShow: {
    view: ['moduleB'],
},
moduleBActivate: {
    load: ['moduleB'],
},

For achieving goals about visibility and activation of a module, I set two roles with related permission and resource. Again, view is used for describing an entry of side-menu or a resource, which is at correspond attribute value section, can be saw on UI layout. load permission is used for a resource can be activated and then be accessible. A resource, which is defined for examples in this article, ranges from a module, a link within a module/routing module, to an element on UI layout. In my principles, I usually take the name of a module as a resource at attribute’s value.

From content of core.module.ts for visibility and activation of modules, moduleAShow role has view permission that makes entries of moduleA at sidemenu UI can be saw by accounts with that. moduleAActivate role has load permission that makes features, links and pages of moduleA can be accessible by accounts with that. Roles of moduleBShow and moduleBActivate have similar meanings for moduleB.


For role-provider.service.ts

From instructions of Nebular’s security package, you must create it manually if file content of your web system does not have role-provider.service.ts file. You also need to provide getRole() and implementation within it. In the content, you can integrate related backend API(s) for your system and getRole() must provide names of roles that a login account has, which are including module’s visibility and activation. Notice: If backend API(s) cannot directly provide name of roles that for corresponded visibility and activation of a module, you can write convert functions for transforming content of API’s response to meet needs for front-end usage at front-end side.

Excerpt content of role-provider.service.ts

@Injectable()
export class RoleProviderService implements NbRoleProvider {
  constructor() {} // skip content

  getRole(): Observable<string | string[]> | string | string[] {
    /**
     * Implement integration with related backend API(s).
     * You must return things for fitting above return-types. In my experience, I only return Observable<string | string[]> with role names the login account has, and they must aligned with ones already defined at core.module.ts .
     * Notice: If there is a need for converting backend's response to fit front-end needs,
     * you can write your own functions for conversion.
     */
  }
}

Notice1: getRole() will be called automatically when you are using isGranted() of NbAccessChecker and subscribe result of isGranted(). So it will send requests to backend API as you subscribing results of isGranted(). Notice2: getRole()` can return an observable-type of array of strings and single string. It supports multiple roles within an account. An account often has a combination of multiple roles such as roles of manager, moduleAShow and moduleAActivate, or another one of user, moduleBShow, and mobuleBActivate.


For page-menu.ts

Excerpt content of page-menu.ts

export const MENU_ITEMS: NbMenuItem[] = [
  {
    title: 'Welcome',
    icon: 'home-outline',
    link: 'pages/welcome',
    home: true,
    key: 'welcome',
    data: {
      permission: 'view',
      resource: 'welcome',
    },
  },
  {
    title: 'module A',
    icon: 'lock-outline',
    key: 'module A',
    data: {
      permisssion: 'view',
      resource: 'moduleA',
    },
    children: [
      {
        title: 'entry A1',
        link: '/pages/module-A/A1',
        key: 'A1',
      },
      {
        title: 'entry A2',
        link: '/pages/module-A/A2',
        key: 'A2',
        data: {
          permission: 'edit',
          resource: 'moduleA',
        },
      },
      {
        title: 'entry A3',
        link: '/pages/module-A/A3',
        key: 'A3',
        data: {
          permission: 'admin',
          resource: 'moduleA',
        },
      },
    ],
  },
  {
    title: 'module B',
    icon: 'home-outline',
    key: 'module B',
    data: {
      permission: 'view',
      resource: 'moduleB',
    },
    children: [
      {
        title: 'entry B1',
        link: '/pages/module-B/B1',
        key: 'B1',
      },
      {
        title: 'entry B2',
        link: '/pages/module-B/B2',
        key: 'B2',
      },
      {
        title: 'entry B3',
        link: '/pages/module-B/B3',
        key: 'B3',
        data: {
          permission: 'edit',
          resource: 'moduleB',
        },
      },
    ],
  },
  {
    title: 'Admin permission setting',
    icon: 'lock-outline',
    key: 'permission-setting',
    data: {
      permission: 'view',
      resource: 'admin-permission-setting',
    },
    children: [
      {
        title: 'entry C1',
        link: '/pages/admin-permisssion-setting/C1',
        key: 'C1',
      },
    ],
  },
];

I define a module can be saw(visibility) at side-menu area about U.I. by using parameter of data object, which contains values of permission and resource. It’s recommended for you to read and understand content of this file with the table for relationship between modules and roles and also includes in role section of core.module.ts.

In principle, I define a module’s visibility at side-menu area at its top-level section, so all of its children have same visibility with same permission and resource without explicitly adding same data parameter to each of them. If a child under a module has extra requirement for visibility such as entry A2 and B3 cannot be saw by user role but can be saw by manager and admin, I will assign a data object to this child with different permission and role comparing to the module’s top-level one. If a child under a module cannot be saw by roles of user and manager but can be saw only by admin such as entry A3, I will add a data object explicitly to it with different permission and resource.

Again, the module of admin-permission-setting is only accessible by admin role, so I add a data object to this module’s top-level with its permission and resource. This module is static and only visible for admin role, so it would not be listed at core.module.ts with other roles.


For pages-routing.module.ts

I define a module’s activation at file pages-routing.module.ts. In this case, all modules are lazy-loaded, so we assign a data object at each module’s top-level and component and use a directive canLoad from Angular in this file for dynamically checking a module can be accessed.

I implement canLoad logic for this feature from requests of a stakeholder in file entry-routing-acl.guard.ts and I will state its content in the later part of this article.

Excerpt content of pages-routing.module.ts

const routes: Route = [
  {
    path: '',
    component: PagesComponent,
    children: [
      {
        path: 'welcome',
        component: WelcomeComponent,
        data: {
          permission: 'load',
          resource: 'welcome',
        },
        canLoad: [EntryRoutingACLGuard],
      },
      {
        path: 'moduleA',
        loadChildren: './moduleA/moduleA.module#moduleA',
        data: {
          permission: 'load',
          resource: 'moduleA',
        },
        canLoad: [EntryRoutingACLGuard],
      },
      {
        path: 'moduleB',
        loadChildren: './moduleB/moduleB.module#moduleB',
        data: {
          permission: 'load',
          resource: 'moduleB',
        },
        canLoad: [EntryRoutingACLGuard],
      },
      {
        path: 'admin-permission-setting',
        loadChildren:
          './adminPermissionSetting/adminPermissionSetting.module#AdminPermissionSetting',
        data: {
          permission: 'load',
          resource: 'admin-permission-setting',
        },
        canLoad: [EntryRoutingACLGuard],
      },
    ],
  },
];

From above content of pages-routing.module.ts, each module and component all has its data object and same canLoad directive corresponded Guard: EntryRoutingACLGuard. Each data object contains same permission: load, which means its access permission toward a resource, and different resource name, which means the module represents its resource.


For pages.component.ts

In starter-kit branch of ngx-admin, this component load all entries, which are listed at page-menu.ts to side-menu. For fulfilling the feature of modules' visibility, I add extra mechanism for dynamically setting visibility of an entry regards to different account and its role.

In this file, I call a function setVisibilityOfEntries() coming from entry-checking-with-role-permission.service.ts for setting visibility of a module and its children.

Excerpt content of pages.component.ts

export class PagesComponent {
  menu = [];

  constructor(private _entryChkr: EntryCheckingWithRolePermissionService) {}

  ngOnInit() {
    this.menu = MENU_ITEMS;

    this.menu = this._entryChkr.setVisibilityOfEntries(this.menu);
  }
}

For moduleA-routing.module.ts

For fulfilling feature of checking activation of a module and its children, I add canActivate directive with EntryRoutingACLGuard routing guard to each child explicitly for dynamically checking whether they are accessible by a login account with its own roles or not. This meaning is same as declaration about a routing path needs to be checked.

Warning: If a child does not have canActivate directive with EntryRoutingACLGuard, it won’t be checked about role, resource and permission within a login account.

Excerpt content of moduleA-routing.module.ts

const routes: Routes = [
  {
    path: '',
    component: ModuleAComponent,
    children: [
      {
        path: 'A1',
        component: A1Component,
        canActivate: [EntryRoutingACLGuard],
      },
      {
        path: 'A2',
        component: A2Component,
        canActivate: [EntryRoutingACLGuard],
      },
      {
        path: 'A3',
        component: A3Component,
        canActivate: [EntryRoutingACLGuard],
      },
    ],
  },
];

For entry-checking-with-role-permission.service.ts

This file is used for setting entries and sub-entries at side-menu hidden or show with result of NbAccessChecker’s isGranted(). I also implement a function: moduleIsLoadable() for checking whether a module is loadable or not. If it returns true means Angular can load this module for the current login account. Otherwise, Angular cannot load the module for this login account.

Excerpt content of entry-checking-with-role-permission.service.ts

@Injectable()
export class EntryCheckingWithRolePermissionService {
  constructor(private _acesChkr: NbAccessChecker) {}

  /**
   * Set visibility of a entry of side-menu.
   */
  setVisibilityOfEntries(entries: MenuItem[]): MenuItem[] {
    entries.forEach((element) => {
      if (
        element.data &&
        element.data['permission'] &&
        element.data['resource']
      ) {
        this._acesChkr
          .isGranted(element.data['permission'], element.data['resource'])
          .subscribe((granted) => {
            element.hidden = !granted;
          });
      } else {
        // Set an entry to hidden by default if it does not provide information of permission and resource with data object.

        element.hidden = true;
      }

      // set for sub-entry
      if (
        !element.hidden &&
        item.children != null &&
        item.children !== undefined
      ) {
        element.children.forEach((subElement) => {
          if (
            subElement.data &&
            subElement.data['permission'] &&
            subElement.data['resource']
          ) {
            this._acesChkr
              .isGranted(
                subElement.data['permission'],
                subElement.data['resource']
              )
              .subscribe((granted) => {
                subElement.hidden = !granted;
              });
          } else {
            /**
             * Align a sub-entry's hidden same as entry's hidden by default
             * if data object of a sub-entry does not provide information of
             * permission and resource.
             */

            subElement.hidden = element.hidden;
          }
        });
      }
    });

    return entries;
  }

  /**
   * Answer a module is Loadable for an account.
   *
   */
  moduleIsLoadable(
    route: Route
  ): boolean | Observable<boolean> | Promise<boolean> {
    if (route.data && route.data['permission'] && route.data['resource']) {
      /**
       * Notice: RxJS 6 can use toPromise(), RxJS 7 still can use toPromise() but it becomes depricated.
       * Start from RxJS 8, you cannot use toPromise() anymore, and it's recommended to use
       * lastValueFrom() or firstValueFrom()
       */

      return this._acesChkr
        .isGranted(route.data['permission'], route.data['resource'])
        .toPromise()
        .then((resp) => {
          if (resp === false) {
            /**
             * If you want to show message about cannot load this module,
             * you can put your code about displaying message here.
             */
          }

          return resp;
        });
    }

    /**
     * If a route without data object, information of permission and resource, set it to be unloadable for keeping it a safe state.
     */

    return false;
  }
}

For entry-routing-acl.guard.ts

This file is implemented for canLoad and canActivate directives. canLoad directive, which is from Angular, is used for checking a lazy-loaded module can be loaded for a login account. It returns true and then load the module if a login account has adequate roles with permissions and resources. Otherwise, it returns false. It works if a tab of a browser is only used for single login account. canLoad directive won’t work if multiple accounts, which have different activated modules, use same tab of a browser successively without refreshing page. So it’s time to introduce canActivate directive for route-guard enhancement.

canActivate directive will guard route paths of children under a module. canActivate directive does following instructions in steps:

  1. Extract intent url from route
  2. Check whether the intent url is existing in the routing set of the web system, ranging from multiple modules' top-level and their children. If it doesn’t found matched route with intent url, the guard returns false, which means it refuses to route to the intent url and will keep stay same url.
  3. If there is a found url but doesn’t matched intent url, or a found url same as intent url but it was set to hide, canActivate function return false.
  4. If there is a found url same as the intent url, and not set to hide, let’s use isGranted() of NbAccessChecker with permission and resource information from data object at intent url. Transform result of isGranted() from Observable to Promise and use then() to handle what you want such as displaying a message, finally return this result of the then().
  5. If the intent url doesn’t have information of permission and resource from data object, let’s use its parent’s data object as alternative one for parameters to isGranted() of NbAccessChecker. It’s also needed to transform result of isGranted() from ObservabletoPromiseand then usethen()to handle what you want such as displaying a message. Finally, please return result of thethen()`.
  6. Return true at this step if the intent url does not match above conditions. Returning true means allow to direct the login account to the route it wants.

 

Excerpt content of entry-routing-acl.guard.ts

@Injectable()
export EntryRoutingACLGuard implements canLoad, canActivate {

    private _menuEntries = [];

    constructor(
    private _itemChekr: EntryCheckingWithRolePermissionService,
    private _acesChekr: NbAccessChecker,
    ) {

        this._menuEntries = MENU_ITEMS;

        this._menuEntries = this._itemChekr.setVisibilityOfEntries(this._menuEntries);


    }

    /**
    * confirm a module is loadable for the login account
    */
    canLoad(
    route: Route, segments: UrlSegment[],
    ): Observable<boolean> | Promise<boolean> | boolean {

        return this._itemChekr.moduleIsLoadable(route);
    }

    /**
    * return part of resolved url at routing
    */
    getResolvedUrl(route: ActivatedRouteSnapshot): string {
        return route.pathFromRoot
        .map(v => v.url.map(segment => segment.toString()).join('/'))
        .join('/');
    }

    /**
    * confirm a intent url can be routed for the login account
    */
    canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot,
    ): Observable<boolean> | Promise<boolean> | boolean {

        // get url that after the hashtag at browser's url field
        let tmpPartOfUrl_1 = this.getResolvedUrl(next);

        // get last part of url from the intent url
        const childUrlStr = next.children.map(v => {
            return v.url.map(part => part.toString()).join('/');
        });

        if (childUrlStr.length > 0) {
            /**
            * append childUrlStr to tail of tmpPartOfUrl_1 if length of childUrlStr is greater than zero
            */
            tmpPartOfUrl_1 += `/${ childUrlStr }`;
        }

        /**
        * replace continuous slashes to single slash.
        */
        const partOfUrl = tmpPartOfUrl_1.replace(/\/\//gi, '/');

        /**
        * Find out matched link of children of items under _menuEntries
        */
        let matchUrlElement: MenuItem = {title: ''};

        for (const { title, link, children } of this._menuEntries) {

            if (link === partOfUrl) {
              /**
              * Jump out the loop if it finds out an element at main item
              */
              matchUrlElement = { title, link };
              break;
            }

            if (children !== undefined) {

                /**
                * Further find out content of link if there exists sub-items.
                * Jump out the loop if it finds out  an element at sub-items.
                */

                matchUrlElement = children.find(element => {
                    return element.link = partOfUrl;
                });

                if (matchUrlElement !== undefined) {
                    break;
                }
            }
        }


        if (matchUrlElement === undefined) {
            // if there is no link same as the intent url, return false.

            return false;
        }

        if (
            (matchUrlElement.link !== partOfUrl) ||
            (
                (matchUrlElement.link === partOfUrl) &&
                (matchUrlElement.hidden === true)
            )
        ) {

            /**
            * Return false if a found link is not matched or
            * the found link is matched but it's hidden for this login account
            */
            return false;
        }

        /**
        * Check whether the data object of intent url is existing or not.
        * If it exists, and then use NbAccessChecker's isGranted() to
        * check the route can be reached by Angular for this login account.
        * Notice: If it doesn't exist, and then to check data object of the intent url's parent link.
        */
        if (next.data !== undefined && Object.entries(next.data).length > 0) {

            /**
            * Examine a link from <module-name>-routings.module.ts can be routed for
            * the login account's permission and resource
            */
            return this._acesChekr.isGranted(next.data['permission'], next.data['resource']).toPromise().then(resp=> {
                if (resp === false) {
                  /**
                  * Put your code here if you have a requirement about
                  * when Angular cannot route to the intent url for the login account.
                  */
                }
                return resp;
            });
        }


        /**
        * Check whether next.parent['data'] is existing or not.
        * If it exists, and then to use NbAccessChecker's isGranted() to check the route can be routed by Angular for the login account.
        * Note: Use next variable(Angular's ActivatedRouteSnapshot-type) with
        * NbAccessChecker's isGranted()
        */
        if (
            next.parent['data'] !== undefined &&
            Object.entries()next.parent['data'].length > 0
        ) {

            /**
            * Examine a link from <module-name>-routings.module.ts can be routed
            * for the login account's permission and resource
            */
            return this._acesChekr.isGranted(next.parent['data']['permission'], next.parent['data']['resource']).toPromise().then(resp => {

                if (resp === false) {

                    /**
                    * Put your code here if you have a requirement about
                    * when Angular cannot route to the intent url for the login account.
                    */
                }

                return resp;
            });
        }
    }
}

If you have done above things and reach here, congratulations! Your web system now have ability for setting visibility and activation of modules for all accounts.


Ref. 1: https://github.com/akveo/ngx-admin/issues/1779

Ref. 2: Inspired implementation from dynamic entries of sidemenu https://github.com/akveo/nebular/issues/274#issuecomment-399325274

Ref. 3: Nebular ACL configuration and usage https://akveo.github.io/nebular/docs/security/acl-configuration--usage#acl