diff --git a/.angular-cli.json b/.angular-cli.json index 6633830990..a2fa2e57f0 100644 --- a/.angular-cli.json +++ b/.angular-cli.json @@ -82,7 +82,8 @@ "assets": [ "assets", "favicon.ico", - "favicon.png" + "favicon.png", + "google46533d2e7a851062.html" ], "index": "index.html", "main": "main.ts", diff --git a/DEV_DOCS.md b/DEV_DOCS.md index a7fac4bbf2..46cde7d13f 100644 --- a/DEV_DOCS.md +++ b/DEV_DOCS.md @@ -45,6 +45,8 @@ We have to: - theme - `@nebular/theme` npm package, main framework package - auth - `@nebular/auth` npm package, auth package (login, register, etc) - icons - `nebular-icons` npm package, cool icons font + - security - `@nebular/security` npm package, security framework package + ## Auth // TODO diff --git a/README.md b/README.md index fbe993fae9..8d156959b6 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,13 @@ What is included: - Authentication components (login/register/reset password/restore password). - Multiple configurable providers (backend connectors). - Helpers for token management (storing, passing with HTTP requests, etc). -3) [ ] @nebular/acl - module for roles and permissions management +3) [x] @nebular/security + - Roles and permissions management (ACL) + - `*nbIsGranted` conditional directive 4) [ ] @nebular/dashboard - module for draggable/resizable dashboards creation 5) [ ] @nebular/data - application data & state management 6) [x] Admin dashboard starter kit [ngx-admin](http://github.com/akveo/ngx-admin) - application based on Nebular modules with beautiful IOT components. -7) [ ] More great features! +7) [ ] More great features to come! ### Demo Application: @@ -30,10 +32,10 @@ What is included: ### Use cases -Nebular is a great toolkit if you build a Rich UI application based on Angular, and don't want to spend your time on painful project setup. It provides you with a unified approach for managing styles for various components (3rd party including), pure components tightly connect to Angular and authentication layer easily configurable for your API. +Nebular is a great toolkit if you build a Rich UI web-application based on Angular, and don't want to spend your time on painful project setup. It provides you with a unified approach for managing styles of various components (3rd party including), pure components tightly connected to Angular and authentication layer easily configurable for your API. ### The purpose -There are a lot of awesome front-end frameworks out there these days. They provide a massive quantity of useful features making our lives more comfortable. Our intention is not to create a new one as we are pretty much aware of the complexity and amount of work developers put on their creations. But as developers, we feel that nowadays front-end development is disjointed. You have to search for libraries, go through the different installation process, everything looks different, and sometimes it's just annoying that you can't just sit and start going. That's why we are on a mission to assemble together the most usefule modules and libraries, join them with a unified application and graphical interface creating a great toolkit for easier setup. +There are a lot of awesome front-end frameworks and libraries out there these days. They provide a massive quantity of useful features making our lives more comfortable. Our intention is not to create a new one as we are pretty much aware of the complexity and amount of work developers put on their creations. But as developers, we feel that nowadays front-end development is disjointed. You have to search for libraries, go through the different installation process, everything looks different, and sometimes it's just annoying that you can't just sit and start going. That's why we are on a mission to assemble together the most usefule modules and libraries, join them with a unified application and graphical interface creating a great toolkit for easier setup. ### Documentation Installation, customization and other useful articles: https://akveo.github.io/nebular diff --git a/docs/app/homepage/homepage.component.html b/docs/app/homepage/homepage.component.html index dfa1556a47..c6eeb238fc 100644 --- a/docs/app/homepage/homepage.component.html +++ b/docs/app/homepage/homepage.component.html @@ -41,7 +41,7 @@

Nebular

Set of essential modules for your next Angular application.

Nebular's primary goal is to assemble together and connect the most awesome features and libraries - creating an efficient ecosystem to speed up and simpify the development.

+ creating an efficient ecosystem to speed up and simplify the development.

Demo Documentation @@ -95,8 +95,8 @@

THEME SYSTEM

-

Auth Module

-

Now front-end authentication layer is just a matter of a configuration. Concepts.

+

Auth & Security Module

+

Now front-end authentication and authorization are just a matter of a configuration. Auth & Security Concepts.

diff --git a/docs/app/homepage/homepage.component.ts b/docs/app/homepage/homepage.component.ts index 4a1f4540c3..3637e50907 100644 --- a/docs/app/homepage/homepage.component.ts +++ b/docs/app/homepage/homepage.component.ts @@ -117,7 +117,7 @@ export class NgdHomepageComponent implements AfterViewInit, OnInit { } ngOnInit() { - this.titleService.setTitle('Nebular - full-featured framework based on Angular.'); + this.titleService.setTitle('Nebular is a set of essential modules for your next Angular application.'); } ngAfterViewInit() { diff --git a/docs/articles/index.md b/docs/articles/index.md index f917b9e759..648d122969 100644 --- a/docs/articles/index.md +++ b/docs/articles/index.md @@ -14,7 +14,7 @@ In simpler words, Nebular is a set of modules (components, services & styles) he ## What's included -What is included: +Nebular modules are distributed as separated `npm` packages, here's a list of currently available: - **@nebular/theme** - Theme System - set of SCSS rules, which allow you to modify application looks & feel by changing style-variables, with fewer custom styles. @@ -24,7 +24,9 @@ What is included: - Authentication components (login/register/reset password/restore password). - Multiple configurable providers (backend connectors). - Helpers for token management (storing, passing with HTTP requests, etc). -- *@nebular/acl* - module for roles and permissions management. Coming soon. +- **@nebular/security** - module for roles and permissions management. + + - *@nebular/dashboard* - module for draggable/resizable dashboards creation. Coming soon. - *@nebular/data* - application data & state management. Coming soon. - **Admin dashboard starter kit ngx-admin** - application based on Nebular modules with beautiful IOT components. diff --git a/docs/articles/security-acl-configuration.md b/docs/articles/security-acl-configuration.md new file mode 100644 index 0000000000..6e08fcc8d9 --- /dev/null +++ b/docs/articles/security-acl-configuration.md @@ -0,0 +1,221 @@ +## Abstract + +Permissions control is a general task when it comes to development of more or less complex web application. Your application may have various roles and resources you need to protect. +ACL (access control list) provides a flexible way of configuring "who can do what against what resource". + +In this article we configure a common setup when the app has three roles (`guest`, `user` and `moderator`), the roles have different permissions (`view`, `create`, `remove`) +and the application contains two type of resources that needs to be protected (`news`, `comments`). + +## ACL Configuration + +Nebular ACL has a simple way of setting it up. When registering a module you can specify a set of ACL rules by simply providing it as a module configuration. + +Let's assume that our guest users can only `view` `news` and `comments`, users can do everything as guests, but also can `create` `comments`, and moderators can also `create` and `remove` `news` and `comments`. +Now, let's convert this into an ACL configuration object which Nebular can understand. Open your `app.module.ts` and change the `NbSecurityModule.forRoot()` call as follows: + +```typescript + +@NgModule({ + imports: [ + // ... + + NbSecurityModule.forRoot({ + accessControl: { + guest: { + view: ['news', 'comments'], + }, + user: { + parent: 'guest', + create: 'comments', + }, + moderator: { + parent: 'user', + create: 'news', + remove: '*', + }, + }, + }), + + ], + +``` + +As you can see the configuration is pretty much straightforward, each role can have a list of permissions (view, create, remove) and resources that are allowed for those permissions. We can also specify a `*` resource, +which means that we have a permission againts any resource (like moderators can remove both news and comments). +
+ +## Role Configuration + +So far we told Nebular Security what roles-permissions-resources our application has. Now we need to specify how Nebular can determine a role of currently authenticated user. +To do so we need to create a `RoleProvider` with one simple method `getRole`, which returns an `Observable` of a role. +In a simplest form we can provide this service directly in the main module: + + +```typescript +// ... + +import { of as observableOf } from 'rxjs/observable/of'; +import { NbSecurityModule, NbRoleProvider } from '@nebular/security'; + + +@NgModule({ + imports: [ + // ... + + NbSecurityModule.forRoot({ + // ... + }), + + ], + providers: [ + // ... + { + provide: NbRoleProvider, + useValue: { + getRole: () => { + return observableOf('guest'); + }, + }, + }, + ], +``` +That's easy we have just provided a role, so that Nebular can determine which user is currently accessing the app. +The good thing about this configuration is that it's not tightly coupled with the rest of your authentication flow, which gives you a lot of flexibility over it. + +But, in our example the role is "hardcoded", which in the real world app would be dynamic and depend on the current user. + +Assuming that you already have `Nebular Auth` module fully configured and functioning based on `JWT` we will adjust the example to retrieve a role from the user token. + +Let's create a separate `role.provider.ts` service in order not to put a lot of logic into the module itself: + +```typescript +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { map } from 'rxjs/operators/map'; + +import { NbAuthService, NbAuthJWTToken } from '@nebular/auth'; +import { NbRoleProvider } from '@nebular/security'; + + +@Injectable() +export class RoleProvider implements NbRoleProvider { + + constructor(private authService: NbAuthService) { + } + + getRole(): Observable { + // ... + } +} + +``` + +Now, let's complete the `getRole` method to extract the role from the token: + +```typescript +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { map } from 'rxjs/operators/map'; + +import { NbAuthService, NbAuthJWTToken } from '@nebular/auth'; +import { NbRoleProvider } from '@nebular/security'; + +@Injectable() +export class RoleProvider implements NbRoleProvider { + + constructor(private authService: NbAuthService) { + } + + getRole(): Observable { + return this.authService.onTokenChange() + .pipe( + map((token: NbAuthJWTToken) => { + return token ? token.getPayload()['role'] : 'guest'; + }), + ); + } +} +``` + +So we subscribe to the `tokenChange` observable, which will produce a new token each time authentication change occurres. +Then we simply get a role from a token (for example simplicity, we assume that token payload always has a role value) or return default `guest` value. + +Don't worry if your setup does not use Nebular Auth. You can adjust this code to retrieve a user role from any service of your own. + + +And let's provide the service in the app module: + +```typescript +// ... + +import { RoleProvider } from './role.provider'; +import { NbSecurityModule, NbRoleProvider } from '@nebular/security'; + + +@NgModule({ + imports: [ + // ... + + NbSecurityModule.forRoot({ + // ... + }), + + ], + providers: [ + // ... + { provide: NbRoleProvider, useClass: RoleProvider }, // provide the class + ], +``` + + +## Usage + +Finally, we can move on to the part where we start putting security rules in our app. Let's assume that we have that `Post Comment` button, that should only be shown to authenticated users (with a role `user`). +So we need to hide the button for guests. + +Nebular Security provides us with a simple `*nbIsGranted` conditional directive, which under the hood works as `*ngIf`, showing or hiding a template block based on a user role: + +```typescript +@Component({ + // ... + template: ` + + `, +}) +export class CommentFormComponent { +// ... +``` +We just need to pass a `permission` and some `resource` in order to control the button visibility. + +For more advanced use cases, we can directly use the `NbAccessChecker` service. It provides you with `isGranted` method , which returns an `Observable` of the ACL check result. +We can adjust our example to utilize it. In your `comment-form.component.ts`, import the `NbAccessChecker` service. + +```typescript +import { Component } from '@angular/core'; +import { NbAccessChecker } from '@nebular/security'; + +@Component({ + // ... +}) +export class CommentFormComponent { + + constructor(public accessChecker: NbAccessChecker) { } +} +``` + +And let's add an `if` statement to the `Post Comment` button, so that it is only shown when permitted: + +```typescript +@Component({ + // ... + template: ` + + `, +}) +export class CommentFormComponent { +// ... +``` +We call `isGranted` method, which listens to the currently provided role and checks it permissions against specified in the ACL configuration. +Moreover, as it listens to the *role change*, it hides the button if authentication gets changed during the app usage. + +Same way we can call the `isGranted` method from any part of the app, including router guards and services, which gives us a transparent and flexibly configurable way to manage user access to various resources. diff --git a/docs/articles/security-install.md b/docs/articles/security-install.md new file mode 100644 index 0000000000..7b5a48fba4 --- /dev/null +++ b/docs/articles/security-install.md @@ -0,0 +1,44 @@ +
+
Note
+
+ If you use our [ngx-admin starter kit](#/docs/installation/based-on-starter-kit-ngxadmin-ngxadmin) then you already have the Security module in place. +
+
+ +## Installation steps + +1) First, let's install the module as it's distributed as an npm package. Security module doesn't have a dependency on Auth or Theme modules, but it is recommended to use them in conjunction. + + +```bash + +npm i @nebular/security +``` + +2) Import the module: + +```typescript + +import { NbSecurityModule } from '@nebular/security + +``` + +3) Now, let's register the module in the root module: + +```typescript + +@NgModule({ + imports: [ + // ... + + NbSecurityModule.forRoot(), + +``` + +Great, at this stage we have installed Nebular Security and ready to configure it. + +
+ +## Where to next + +- Roles & Permissions [Configuration & Usage](#/docs/security/acl-configuration--usage) diff --git a/docs/articles/security-intro.md b/docs/articles/security-intro.md new file mode 100644 index 0000000000..448a5a8a18 --- /dev/null +++ b/docs/articles/security-intro.md @@ -0,0 +1,27 @@ +Security is an important part of any adult web application. It is a common task to manage a user access to particular resources. +Unlike the `Nebular Auth`, which provides a way to `authenticate` a user, `Nebular Security` helps you to `authorize` a user to access some of the application resources. + +
+
Warning
+
+ Front-end ACL won't resolve all of the security issues and just provides a better user expiriece for your application. + It is essential to duplicate security rules on the back-end side. +
+
+
+ +## What's included + +- ACL roles/permissions/resources configuration +- `RoleProvider` - user role determination, authentication agnostic +- `NbAccessChecker` - a service that checks whether access is granted or not +- `*nbIsGranted` - conditional directive to manager your content visibility + + +- *Security Decorator* - decorator that manages access to a particular method, coming soon. + +
+ +## Where to next + +- Security Module [Installation](#/docs/security/installation) diff --git a/docs/google46533d2e7a851062.html b/docs/google46533d2e7a851062.html new file mode 100644 index 0000000000..b311a4fc35 --- /dev/null +++ b/docs/google46533d2e7a851062.html @@ -0,0 +1 @@ +google-site-verification: google46533d2e7a851062.html \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index c83e87d58b..846afb9cd0 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,6 +4,9 @@ Nebular is a set of essential modules for your next Angular application. + + + diff --git a/docs/output.json b/docs/output.json index 530b0ca150..08588f5a12 100644 --- a/docs/output.json +++ b/docs/output.json @@ -769,6 +769,299 @@ "shortDescription": "Nebular token service. Provides access to the stored token.\nBy default returns NbAuthSimpleToken instance,\nbut you can inject NbAuthJWTToken if you need additional methods for JWT token.", "styles": [] }, + { + "kind": "service", + "platform": null, + "examples": [], + "props": [], + "methods": [ + { + "examples": [], + "params": [ + { + "name": "settings", + "type": "NbAclOptions", + "required": null, + "shortDescription": "", + "description": "" + } + ], + "platform": null, + "name": "constructor", + "type": [ + "NbAclService" + ], + "isStatic": false, + "shortDescription": "", + "description": "" + }, + { + "examples": [], + "params": [ + { + "name": "role", + "type": "string", + "required": null + }, + { + "name": "permission", + "type": "string", + "required": null + }, + { + "name": "resource", + "type": "", + "required": null, + "description": "\n" + } + ], + "platform": null, + "name": "allow", + "type": [ + "void" + ], + "isStatic": false, + "shortDescription": "Allow a permission for specific resources to a role" + }, + { + "examples": [], + "params": [ + { + "name": "role", + "type": "string", + "required": null + }, + { + "name": "permission", + "type": "string", + "required": null + }, + { + "name": "resource", + "type": "string", + "required": null + } + ], + "platform": null, + "name": "can", + "type": [ + "any" + ], + "isStatic": false, + "shortDescription": "Check whether the role has a permission to a resource" + }, + { + "examples": [], + "params": [ + { + "name": "role", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + }, + { + "name": "permission", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + }, + { + "name": "resource", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + } + ], + "platform": null, + "name": "exactCan", + "type": [ + "any" + ], + "isStatic": false, + "shortDescription": "", + "description": "" + }, + { + "examples": [], + "params": [ + { + "name": "role", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + } + ], + "platform": null, + "name": "getRole", + "type": [ + "NbAclRole" + ], + "isStatic": false, + "shortDescription": "", + "description": "" + }, + { + "examples": [], + "params": [ + { + "name": "role", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + } + ], + "platform": null, + "name": "getRoleAbilities", + "type": [ + "{}" + ], + "isStatic": false, + "shortDescription": "", + "description": "" + }, + { + "examples": [], + "params": [ + { + "name": "role", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + } + ], + "platform": null, + "name": "getRoleParent", + "type": [ + "string" + ], + "isStatic": false, + "shortDescription": "", + "description": "" + }, + { + "examples": [], + "params": [ + { + "name": "role", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + }, + { + "name": "permission", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + } + ], + "platform": null, + "name": "getRoleResources", + "type": [ + "string[]" + ], + "isStatic": false, + "shortDescription": "", + "description": "" + }, + { + "examples": [], + "params": [ + { + "name": "role", + "type": "string", + "required": null + }, + { + "name": "parent", + "type": "string", + "required": null + }, + { + "name": "abilities", + "type": "", + "required": null, + "description": "\n" + } + ], + "platform": null, + "name": "register", + "type": [ + "void" + ], + "isStatic": false, + "shortDescription": "Register a new role with a list of abilities (permission/resources combinations)" + }, + { + "examples": [], + "params": [ + { + "name": "list", + "type": "\n", + "required": null, + "description": "\n" + } + ], + "platform": null, + "name": "setAccessControl", + "type": [ + "void" + ], + "isStatic": false, + "shortDescription": "Set/Reset ACL list" + }, + { + "examples": [], + "params": [ + { + "name": "resource", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + } + ], + "platform": null, + "name": "validateResource", + "type": [ + "void" + ], + "isStatic": false, + "shortDescription": "", + "description": "" + }, + { + "examples": [], + "params": [ + { + "name": "role", + "type": "string", + "required": null, + "shortDescription": "", + "description": "" + } + ], + "platform": null, + "name": "validateRole", + "type": [ + "void" + ], + "isStatic": false, + "shortDescription": "", + "description": "" + } + ], + "name": "NbAclService", + "shortDescription": "Common acl service.", + "styles": [] + }, { "kind": "component", "platform": null, @@ -2983,8 +3276,8 @@ }, { "shortDescription": "Example of fixed sidebar located on the left side, initially collapsed.", - "description": "", - "code": "\n\n Header\n \n Menu or another component here\n \n \n Footer components here\n \n\n" + "description": " Sidebar content, menu or another component here.", + "code": "\n\n Header\n\n Sidebar content, menu or another component here.\n\n \n Footer components here\n \n\n" } ], "props": [ @@ -8562,10 +8855,6 @@ { "theme": "default", "prop": "checkbox-border-color" - }, - { - "theme": "default", - "prop": "checkbox-checkmark" } ] }, @@ -8778,14 +9067,14 @@ }, "checkbox-checkmark": { "name": "checkbox-checkmark", - "value": "#dadfe6", - "parents": [ + "value": "rgba(0, 0, 0, 0)", + "parents": [], + "childs": [ { - "theme": "default", - "prop": "form-control-border-color" + "theme": "cosmic", + "prop": "checkbox-checkmark" } - ], - "childs": [] + ] }, "checkbox-checked-bg": { "name": "checkbox-checked-bg", @@ -9802,6 +10091,10 @@ { "theme": "cosmic", "prop": "form-control-placeholder-color" + }, + { + "theme": "cosmic", + "prop": "checkbox-border-color" } ] }, @@ -13422,16 +13715,7 @@ "prop": "separator" } ], - "childs": [ - { - "theme": "cosmic", - "prop": "checkbox-border-color" - }, - { - "theme": "cosmic", - "prop": "checkbox-checkmark" - } - ] + "childs": [] }, "form-control-selected-border-color": { "name": "form-control-selected-border-color", @@ -13647,30 +13931,22 @@ }, "checkbox-border-color": { "name": "checkbox-border-color", - "value": "#342e73", + "value": "#a1a1e5", "parents": [ { "theme": "cosmic", - "prop": "form-control-border-color" - }, - { - "theme": "cosmic", - "prop": "separator" + "prop": "color-fg" } ], "childs": [] }, "checkbox-checkmark": { "name": "checkbox-checkmark", - "value": "#342e73", + "value": "rgba(0, 0, 0, 0)", "parents": [ { - "theme": "cosmic", - "prop": "form-control-border-color" - }, - { - "theme": "cosmic", - "prop": "separator" + "theme": "default", + "prop": "checkbox-checkmark" } ], "childs": [] diff --git a/docs/structure.ts b/docs/structure.ts index fee54ddad3..0839ca0506 100644 --- a/docs/structure.ts +++ b/docs/structure.ts @@ -591,4 +591,43 @@ export const STRUCTURE = [ }, ], }, + { + type: 'section', + name: 'Security', + children: [ + { + type: 'page', + name: 'Introduction', + children: [ + { + type: 'block', + block: 'markdown', + source: 'security-intro.md', + }, + ], + }, + { + type: 'page', + name: 'Installation', + children: [ + { + type: 'block', + block: 'markdown', + source: 'security-install.md', + }, + ], + }, + { + type: 'page', + name: 'ACL Configuration & Usage', + children: [ + { + type: 'block', + block: 'markdown', + source: 'security-acl-configuration.md', + }, + ], + }, + ], + }, ]; diff --git a/docs/tsconfig.app.json b/docs/tsconfig.app.json index 1a4fd5f3a1..3849386dfa 100644 --- a/docs/tsconfig.app.json +++ b/docs/tsconfig.app.json @@ -7,7 +7,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "lib": [ - "es2016", + "es2017", "dom" ], "outDir": "../out-tsc/app", diff --git a/docs/tsconfig.spec.json b/docs/tsconfig.spec.json index 6c5160e12e..9cab448b25 100644 --- a/docs/tsconfig.spec.json +++ b/docs/tsconfig.spec.json @@ -6,7 +6,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "lib": [ - "es2016" + "es2017" ], "outDir": "../out-tsc/spec", "module": "commonjs", diff --git a/gulpfile.js b/gulpfile.js index 31a4a43744..27edc95c7d 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -78,6 +78,7 @@ const ROLLUP_GLOBALS = { // @nebular dependencies '@nebular/theme': 'nb.theme', '@nebular/auth': 'nb.auth', + '@nebular/security': 'nb.security', }; const ROLLUP_COMMON_CONFIG = { sourceMap: true, @@ -98,11 +99,17 @@ gulp.task('default', ['copy-sources']); gulp.task('inline-resources', copyResources); gulp.task('bundle:umd:theme', bundleUmdTheme); gulp.task('bundle:umd:auth', bundleUmdAuth); -gulp.task('bundle', ['bundle:umd:theme', 'bundle:umd:auth']); +gulp.task('bundle:umd:security', bundleUmdSecurity); +gulp.task('bundle', ['bundle:umd:theme', 'bundle:umd:auth', 'bundle:umd:security']); gulp.task('bump', bumpVersions); function bumpVersions() { - gulp.src(['./package.json', './src/framework/theme/package.json', './src/framework/auth/package.json'], {base: './'}) + gulp.src([ + './package.json', + './src/framework/theme/package.json', + './src/framework/auth/package.json', + './src/framework/security/package.json', + ], {base: './'}) .pipe(bump({ version: VERSION })) @@ -181,6 +188,19 @@ function bundleUmdAuth() { bundle(config); } +function bundleUmdSecurity() { + const config = { + src: `${LIB_DIR}/security/**/*.js`, + moduleName: 'nb.security', + entry: `${LIB_DIR}/security/index.js`, + format: 'umd', + output: 'security.umd.js', + dest: `${LIB_DIR}/security/bundles`, + }; + + bundle(config); +} + function bundle(config) { gulp.src(config.src) .pipe(rollup(Object.assign({}, ROLLUP_COMMON_CONFIG, { diff --git a/karma.conf.js b/karma.conf.js index 67eec3b117..5c4c88d921 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -12,6 +12,7 @@ module.exports = function (config) { require('karma-browserstack-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), + require('karma-spec-reporter'), require('@angular/cli/plugins/karma') ], client: { @@ -24,7 +25,7 @@ module.exports = function (config) { angularCli: { environment: 'dev' }, - reporters: ['progress', 'kjhtml'], + reporters: ['spec', 'kjhtml'], port: 9876, colors: true, logLevel: config.LOG_INFO, @@ -72,7 +73,6 @@ module.exports = function (config) { pollingTimeout: 20000, video: false, }, - singleRun: true }; if (process.env['TRAVIS']) { @@ -80,6 +80,8 @@ module.exports = function (config) { const [platform] = process.env.MODE.split('_'); const buildId = `TRAVIS #${process.env['TRAVIS_BUILD_NUMBER']} (${process.env['TRAVIS_BUILD_ID']})`; + configuration.singleRun = true; + if (platform === 'sauce') { const key = require('./scripts/ci/sauce/config'); @@ -101,6 +103,5 @@ module.exports = function (config) { } } - console.log(configuration); config.set(configuration); }; diff --git a/package-lock.json b/package-lock.json index 42898c5093..22ae52fb05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10678,6 +10678,15 @@ "source-map-support": "0.4.18" } }, + "karma-spec-reporter": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/karma-spec-reporter/-/karma-spec-reporter-0.0.32.tgz", + "integrity": "sha1-LpxyB+pyZ3EmAln4K+y1QyCeRAo=", + "dev": true, + "requires": { + "colors": "1.1.2" + } + }, "killable": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.0.tgz", diff --git a/package.json b/package.json index a9325d1bc2..eed1b6c637 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "version:changelog": "npm run conventional-changelog -- -p angular -i CHANGELOG.md -s", "release:prepare": "npm run build:package", "release:validate": "npm-run-all release:prepare build:wp ci:lint e2e:wp test:wp", - "release": "npm run release:validate && npm publish --access=public src/.lib/theme && npm publish --access=public src/.lib/auth" + "release": "npm run release:validate && npm publish --access=public src/.lib/theme && npm publish --access=public src/.lib/auth && npm publish --access=public src/.lib/security" }, "keywords": [ "angular", @@ -131,6 +131,7 @@ "karma-jasmine": "1.1.0", "karma-jasmine-html-reporter": "0.2.2", "karma-sauce-launcher": "1.2.0", + "karma-spec-reporter": "0.0.32", "npm-run-all": "4.0.1", "protractor": "5.2.2", "pump": "1.0.2", diff --git a/src/app/acl-test/acl-test.component.ts b/src/app/acl-test/acl-test.component.ts new file mode 100644 index 0000000000..5b4d95ab31 --- /dev/null +++ b/src/app/acl-test/acl-test.component.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Component } from '@angular/core'; +import { NbAccessChecker } from '@nebular/security'; + +@Component({ + selector: 'nb-actions-test', + template: ` + + + + Service usage + + + + + + Directive usage + + + + + + + `, +}) +export class NbAclTestComponent { + + constructor(public accessChecker: NbAccessChecker) { + } + +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 76aee7a3b5..83235dfe1e 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -8,6 +8,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; +import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import { NbActionsModule, @@ -31,6 +32,8 @@ import { NbAuthJWTInterceptor, } from '@nebular/auth'; +import { NbSecurityModule, NbRoleProvider } from '@nebular/security'; + import { NbAppComponent } from './app.component'; import { NbLayoutTestComponent } from './layout-test/layout-test.component'; import { NbLayoutHeaderTestComponent } from './layout-test/layout-header-test.component'; @@ -72,8 +75,9 @@ import { NbFormsTestComponent } from './forms-test/forms-test.component'; import { routes } from './app.routes'; import { NbCardTestComponent } from './card-test/card-test.component'; -import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; +import { NbAclTestComponent } from './acl-test/acl-test.component'; import { AuthGuard } from './auth-guard.service'; +import { RoleProvider } from './role.provider'; const NB_TEST_COMPONENTS = [ NbAppComponent, @@ -110,6 +114,7 @@ const NB_TEST_COMPONENTS = [ NbActionsTestComponent, NbFormsTestComponent, NbCheckboxTestComponent, + NbAclTestComponent, ]; @NgModule({ @@ -193,6 +198,22 @@ const NB_TEST_COMPONENTS = [ }, }, }), + NbSecurityModule.forRoot({ + accessControl: { + guest: { + view: ['news', 'comments'], + }, + user: { + parent: 'guest', + create: 'comments', + }, + moderator: { + parent: 'user', + create: 'news', + remove: '*', + }, + }, + }), ], declarations: [ ...NB_TEST_COMPONENTS, @@ -204,6 +225,7 @@ const NB_TEST_COMPONENTS = [ AuthGuard, { provide: NB_AUTH_TOKEN_WRAPPER_TOKEN, useClass: NbAuthJWTToken }, { provide: HTTP_INTERCEPTORS, useClass: NbAuthJWTInterceptor, multi: true }, + { provide: NbRoleProvider, useClass: RoleProvider }, ], bootstrap: [NbAppComponent], }) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dfe9b071fb..ef913be508 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -51,6 +51,7 @@ import { } from '@nebular/auth'; import { AuthGuard } from './auth-guard.service'; import { NbCheckboxTestComponent } from './checkbox-test/checkbox-test.component'; +import { NbAclTestComponent } from './acl-test/acl-test.component'; export const routes: Routes = [ { @@ -248,6 +249,10 @@ export const routes: Routes = [ path: 'forms', component: NbFormsTestComponent, }, + { + path: 'acl', + component: NbAclTestComponent, + }, { path: '**', component: NbCardTestComponent, diff --git a/src/app/role.provider.ts b/src/app/role.provider.ts new file mode 100644 index 0000000000..474673c9e6 --- /dev/null +++ b/src/app/role.provider.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { map } from 'rxjs/operators/map'; + +import { NbAuthService, NbAuthJWTToken } from '@nebular/auth'; +import { NbRoleProvider } from '@nebular/security'; + +@Injectable() +export class RoleProvider implements NbRoleProvider { + + constructor(private authService: NbAuthService) { + } + + getRole(): Observable { + return this.authService.onTokenChange() + .pipe( + map((token: NbAuthJWTToken) => { + return token && token.getPayload() ? token.getPayload()['role'] : 'guest'; + }), + ); + } +} diff --git a/src/backend/app.js b/src/backend/app.js index 05b49a22d4..e382bcaab3 100644 --- a/src/backend/app.js +++ b/src/backend/app.js @@ -46,6 +46,7 @@ app.post('/api/auth/login', function (req, res) { var payload = { id: user.id, email: user.email, + role: 'user', }; var token = jwt.encode(payload, cfg.jwtSecret); return res.json({ @@ -73,6 +74,7 @@ app.post('/api/auth/register', function (req, res) { var payload = { id: user.id, email: user.email, + role: 'user', }; var token = jwt.encode(payload, cfg.jwtSecret); return res.json({ diff --git a/src/framework/security/LICENSE.txt b/src/framework/security/LICENSE.txt new file mode 100644 index 0000000000..031c83cb11 --- /dev/null +++ b/src/framework/security/LICENSE.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2017 Akveo. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/src/framework/security/README.md b/src/framework/security/README.md new file mode 100644 index 0000000000..11ece442e4 --- /dev/null +++ b/src/framework/security/README.md @@ -0,0 +1 @@ +### @nebular/security module, more details https://akveo.github.io/nebular/ diff --git a/src/framework/security/directives/is-granted.directive.ts b/src/framework/security/directives/is-granted.directive.ts new file mode 100644 index 0000000000..505f6f1eca --- /dev/null +++ b/src/framework/security/directives/is-granted.directive.ts @@ -0,0 +1,37 @@ +import { Directive, Input, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core'; +import { takeWhile } from 'rxjs/operators/takeWhile'; + +import { NbAccessChecker } from '../services/access-checker.service'; + +@Directive({ selector: '[nbIsGranted]'}) +export class NbIsGrantedDirective implements OnDestroy { + + private alive = true; + private hasView = false; + + constructor(private templateRef: TemplateRef, + private viewContainer: ViewContainerRef, + private accessChecker: NbAccessChecker) { + } + + @Input() set nbIsGranted([permission, resource]: [string, string]) { + + this.accessChecker.isGranted(permission, resource) + .pipe( + takeWhile(() => this.alive), + ) + .subscribe((can: boolean) => { + if (can && !this.hasView) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.hasView = true; + } else if (!can && this.hasView) { + this.viewContainer.clear(); + this.hasView = false; + } + }); + } + + ngOnDestroy() { + this.alive = false; + } +} diff --git a/src/framework/security/index.ts b/src/framework/security/index.ts new file mode 100644 index 0000000000..50b31bdf2a --- /dev/null +++ b/src/framework/security/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ +export * from './security.options'; +export * from './security.module'; +export * from './services/acl.service'; +export * from './services/access-checker.service'; +export * from './services/role.provider'; diff --git a/src/framework/security/package.json b/src/framework/security/package.json new file mode 100644 index 0000000000..ff34e7e84f --- /dev/null +++ b/src/framework/security/package.json @@ -0,0 +1,37 @@ +{ + "name": "@nebular/security", + "version": "2.0.0-rc.4", + "description": "@nebular/security", + "main": "./bundles/security.umd.js", + "module": "./index.js", + "typings": "./index.d.ts", + "author": "akveo", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/akveo/nebular.git" + }, + "bugs": { + "url": "https://github.com/akveo/nebular/issues" + }, + "homepage": "https://github.com/akveo/nebular#readme", + "keywords": [ + "angular", + "typescript", + "ng2-admin", + "ngx-admin", + "theme", + "auth", + "acl", + "security", + "login", + "register", + "nebular" + ], + "peerDependencies": { + "@angular/common": "^5.1.0", + "@angular/core": "^5.1.0", + "@angular/router": "^5.1.0", + "rxjs": "~5.5.5" + } +} diff --git a/src/framework/security/security.module.ts b/src/framework/security/security.module.ts new file mode 100644 index 0000000000..548bacfde8 --- /dev/null +++ b/src/framework/security/security.module.ts @@ -0,0 +1,34 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { NB_SECURITY_OPTIONS_TOKEN, NbAclOptions } from './security.options'; +import { NbAclService } from './services/acl.service'; +import { NbAccessChecker } from './services/access-checker.service'; +import { NbIsGrantedDirective } from './directives/is-granted.directive'; + +@NgModule({ + imports: [ + CommonModule, + ], + declarations: [ + NbIsGrantedDirective, + ], + exports: [ + NbIsGrantedDirective, + ], +}) +export class NbSecurityModule { + static forRoot(nbSecurityOptions?: NbAclOptions): ModuleWithProviders { + return { + ngModule: NbSecurityModule, + providers: [ + { provide: NB_SECURITY_OPTIONS_TOKEN, useValue: nbSecurityOptions }, + NbAclService, + NbAccessChecker, + ], + exports: [ + NbIsGrantedDirective, + ], + }; + } +} diff --git a/src/framework/security/security.options.ts b/src/framework/security/security.options.ts new file mode 100644 index 0000000000..d62cfe090d --- /dev/null +++ b/src/framework/security/security.options.ts @@ -0,0 +1,16 @@ +import { InjectionToken } from '@angular/core'; + +export interface NbAclRole { + parent?: string, + [permission: string]: string|string[], +} + +export interface NbAccessControl { + [role: string]: NbAclRole, +} + +export interface NbAclOptions { + accessControl?: NbAccessControl, +} + +export const NB_SECURITY_OPTIONS_TOKEN = new InjectionToken('Nebular Security Options'); diff --git a/src/framework/security/services/access-checker.service.ts b/src/framework/security/services/access-checker.service.ts new file mode 100644 index 0000000000..267cb1353b --- /dev/null +++ b/src/framework/security/services/access-checker.service.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ +import { Injectable } from '@angular/core'; +import { NbRoleProvider } from './role.provider'; +import { NbAclService } from './acl.service'; +import { Observable } from 'rxjs/Observable'; +import { map } from 'rxjs/operators/map'; + +@Injectable() +export class NbAccessChecker { + + constructor(protected roleProvider: NbRoleProvider, protected acl: NbAclService) { + } + + isGranted(permission: string, resource: string): Observable { + return this.roleProvider.getRole() + .pipe( + map((role: string) => { + return this.acl.can(role, permission, resource); + }), + ); + } +} diff --git a/src/framework/security/services/access-checker.spec.ts b/src/framework/security/services/access-checker.spec.ts new file mode 100644 index 0000000000..bc477caf9e --- /dev/null +++ b/src/framework/security/services/access-checker.spec.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ +import { async, TestBed, inject } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs/observable/of'; + +import { NbRoleProvider } from './role.provider'; +import { NbAclService } from './acl.service'; +import { NbAccessChecker } from './access-checker.service'; + +let accessChecker: NbAccessChecker; + +function setupAcl(can) { + beforeEach(() => { + // Configure testbed to prepare services + TestBed.configureTestingModule({ + providers: [ + { + provide: NbRoleProvider, + useValue: { + getRole: () => { + return observableOf('admin'); + }, + }, + }, + { + provide: NbAclService, + useValue: { + can: (role, permission, resource) => { + return can; // this is a simple mocked ACL implementation + }, + }, + }, + NbAccessChecker, + ], + }); + }); + + // Single async inject to save references; which are used in all tests below + beforeEach(async(inject( + [NbAccessChecker], + (_accessChecker) => { + accessChecker = _accessChecker + }, + ))); +} + +describe('authorization checker', () => { + + describe('acl returns true', () => { + setupAcl(true); + + it(`checks against provided role`, (done) => { + accessChecker.isGranted('delete', 'users').subscribe((result: boolean) => { + expect(result).toBe(true); + done(); + }) + }); + }); + + describe('acl returns false', () => { + setupAcl(false); + + it(`checks against provided role`, (done) => { + accessChecker.isGranted('delete', 'users').subscribe((result: boolean) => { + expect(result).toBe(false); + done(); + }) + }); + }); +}); diff --git a/src/framework/security/services/acl.service.ts b/src/framework/security/services/acl.service.ts new file mode 100644 index 0000000000..053bbaaae0 --- /dev/null +++ b/src/framework/security/services/acl.service.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ +import { Inject, Injectable, Optional } from '@angular/core'; +import { NB_SECURITY_OPTIONS_TOKEN, NbAclOptions, NbAclRole, NbAccessControl } from '../security.options'; + +const shallowObjectClone = (o) => Object.assign({}, o); +const shallowArrayClone = (a) => Object.assign([], a); +const popParent = (abilities) => { + const parent = abilities['parent']; + delete abilities['parent']; + return parent; +}; + +/** + * Common acl service. + */ +@Injectable() +export class NbAclService { + + private static readonly ANY_RESOURCE = '*'; + + private state: NbAccessControl = {}; + + constructor(@Optional() @Inject(NB_SECURITY_OPTIONS_TOKEN) protected settings: NbAclOptions = {}) { + + if (settings.accessControl) { + this.setAccessControl(settings.accessControl); + } + } + + /** + * Set/Reset ACL list + * @param {NbAccessControl} list + */ + setAccessControl(list: NbAccessControl) { + for (const [role, value] of Object.entries(list)) { + const abilities = shallowObjectClone(value); + const parent = popParent(abilities); + this.register(role, parent, abilities); + } + } + + /** + * Register a new role with a list of abilities (permission/resources combinations) + * @param {string} role + * @param {string} parent + * @param {[permission: string]: string|string[]} abilities + */ + register(role: string, parent: string = null, abilities: {[permission: string]: string|string[]} = {}) { + + this.validateRole(role); + + this.state[role] = { + parent: parent, + }; + + for (const [permission, value] of Object.entries(abilities)) { + const resources = typeof value === 'string' ? [value] : value; + this.allow(role, permission, shallowArrayClone(resources)); + } + } + + /** + * Allow a permission for specific resources to a role + * @param {string} role + * @param {string} permission + * @param {string | string[]} resource + */ + allow(role: string, permission: string, resource: string|string[]) { + + this.validateRole(role); + + if (!this.getRole(role)) { + this.register(role, null, {}); + } + + resource = typeof resource === 'string' ? [resource] : resource; + + let resources = shallowArrayClone(this.getRoleResources(role, permission)); + resources = resources.concat(resource); + + this.state[role][permission] = resources + .filter((item, pos) => resources.indexOf(item) === pos); + } + + /** + * Check whether the role has a permission to a resource + * @param {string} role + * @param {string} permission + * @param {string} resource + * @returns {boolean} + */ + can(role: string, permission: string, resource: string) { + this.validateResource(resource); + + const parentRole = this.getRoleParent(role); + const parentCan = parentRole && this.can(this.getRoleParent(role), permission, resource); + return parentCan || this.exactCan(role, permission, resource); + } + + private getRole(role: string): NbAclRole { + return this.state[role]; + } + + private validateRole(role: string) { + if (!role) { + throw new Error('NbAclService: role name cannot be empty'); + } + } + + private validateResource(resource: string) { + if (!resource || [NbAclService.ANY_RESOURCE].includes(resource)) { + throw new Error(`NbAclService: cannot use empty or bulk '*' resource placeholder with 'can' method`); + } + } + + private exactCan(role: string, permission: string, resource: string) { + const resources = this.getRoleResources(role, permission); + return resources.includes(resource) || resources.includes(NbAclService.ANY_RESOURCE); + } + + private getRoleResources(role: string, permission: string): string[] { + return this.getRoleAbilities(role)[permission] || []; + } + + private getRoleAbilities(role: string): {[permission: string]: string[]} { + const abilities = shallowObjectClone(this.state[role] || {}); + popParent(shallowObjectClone(this.state[role] || {})); + return abilities; + } + + private getRoleParent(role: string): string { + return this.state[role] ? this.state[role]['parent'] : null; + } +} diff --git a/src/framework/security/services/acl.spec.ts b/src/framework/security/services/acl.spec.ts new file mode 100644 index 0000000000..e75b4fff9b --- /dev/null +++ b/src/framework/security/services/acl.spec.ts @@ -0,0 +1,509 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { TestBed, inject, async } from '@angular/core/testing'; +import { NbAclService } from './acl.service'; +import { NB_SECURITY_OPTIONS_TOKEN } from '../security.options'; +import { deepExtend } from '../../auth/helpers'; // TODO: common module? + + +let aclService: NbAclService; + +function sharedAclTests (defaultSettings) { + + it(`should store different object`, () => { + expect(defaultSettings.accessControl).not.toBe(aclService['state']); + }); + + it(`forbidden for any role - permission - resource`, () => { + expect(aclService.can('user', 'view', 'content')).toBe(false); + }); + + it(`can register a role`, () => { + const modifiedRoles = deepExtend({}, defaultSettings.accessControl); + modifiedRoles.guest = { + parent: null, + }; + + aclService.register('guest', null, {}); + expect(aclService['state']).toEqual(modifiedRoles); + }); + + it(`can register a role with default values`, () => { + const modifiedRoles = deepExtend({}, defaultSettings.accessControl); + modifiedRoles.guest = { + parent: null, + }; + + aclService.register('guest'); + expect(aclService['state']).toEqual(modifiedRoles); + }); + + it(`can register a role with custom values`, () => { + const modifiedRoles = deepExtend({}, defaultSettings.accessControl); + modifiedRoles.guest = { + parent: null, + view: ['users'], + }; + + aclService.register('guest', null, { view: ['users'] }); + expect(aclService['state']).toEqual(modifiedRoles); + }); + + it(`will rewrite newly registered role`, () => { + let modifiedRoles; + + modifiedRoles = deepExtend({}, defaultSettings.accessControl); + modifiedRoles.guest = { + parent: null, + view: ['users'], + }; + aclService.register('guest', null, { view: ['users'] }); + expect(aclService['state']).toEqual(modifiedRoles); + + modifiedRoles = deepExtend({}, defaultSettings.accessControl); + modifiedRoles.guest = { + parent: null, + edit: ['users'], + }; + aclService.register('guest', null, { edit: ['users'] }); + expect(aclService['state']).toEqual(modifiedRoles); + }); + + it(`can register multiple roles`, () => { + let modifiedRoles; + + modifiedRoles = deepExtend({}, defaultSettings.accessControl); + modifiedRoles.guest = { + parent: null, + view: ['users'], + }; + + aclService.register('guest', null, { view: ['users'] }); + expect(aclService['state']).toEqual(modifiedRoles); + + modifiedRoles = deepExtend({}, defaultSettings.accessControl); + modifiedRoles.guest = { + parent: null, + view: ['users'], + }; + modifiedRoles.user = { + parent: 'guest', + edit: ['users'], + }; + aclService.register('user', 'guest', { edit: ['users'] }); + expect(aclService['state']).toEqual(modifiedRoles); + }); + + it(`cannot register a role with an empty name`, () => { + expect(() => aclService.register('', null, { view: ['users'] })) + .toThrow(new Error('NbAclService: role name cannot be empty')); + }); + + it(`cannot check bulk resource`, () => { + expect(() => aclService.can('guest', 'edit', '*')) + .toThrow(new Error(`NbAclService: cannot use empty or bulk '*' resource placeholder with 'can' method`)); + }); + + it(`can handle permissions`, () => { + aclService.register('guest', null, { view: ['users'] }); + expect(aclService.can('guest', 'view', 'users')).toBe(true); + expect(aclService.can('guest', 'edit', 'users')).toBe(false); + expect(aclService.can('user', 'view', 'users')).toBe(false); + }); + + it(`can handle permissions with multiple resorces`, () => { + aclService.register('guest', null, { view: ['users', 'posts'] }); + expect(aclService.can('guest', 'view', 'users')).toBe(true); + expect(aclService.can('guest', 'edit', 'users')).toBe(false); + expect(aclService.can('user', 'view', 'users')).toBe(false); + + expect(aclService.can('guest', 'view', 'posts')).toBe(true); + expect(aclService.can('guest', 'edit', 'posts')).toBe(false); + expect(aclService.can('user', 'view', 'posts')).toBe(false); + }); + + it(`can inherit permissions - 2 levels`, () => { + aclService.register('guest', null, { view: ['users'] }); + aclService.register('user', 'guest', { edit: ['users'] }); + + expect(aclService.can('guest', 'view', 'users')).toBe(true); + expect(aclService.can('guest', 'edit', 'users')).toBe(false); + expect(aclService.can('user', 'view', 'users')).toBe(true); + expect(aclService.can('user', 'edit', 'users')).toBe(true); + + expect(aclService.can('custom', 'view', 'users')).toBe(false); + expect(aclService.can('custom', 'edit', 'users')).toBe(false); + }); + + it(`can inherit permissions - 3 levels`, () => { + aclService.register('guest', null, { view: ['users'] }); + aclService.register('user', 'guest', { edit: ['users'] }); + aclService.register('admin', 'user', { remove: ['users'] }); + aclService.register('guest-who-can-delete', 'guest', { remove: ['users'] }); + + expect(aclService.can('guest', 'view', 'users')).toBe(true); + expect(aclService.can('guest', 'edit', 'users')).toBe(false); + expect(aclService.can('guest', 'remove', 'users')).toBe(false); + + expect(aclService.can('user', 'view', 'users')).toBe(true); + expect(aclService.can('user', 'edit', 'users')).toBe(true); + expect(aclService.can('user', 'remove', 'users')).toBe(false); + + expect(aclService.can('admin', 'view', 'users')).toBe(true); + expect(aclService.can('admin', 'edit', 'users')).toBe(true); + expect(aclService.can('admin', 'remove', 'users')).toBe(true); + + expect(aclService.can('guest-who-can-delete', 'view', 'users')).toBe(true); + expect(aclService.can('guest-who-can-delete', 'edit', 'users')).toBe(false); + expect(aclService.can('guest-who-can-delete', 'remove', 'users')).toBe(true); + + expect(aclService.can('custom', 'view', 'users')).toBe(false); + expect(aclService.can('custom', 'edit', 'users')).toBe(false); + expect(aclService.can('custom', 'remove', 'users')).toBe(false); + }); + + it(`can inherit permissions - 3 levels, more resources`, () => { + aclService.register('guest', null, { view: ['users', 'posts'] }); + aclService.register('user', 'guest', { view: ['dashboard'], edit: ['users', 'posts', 'dashboard'] }); + aclService.register('admin', 'user', { remove: ['users', 'posts'] }); + + expect(aclService.can('guest', 'view', 'users')).toBe(true); + expect(aclService.can('guest', 'edit', 'users')).toBe(false); + expect(aclService.can('guest', 'remove', 'users')).toBe(false); + + expect(aclService.can('guest', 'view', 'posts')).toBe(true); + expect(aclService.can('guest', 'edit', 'posts')).toBe(false); + expect(aclService.can('guest', 'remove', 'posts')).toBe(false); + + expect(aclService.can('guest', 'view', 'dashboard')).toBe(false); + expect(aclService.can('guest', 'edit', 'dashboard')).toBe(false); + expect(aclService.can('guest', 'remove', 'dashboard')).toBe(false); + + expect(aclService.can('user', 'view', 'users')).toBe(true); + expect(aclService.can('user', 'edit', 'users')).toBe(true); + expect(aclService.can('user', 'remove', 'users')).toBe(false); + + expect(aclService.can('user', 'view', 'posts')).toBe(true); + expect(aclService.can('user', 'edit', 'posts')).toBe(true); + expect(aclService.can('user', 'remove', 'posts')).toBe(false); + + expect(aclService.can('user', 'view', 'dashboard')).toBe(true); + expect(aclService.can('user', 'edit', 'dashboard')).toBe(true); + expect(aclService.can('user', 'remove', 'dashboard')).toBe(false); + + expect(aclService.can('admin', 'view', 'users')).toBe(true); + expect(aclService.can('admin', 'edit', 'users')).toBe(true); + expect(aclService.can('admin', 'remove', 'users')).toBe(true); + + expect(aclService.can('admin', 'view', 'posts')).toBe(true); + expect(aclService.can('admin', 'edit', 'posts')).toBe(true); + expect(aclService.can('admin', 'remove', 'posts')).toBe(true); + + expect(aclService.can('admin', 'view', 'dashboard')).toBe(true); + expect(aclService.can('admin', 'edit', 'dashboard')).toBe(true); + expect(aclService.can('admin', 'remove', 'dashboard')).toBe(false); + }); + + it(`cannot allow empty role for 'allow' method`, () => { + expect(() => aclService.allow('', null, 'users' )) + .toThrow(new Error('NbAclService: role name cannot be empty')); + }); + + it(`should allow new role`, () => { + aclService.allow('guest', 'view', 'users'); + + expect(aclService.can('guest', 'view', 'users')).toBe(true); + expect(aclService.can('guest', 'edit', 'users')).toBe(false); + }); + + it(`should allow new permissions on role`, () => { + aclService.register('guest', null, { view: ['users'] }); + aclService.allow('guest', 'edit', 'users'); + + expect(aclService.can('guest', 'view', 'users')).toBe(true); + expect(aclService.can('guest', 'edit', 'users')).toBe(true); + }); + + + it(`should allow new permissions on parent role`, () => { + aclService.register('guest', null, { view: ['users'] }); + aclService.register('user', 'guest', { remove: ['users'] }); + + expect(aclService.can('guest', 'view', 'users')).toBe(true); + expect(aclService.can('guest', 'edit', 'users')).toBe(false); + expect(aclService.can('guest', 'remove', 'users')).toBe(false); + + expect(aclService.can('user', 'view', 'users')).toBe(true); + expect(aclService.can('user', 'edit', 'users')).toBe(false); + expect(aclService.can('user', 'remove', 'users')).toBe(true); + + aclService.allow('guest', 'edit', 'users'); + + expect(aclService.can('guest', 'view', 'users')).toBe(true); + expect(aclService.can('guest', 'edit', 'users')).toBe(true); + expect(aclService.can('guest', 'remove', 'users')).toBe(false); + + expect(aclService.can('user', 'view', 'users')).toBe(true); + expect(aclService.can('user', 'edit', 'users')).toBe(true); + expect(aclService.can('user', 'remove', 'users')).toBe(true); + }); + + it(`cannot be changed by reference through module options`, () => { + + const settings = { + accessControl: { + guest: { + parent: null, + count: ['users'], + }, + super_user: { + parent: 'admin', + manage: ['all'], + }, + }, + }; + + aclService.setAccessControl(settings.accessControl); + + expect(aclService.can('admin', 'manage', 'all')).toBe(false); + expect(aclService.can('admin', 'edit', 'users')).toBe(false); + expect(aclService.can('super_user', 'view', 'users')).toBe(false); + expect(aclService.can('super_user', 'edit', 'users')).toBe(false); + + settings.accessControl['admin'] = { + parent: 'guest', + manage: ['all'], + view: ['users'], + }; + + settings.accessControl['admin'] = { + parent: 'guest', + manage: ['all'], + edit: ['users'], + }; + + expect(aclService.can('admin', 'manage', 'all')).toBe(false); + expect(aclService.can('admin', 'edit', 'users')).toBe(false); + expect(aclService.can('super_user', 'view', 'users')).toBe(false); + expect(aclService.can('super_user', 'edit', 'users')).toBe(false); + }); + + it(`cannot be changed by reference through allow`, () => { + + const resources = ['dashboard']; + + aclService.allow('super_user', 'view', resources); + expect(aclService.can('super_user', 'view', 'dashboard')).toBe(true); + + resources.push('statistics'); + expect(aclService.can('super_user', 'view', 'statistics')).toBe(false); + }); + + it(`cannot be changed by reference through register`, () => { + + const abilities = { view: ['users'] }; + + aclService.register('moderator', null, abilities); + + expect(aclService.can('moderator', 'view', 'users')).toBe(true); + + abilities['view'] = ['users', 'dashboard']; + abilities['edit'] = ['users']; + expect(aclService.can('moderator', 'view', 'users')).toBe(true); + expect(aclService.can('moderator', 'view', 'dashboard')).toBe(false); + expect(aclService.can('moderator', 'edit', 'users')).toBe(false); + }); + + it(`can accept roles as string`, () => { + + aclService.register('role', null, { view: 'all', edit: '*' }); + + expect(aclService.can('role', 'view', 'all')).toBe(true); + expect(aclService.can('role', 'edit', 'all')).toBe(true); + expect(aclService.can('role', 'edit', 'any')).toBe(true); + expect(aclService.can('role', 'delete', 'any')).toBe(false); + }); +} + + +describe('acl-service', () => { + + describe('with default settings', () => { + + beforeEach(() => { + // Configure testbed to prepare services + TestBed.configureTestingModule({ + providers: [ + { provide: NB_SECURITY_OPTIONS_TOKEN, useValue: {} }, + NbAclService, + ], + }); + }); + + // Single async inject to save references; which are used in all tests below + beforeEach(async(inject( + [NbAclService], + (_aclService) => { + aclService = _aclService + }, + ))); + + it(`has empty default state`, () => { + expect(aclService['state']).toEqual({}); + }); + + sharedAclTests({ accessControl: {} }); + }); + + describe('with some roles settings', () => { + + const defaultSettings = { + accessControl: { + guest: { + parent: null, + count: ['users'], + }, + super_user: { + parent: 'admin', + manage: ['all'], + }, + }, + }; + + beforeEach(() => { + // Configure testbed to prepare services + TestBed.configureTestingModule({ + providers: [ + { provide: NB_SECURITY_OPTIONS_TOKEN, useValue: defaultSettings }, // useValue will clone + NbAclService, + ], + }); + }); + + // Single async inject to save references; which are used in all tests below + beforeEach(async(inject( + [NbAclService], + (_aclService) => { + aclService = _aclService + }, + ))); + + it(`has predefined default state`, () => { + expect(aclService['state']).toEqual(defaultSettings.accessControl); + }); + + sharedAclTests(defaultSettings); + + it(`has predefined rules`, () => { + expect(aclService.can('super_user', 'manage', 'all')).toBe(true); + expect(aclService.can('admin', 'manage', 'all')).toBe(false); + expect(aclService.can('super_user', 'view', 'users')).toBe(false); + }); + }); + + describe('with some roles settings (not cloned)', () => { + + const defaultSettings = { + accessControl: { + guest: { + parent: null, + count: ['users'], + }, + super_user: { + parent: 'admin', + manage: ['all'], + }, + }, + }; + + beforeEach(() => { + // Configure testbed to prepare services + TestBed.configureTestingModule({ + providers: [ + { provide: NB_SECURITY_OPTIONS_TOKEN, useFactory: () => defaultSettings }, + NbAclService, + ], + }); + }); + + // Single async inject to save references; which are used in all tests below + beforeEach(async(inject( + [NbAclService], + (_aclService) => { + aclService = _aclService + }, + ))); + + it(`has predefined default state`, () => { + expect(aclService['state']).toEqual(defaultSettings.accessControl); + }); + + sharedAclTests(defaultSettings); + + it(`has predefined rules`, () => { + expect(aclService.can('super_user', 'manage', 'all')).toBe(true); + expect(aclService.can('admin', 'manage', 'all')).toBe(false); + expect(aclService.can('super_user', 'view', 'users')).toBe(false); + }); + }); + + describe('with bulk resources', () => { + + const defaultSettings = { + accessControl: { + guest: { + parent: null, + count: ['users'], + }, + moderator: { + parent: 'guest', + view: ['*'], + count: ['*'], + }, + super_user: { + parent: 'admin', + manage: ['all'], + }, + }, + }; + + beforeEach(() => { + // Configure testbed to prepare services + TestBed.configureTestingModule({ + providers: [ + { provide: NB_SECURITY_OPTIONS_TOKEN, useFactory: () => defaultSettings }, // will provide a reference + NbAclService, + ], + }); + }); + + // Single async inject to save references; which are used in all tests below + beforeEach(async(inject( + [NbAclService], + (_aclService) => { + aclService = _aclService + }, + ))); + + it(`has predefined default state`, () => { + expect(aclService['state']).toEqual(defaultSettings.accessControl); + }); + + sharedAclTests(defaultSettings); + + it(`can access anything with '*'`, () => { + expect(aclService.can('moderator', 'view', 'all')).toBe(true); + expect(aclService.can('moderator', 'view', 'users')).toBe(true); + expect(aclService.can('moderator', 'view', 'dashboard')).toBe(true); + expect(aclService.can('moderator', 'count', 'all')).toBe(true); + expect(aclService.can('moderator', 'count', 'any')).toBe(true); + expect(aclService.can('moderator', 'count', 'some')).toBe(true); + + expect(aclService.can('moderator', 'delete', 'all')).toBe(false); + }); + }); +}); diff --git a/src/framework/security/services/role.provider.ts b/src/framework/security/services/role.provider.ts new file mode 100644 index 0000000000..0f008db7a9 --- /dev/null +++ b/src/framework/security/services/role.provider.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ +import { Observable } from 'rxjs/Observable'; + +export abstract class NbRoleProvider { + abstract getRole(): Observable; +} diff --git a/tsconfig.publish.json b/tsconfig.publish.json index 5bfc4a6d9f..ccb2d2b409 100644 --- a/tsconfig.publish.json +++ b/tsconfig.publish.json @@ -4,7 +4,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "lib": [ - "es7", + "es2017", "dom" ], "module": "es2015",