├── .babelrc ├── .babelrc-test ├── .coveralls.yml ├── .github ├── FUNDING.yml └── workflows │ └── node.js.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example.png ├── jest.config.json ├── package-lock.json ├── package.json ├── runkit.js ├── src ├── __tests__ │ └── index.ts └── index.ts ├── tsconfig.json └── types └── index.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": "ie >= 11" 7 | } 8 | }] 9 | ], 10 | "env": { 11 | "test": { 12 | "presets": [["@babel/preset-env"]] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.babelrc-test: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "chrome": "60" 6 | } 7 | }] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: xuEfbfsdnikidNTCFoxJbCiDUmMkUCc45 2 | service_name: travis-ci 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mblarsen] 4 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [16.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - run: npm ci 26 | - run: npm run build --if-present 27 | - run: npm test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/docs.md 3 | tags 4 | dist/ 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## next 4 | 5 | - added lock file 6 | - update dev dependencies 7 | 8 | ## 1.0.2 9 | 10 | - allow the user value to be typed 11 | 12 | ## 1.0.1 13 | 14 | - reexport types 15 | 16 | ## 0.9.1 17 | 18 | - internal changes 19 | 20 | ## 0.9.0 21 | 22 | - breaking: The concept of `Subject` has been renamed `VerbObject`, because that is 23 | actually what it is. The subject is always the user. If you are using the 24 | TypeScript types `Subject*` you need to use `VerbObject` instead 25 | - build: move types into index.ts to avoid special handling afterwards 26 | 27 | ## 0.8.0 28 | 29 | - refactor: rewrite in TypeScript 30 | - breaking: GlobalRule is available through Acl.GlobalRule. Before it was 31 | exported separately. This only affects you if you explicitly use `GlobalRule`. 32 | If you've been using non-strict mode (default) you'll have had no need for that. 33 | 34 | ## [0.7.5](https://github.com/mblarsen/browser-acl/compare/v0.7.4...v0.7.5) (2019-11-01) 35 | 36 | ## [0.7.4](https://github.com/mblarsen/browser-acl/compare/v0.7.3...v0.7.4) (2019-10-30) 37 | 38 | ## [0.7.3](https://github.com/mblarsen/browser-acl/compare/v0.7.2...v0.7.3) (2019-08-13) 39 | 40 | ## [0.7.2](https://github.com/mblarsen/browser-acl/compare/v0.7.1...v0.7.2) (2019-08-12) 41 | 42 | ### Bug Fixes 43 | 44 | - Change test values to avoid false result ([1162b86](https://github.com/mblarsen/browser-acl/commit/1162b86)) 45 | 46 | ## [0.7.1](https://github.com/mblarsen/browser-acl/compare/v0.7.0...v0.7.1) (2018-08-17) 47 | 48 | ### Features 49 | 50 | - Add beforeAll feature to policies ([1c29d90](https://github.com/mblarsen/browser-acl/commit/1c29d90)) 51 | 52 | # [0.7.0](https://github.com/mblarsen/browser-acl/compare/v0.5.0...v0.7.0) (2018-05-04) 53 | 54 | ### Bug Fixes 55 | 56 | - Deal with absence truth-test function ([5dda25f](https://github.com/mblarsen/browser-acl/commit/5dda25f)), closes [mblarsen/vue-browser-acl#8](https://github.com/mblarsen/vue-browser-acl/issues/8) 57 | 58 | # [0.5.0](https://github.com/mblarsen/browser-acl/compare/v0.4.0...v0.5.0) (2018-03-31) 59 | 60 | ### Bug Fixes 61 | 62 | - **docs:** Fix jsdoc for rule function, third param can be function ([034e406](https://github.com/mblarsen/browser-acl/commit/034e406)) 63 | 64 | ### Features 65 | 66 | - Add global rules ([442ad55](https://github.com/mblarsen/browser-acl/commit/442ad55)), closes [#1](https://github.com/mblarsen/browser-acl/issues/1) 67 | 68 | # [0.4.0](https://github.com/mblarsen/browser-acl/compare/v0.3.7...v0.4.0) (2018-03-17) 69 | 70 | ### Features 71 | 72 | - Add reset, removeRules, removePolicy, and removeAll ([d93752e](https://github.com/mblarsen/browser-acl/commit/d93752e)) 73 | 74 | ## [0.3.7](https://github.com/mblarsen/browser-acl/compare/v0.3.6...v0.3.7) (2018-02-18) 75 | 76 | ## [0.3.6](https://github.com/mblarsen/browser-acl/compare/v0.3.5...v0.3.6) (2017-11-06) 77 | 78 | ## [0.3.5](https://github.com/mblarsen/browser-acl/compare/v0.3.4...v0.3.5) (2017-10-22) 79 | 80 | ## [0.3.4](https://github.com/mblarsen/browser-acl/compare/v0.3.3...v0.3.4) (2017-10-21) 81 | 82 | ### Bug Fixes 83 | 84 | - Default subjectMapper handles classes and instances ([8b74243](https://github.com/mblarsen/browser-acl/commit/8b74243)) 85 | 86 | ## [0.3.3](https://github.com/mblarsen/browser-acl/compare/v0.3.2...v0.3.3) (2017-10-20) 87 | 88 | ### Bug Fixes 89 | 90 | - **test:** Corrects test name ([0714b07](https://github.com/mblarsen/browser-acl/commit/0714b07)) 91 | - Newed up police is used instead of the constructor function ([ed990b4](https://github.com/mblarsen/browser-acl/commit/ed990b4)) 92 | 93 | ### Features 94 | 95 | - Adds register function ([5e97be1](https://github.com/mblarsen/browser-acl/commit/5e97be1)) 96 | 97 | ## [0.3.2](https://github.com/mblarsen/browser-acl/compare/v0.3.1...v0.3.2) (2017-10-20) 98 | 99 | ### Features 100 | 101 | - Adds some and every functions ([fe41e6b](https://github.com/mblarsen/browser-acl/commit/fe41e6b)) 102 | 103 | ## [0.3.1](https://github.com/mblarsen/browser-acl/compare/v0.3.0...v0.3.1) (2017-10-20) 104 | 105 | ### Features 106 | 107 | - `can` check can take additional params passed to rule eval ([981b3f2](https://github.com/mblarsen/browser-acl/commit/981b3f2)) 108 | 109 | # [0.3.0](https://github.com/mblarsen/browser-acl/compare/v0.2.0...v0.3.0) (2017-10-20) 110 | 111 | # [0.2.0](https://github.com/mblarsen/browser-acl/compare/v0.1.5...v0.2.0) (2017-10-20) 112 | 113 | ## [0.1.5](https://github.com/mblarsen/browser-acl/compare/v0.1.4...v0.1.5) (2017-10-20) 114 | 115 | ### Bug Fixes 116 | 117 | - Fixes build ([dd31329](https://github.com/mblarsen/browser-acl/commit/dd31329)) 118 | 119 | ### Features 120 | 121 | - Added strict mode ([a234938](https://github.com/mblarsen/browser-acl/commit/a234938)) 122 | 123 | ## [0.1.4](https://github.com/mblarsen/browser-acl/compare/v0.1.3...v0.1.4) (2017-10-19) 124 | 125 | ### Bug Fixes 126 | 127 | - **test:** Includes index.js explicitly otherwise package.json is used ([527ff15](https://github.com/mblarsen/browser-acl/commit/527ff15)) 128 | 129 | ## [0.1.3](https://github.com/mblarsen/browser-acl/compare/v0.1.2...v0.1.3) (2017-10-19) 130 | 131 | ## [0.1.2](https://github.com/mblarsen/browser-acl/compare/v0.1.1...v0.1.2) (2017-10-19) 132 | 133 | ## [0.1.1](https://github.com/mblarsen/browser-acl/compare/0143d8d...v0.1.1) (2017-10-19) 134 | 135 | ### Bug Fixes 136 | 137 | - New verb doesn't flush old ones for subject ([0143d8d](https://github.com/mblarsen/browser-acl/commit/0143d8d)) 138 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Michael Bøcker-Larsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # browser-acl 🔒 2 | 3 | [![build status](http://img.shields.io/travis/mblarsen/browser-acl.svg)](http://travis-ci.org/mblarsen/browser-acl) 4 | [![codebeat badge](https://codebeat.co/badges/c3b557c1-c111-4dbb-bd0a-9c6a30a3b247)](https://codebeat.co/projects/github-com-mblarsen-browser-acl-master) 5 | [![Known Vulnerabilities](https://snyk.io/test/github/mblarsen/browser-acl/badge.svg)](https://snyk.io/test/github/mblarsen/browser-acl) 6 | [![Monthly downloads](https://img.shields.io/npm/dm/browser-acl.svg)](https://www.npmjs.com/package/browser-acl) 7 | [![NPM version](http://img.shields.io/npm/v/browser-acl.svg)](https://www.npmjs.com/package/browser-acl) 8 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mblarsen/browser-acl/blob/master/LICENSE) 9 | 10 | > Simple access control (ACL) library for the browser inspired by Laravel's guards and policies. 11 | 12 | Go to [vue-browser-acl](https://github.com/mblarsen/vue-browser-acl) for the official Vue package. 13 | 14 | ![example](example.png) 15 | 16 | [![Contact me on Codementor](https://www.codementor.io/m-badges/mblarsen/im-a-cm-g.svg)](https://www.codementor.io/@mblarsen?refer=badge) 17 | 18 | ## Install 19 | 20 | ``` 21 | npm i browser-acl 22 | ``` 23 | 24 | ## Setup 25 | 26 | ```javascript 27 | import Acl from 'browser-acl' 28 | const acl = new Acl() 29 | 30 | acl.rule('view', Post) 31 | acl.rule('moderate', Post, (user) => user.isModerator()) 32 | acl.rule(['edit', 'delete'], Post, (user, post) => post.userId === user.id) 33 | acl.rule('purgeInactive', (user) => user.isAdmin) 34 | ``` 35 | 36 | [![Try browser-acl on RunKit](https://badge.runkitcdn.com/browser-acl.svg)](https://npm.runkit.com/browser-acl) 37 | 38 | Policies (rules through objects or classes) are also supported: 39 | 40 | ```javascript 41 | // using an object 42 | acl.policy({ 43 | view: true, 44 | edit: (user, post) => post.userId === user.id), 45 | }, Post) 46 | 47 | // using a class 48 | acl.policy(OrganizationPolicy, Organization) 49 | ``` 50 | 51 | Note: policies takes precedence over rules. 52 | 53 | ## Usage 54 | 55 | ```javascript 56 | // true if user owns post 57 | acl.can(user, 'edit', post) 58 | 59 | // true if user owns at least posts 60 | acl.some(user, 'edit', posts) 61 | 62 | // true if user owns all posts 63 | acl.every(user, 'edit', posts) 64 | ``` 65 | 66 | You can add mixins to your user class: 67 | 68 | ```javascript 69 | acl.mixin(User) // class not instance 70 | 71 | user.can('edit', post) 72 | user.can.some('edit', posts) 73 | user.can.every('edit', posts) 74 | ``` 75 | 76 | ### Verb object mapping 77 | 78 | > The process of mapping a verb object to rules 79 | 80 | A **verb object** is an item, an object, an instance of a class. 81 | 82 | The default verb object mapper makes use of ["poor-man's reflection"](https://github.com/mblarsen/browser-acl/blob/f52cc8e704681cb33d4867e7a217e990444baa6a/index.js#L248-L298), that uses the 83 | name of the verb object's constructor to group the rules. 84 | 85 | ```javascript 86 | class Post {} 87 | const post = new Post() 88 | post.constructor.name // The verb object is: Post 89 | ``` 90 | 91 | **Warning: When using webpack or similar this method can break if you are not careful.** 92 | 93 | Since code minifiers will rename functions you have to make sure you only rely 94 | on the function to set up your rules and asking for permission. 95 | 96 | ```diff 97 | acl.rule('edit', 'Post', ...) 98 | acl.can(user, 'edit', 'Post') 👍 works as expected 99 | acl.can(user, 'edit', Post) 👎 'Post' isn't the name as you'd expect 100 | acl.can(user, 'edit', post) 👎 same story here 101 | ``` 102 | 103 | If your build process minifies your code (specifically mangling of function and class 104 | names), this will break in line 3 since the constructor of post will likely not be `Post` 105 | but rather a single letter or a name prefixed with `__WEBPACK_IMPORTED_MODULE`. 106 | 107 | ```diff 108 | - acl.rule('edit', 'Post', ...) 109 | + acl.rule('edit', Post, ...) 110 | acl.can(user, 'edit', 'Post') 👍 works as expected 111 | acl.can(user, 'edit', Post) 👍 and so does this 112 | acl.can(user, 'edit', post) 👍 this too, but see below 113 | ``` 114 | 115 | Passing the class or function, `Post` and whatever that name is after 116 | minification, is used to register the rules. As long as the same import is used 117 | throughout your code base it will work and you don't need to explicitly 118 | register a model. 119 | 120 | #### Best practice 121 | 122 | ```diff 123 | + acl.register(Post, 'Post') 124 | acl.can(user, 'edit', 'Post') 👍 works as expected 125 | acl.can(user, 'edit', Post) 👍 and so does this 126 | acl.can(user, 'edit', post) 👍 this too 127 | ``` 128 | 129 | If you are using _plain objects_ you may want to override the `verbObjectMapper` with 130 | a custom implementation. 131 | 132 | ```javascript 133 | acl.verbObjectMapper = verbObject => typeof verbObject === 'string' 134 | ? verbObject 135 | : verbObject.type 136 | 137 | const post = { type: 'post', id: 1, title: 'My first post' } 138 | acl.can(user, 'edit', post) 👍 139 | ``` 140 | 141 | See more [verbObjectMapper](#verbObjectmapper) 142 | 143 | ## Additional Parameters and Global Rules 144 | 145 | You can define global rules by omitting the verb object when defining rules. 146 | 147 | ```javascript 148 | acl.rule('purgeInactive', (user) => user.admin) 149 | acl.can(user, 'purgeInactive') 150 | ``` 151 | 152 | Also you can pass additional parameters to the handler like this: 153 | 154 | ```javascript 155 | acl.rule('edit', Post, (user, post, verb, additionalParameter) => true) 156 | acl.can(user, 'edit', post, additionalParameter) 157 | ``` 158 | 159 | However, you cannot combine the two without explicitly stating that you are 160 | defining a global rule. You do this by importing the special `GlobalRule` 161 | verb object. 162 | 163 | ```javascript 164 | import { GlobalRule } from 'browser-acl' 165 | acl.rule('purgeInactive', GlobalRule, (user) => user.admin) 166 | acl.can(user, 'purgeInactive', GlobalRule, additionalParameter) 167 | ``` 168 | 169 | Note: When defining the rule you can omit it, but is is required for `can`. 170 | This is only in the case when you need to pass additional parameters. 171 | 172 | # API 173 | 174 | 175 | 176 | ### Table of Contents 177 | 178 | - [Acl][1] 179 | - [rule][2] 180 | - [policy][3] 181 | - [register][4] 182 | - [can][5] 183 | - [some][6] 184 | - [every][7] 185 | - [mixin][8] 186 | - [verbObjectMapper][9] 187 | - [reset][10] 188 | - [removeRules][11] 189 | - [removePolicy][12] 190 | - [removeAll][13] 191 | 192 | ## Acl 193 | 194 | Simple ACL library for the browser inspired by Laravel's guards and policies. 195 | 196 | **Parameters** 197 | 198 | - `$0` **[Object][14]** (optional, default `{}`) 199 | - `$0.strict` (optional, default `false`) 200 | - `options` **[Object][14]** 201 | - `null` **[Boolean][15]** {strict=false}={} Errors out on unknown verbs when true 202 | 203 | ### rule 204 | 205 | You add rules by providing a verb, a verb object and an optional 206 | test (that otherwise defaults to true). 207 | 208 | If the test is a function it will be evaluated with the params: 209 | user, verb object, and verbObjectName. The test value is ultimately evaluated 210 | for truthiness. 211 | 212 | Examples: 213 | 214 | ```javascript 215 | acl.rule('create', Post) 216 | acl.rule('edit', Post, (user, post) => post.userId === user.id) 217 | acl.rule( 218 | 'edit', 219 | Post, 220 | (user, post, verb, additionalParameter, secondAdditionalParameter) => true, 221 | ) 222 | acl.rule('delete', Post, false) // deleting disabled 223 | acl.rule('purgeInactive', (user) => user.isAdmin) // global rule 224 | ``` 225 | 226 | **Parameters** 227 | 228 | - `verbs` **([Array][16]<[string][17]> | [string][17])** 229 | - `verbObject` **([Function][18] \| [Object][14] \| [string][17])** ? 230 | - `test` **([Boolean][15] \| [Function][18])** =true (optional, default `true`) 231 | 232 | Returns **[Acl][19]** 233 | 234 | ### policy 235 | 236 | You can group related rules into policies for a verb object. The policies 237 | properties are verbs and they can plain values or functions. 238 | 239 | If the policy is a function it will be new'ed up before use. 240 | 241 | ```javascript 242 | class Post { 243 | constructor() { 244 | this.view = true // no need for a functon 245 | this.delete = false // not really necessary since an abscent 246 | // verb has the same result 247 | } 248 | beforeAll(verb, user, ...theRest) { 249 | if (user.isAdmin) { 250 | return true 251 | } 252 | // return nothing (undefined) to pass it on to the other rules 253 | } 254 | edit(user, post, verb, additionalParameter, secondAdditionalParameter) { 255 | return post.id === user.id 256 | } 257 | } 258 | ``` 259 | 260 | Policies are useful for grouping rules and adding more complex logic. 261 | 262 | **Parameters** 263 | 264 | - `policy` **[Object][14]** A policy with properties that are verbs 265 | - `verbObject` **([Function][18] \| [Object][14] \| [string][17])** 266 | 267 | Returns **[Acl][19]** 268 | 269 | ### register 270 | 271 | Explicitly map a class or constructor function to a name. 272 | 273 | You would want to do this in case your code is heavily 274 | minified in which case the default mapper cannot use the 275 | simple "reflection" to resolve the verb object name. 276 | 277 | Note: If you override the verbObjectMapper this is not used, 278 | bud it can be used manually through `this.registry`. 279 | 280 | **Parameters** 281 | 282 | - `klass` **[Function][18]** A class or constructor function 283 | - `verbObjectName` **[string][17]** 284 | 285 | ### can 286 | 287 | Performs a test if a user can perform action on verb object. 288 | 289 | The action is a verb and the verb object can be anything the 290 | verbObjectMapper can map to a verb object name. 291 | 292 | E.g. if you can to test if a user can delete a post you would 293 | pass the actual post. Where as if you are testing us a user 294 | can create a post you would pass the class function or a 295 | string. 296 | 297 | ```javascript 298 | acl.can(user, 'create', Post) 299 | acl.can(user, 'edit', post) 300 | acl.can(user, 'edit', post, additionalParameter, secondAdditionalParameter) 301 | ``` 302 | 303 | Note that these are also available on the user if you've used 304 | the mixin: 305 | 306 | ```javascript 307 | user.can('create', Post) 308 | user.can('edit', post) 309 | ``` 310 | 311 | **Parameters** 312 | 313 | - `user` **[Object][14]** 314 | - `verb` **[string][17]** 315 | - `verbObject` **([Function][18] \| [Object][14] \| [string][17])** 316 | - `args` **...any** Any other param is passed into rule 317 | 318 | Returns **any** Boolean 319 | 320 | ### some 321 | 322 | Like can but verb object is an array where only some has to be 323 | true for the rule to match. 324 | 325 | Note the verb objects do not need to be of the same kind. 326 | 327 | **Parameters** 328 | 329 | - `user` **[Object][14]** 330 | - `verb` 331 | - `verbObjects` **[Array][16]<([Function][18] \| [Object][14] \| [string][17])>** 332 | - `args` **...any** Any other param is passed into rule 333 | 334 | Returns **any** Boolean 335 | 336 | ### every 337 | 338 | Like can but verb object is an array where all has to be 339 | true for the rule to match. 340 | 341 | Note the verb objects do not need to be of the same kind. 342 | 343 | **Parameters** 344 | 345 | - `user` **[Object][14]** 346 | - `verb` 347 | - `verbObjects` **[Array][16]<([Function][18] \| [Object][14] \| [string][17])>** 348 | - `args` **...any** Any other param is passed into rule 349 | 350 | Returns **any** Boolean 351 | 352 | ### mixin 353 | 354 | Mix in augments your user class with a `can` function object. This 355 | is optional and you can always call `can` directly on your 356 | Acl instance. 357 | 358 | user.can() 359 | user.can.some() 360 | user.can.every() 361 | 362 | **Parameters** 363 | 364 | - `User` **[Function][18]** A user class or contructor function 365 | 366 | ### verbObjectMapper 367 | 368 | Rules are grouped by verb objects and this default mapper tries to 369 | map any non falsy input to a verb object name. 370 | 371 | This is important when you want to try a verb against a rule 372 | passing in an instance of a class. 373 | 374 | - strings becomes verb objects 375 | - function's names are used for verb object 376 | - object's constructor name is used for verb object 377 | 378 | Override this function if your models do not match this approach. 379 | 380 | E.g. say that you are using plain data objects with a type property 381 | to indicate the type of the object. 382 | 383 | ```javascript 384 | acl.verbObjectMapper = (s) => (typeof s === 'string' ? s : s.type) 385 | ``` 386 | 387 | `can` will now use this function when you pass in your objects. 388 | 389 | ```javascript 390 | acl.rule('edit', 'book', (user, book) => user.id === book.authorId) 391 | const thing = { title: 'The Silmarillion', authorId: 1, type: 'book' } 392 | acl.can(user, 'edit', thing) 393 | ``` 394 | 395 | In the example above the 'thing' will follow the rules for 'book'. The 396 | user can edit the book if they are the author. 397 | 398 | See [register()][4] for how to manually map 399 | classes to verb object name. 400 | 401 | **Parameters** 402 | 403 | - `verbObject` **([Function][18] \| [Object][14] \| [string][17])** 404 | 405 | Returns **[string][17]** A verb object 406 | 407 | ### reset 408 | 409 | Removes all rules, policies, and registrations 410 | 411 | Returns **[Acl][19]** 412 | 413 | ### removeRules 414 | 415 | Remove rules for verb object 416 | 417 | Optionally limit to a single verb. 418 | 419 | **Parameters** 420 | 421 | - `verbObject` **([Object][14] \| [Function][18] \| [String][17])** 422 | - `verb` **[String][17]?** an optional verb (optional, default `null`) 423 | 424 | Returns **[Acl][19]** 425 | 426 | ### removePolicy 427 | 428 | Remove policy for verb object 429 | 430 | **Parameters** 431 | 432 | - `verbObject` **([Object][14] \| [Function][18] \| [String][17])** 433 | 434 | Returns **[Acl][19]** 435 | 436 | ### removeAll 437 | 438 | Convenience method for removing all rules and policies for a verb object 439 | 440 | **Parameters** 441 | 442 | - `verbObject` **([Object][14] \| [Function][18] \| [String][17])** 443 | 444 | Returns **[Acl][19]** 445 | 446 | [1]: #acl 447 | [2]: #rule 448 | [3]: #policy 449 | [4]: #register 450 | [5]: #can 451 | [6]: #some 452 | [7]: #every 453 | [8]: #mixin 454 | [9]: #verbObjectmapper 455 | [10]: #reset 456 | [11]: #removerules 457 | [12]: #removepolicy 458 | [13]: #removeall 459 | [14]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object 460 | [15]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean 461 | [16]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array 462 | [17]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String 463 | [18]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function 464 | [19]: #acl 465 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mblarsen/browser-acl/c64bb137146c19d00ff479ad08687780265e32b5/example.png -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.(t|j)sx?$": "ts-jest" 4 | }, 5 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 6 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-acl", 3 | "description": "Simple ACL library for the browser inspired by Laravel's guards and policies.", 4 | "version": "1.0.2", 5 | "author": "Michael Bøcker-Larsen ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "browser", 9 | "acl", 10 | "permissions" 11 | ], 12 | "source": "src/index.ts", 13 | "main": "dist/index.js", 14 | "umd:main": "dist/index.umd.js", 15 | "module": "dist/index.module.js", 16 | "types": "dist/index.d.ts", 17 | "files": [ 18 | "dist", 19 | "types", 20 | "runkit.js" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/mblarsen/browser-acl" 25 | }, 26 | "bugs": "https://github.com/mblarsen/browser-acl/issues", 27 | "scripts": { 28 | "prebuild": "rm -rf ./dist", 29 | "build": "microbundle build --tsconfig ./tsconfig.json --name BrowserAcl", 30 | "test": "jest --config jest.config.json", 31 | "test:watch": "jest --config jest.config.json --watchAll", 32 | "preversion": "npm run build && npm run test", 33 | "postversion": "git push --tags" 34 | }, 35 | "devDependencies": { 36 | "@babel/core": "^7.18.6", 37 | "@babel/preset-env": "^7.18.6", 38 | "@babel/types": "^7.18.7", 39 | "@types/jest": "^28.1.3", 40 | "babel-jest": "^28.1.2", 41 | "jest": "^28.1.2", 42 | "microbundle": "^0.15.0", 43 | "ts-jest": "^28.0.5", 44 | "typescript": "^4.7.4" 45 | }, 46 | "prettier": { 47 | "semi": false, 48 | "singleQuote": true, 49 | "trailingComma": "all" 50 | }, 51 | "runkitExampleFilename": "runkit.js", 52 | "thanks": "https://github.com/mblarsen/browser-acl?sponsor=1" 53 | } 54 | -------------------------------------------------------------------------------- /runkit.js: -------------------------------------------------------------------------------- 1 | const { default: Acl } = require('browser-acl') 2 | const acl = new Acl() 3 | 4 | class User { 5 | constructor({ id, name, role = 'user' }) { 6 | this.id = id 7 | this.name = name 8 | this.role = role 9 | } 10 | isModerator() { 11 | return this.role === 'moderator' || this.role === 'admin' 12 | } 13 | isAdmin() { 14 | return this.role === 'admin' 15 | } 16 | } 17 | 18 | class Post { 19 | constructor({ userId, title }) { 20 | this.title = title 21 | this.userId = userId 22 | } 23 | } 24 | 25 | class Comment { 26 | constructor({ userId, body }) { 27 | this.body = body 28 | this.userId = userId 29 | } 30 | } 31 | 32 | class CommentPolicy { 33 | beforeAll(_, user) { 34 | if (user.isAdmin()) { 35 | return true 36 | } 37 | } 38 | edit(user, comment) { 39 | return comment.userId === user.id 40 | } 41 | moderate(user, _) { 42 | return user.isModerator() 43 | } 44 | } 45 | 46 | acl.rule('view', Post) 47 | acl.rule('moderate', Post, user => user.isModerator()) 48 | acl.rule(['edit', 'delete'], Post, (user, post) => post.userId === user.id) 49 | acl.rule('purgeInactive', user => user.isAdmin()) 50 | acl.policy(CommentPolicy, Comment) 51 | 52 | const admin = new User({ id: 1, name: 'Admin', role: 'admin' }) 53 | const moderator = new User({ id: 2, name: 'Moderator', role: 'moderator' }) 54 | const user1 = new User({ id: 3, name: 'User 1', role: 'user' }) 55 | const user2 = new User({ id: 4, name: 'User 2', role: 'user' }) 56 | const post1 = new Post({ userId: 3, title: 'Post 1' }) 57 | const comment1 = new Comment({ userId: 3, body: 'Hmmm...' }) 58 | 59 | console.log('User 1 can edit post 1?', acl.can(user1, 'edit', post1)) 60 | console.log('User 2 can edit post 1?', acl.can(user2, 'edit', post1)) 61 | console.log('Moderator can edit post 1?', acl.can(moderator, 'edit', post1)) 62 | console.log( 63 | 'Moderator can moderate post 1?', 64 | acl.can(moderator, 'moderate', post1), 65 | ) 66 | 67 | console.log('Admin can edit comment 1?', acl.can(admin, 'edit', comment1)) 68 | console.log('User 2 can edit comment 1?', acl.can(user2, 'edit', comment1)) 69 | -------------------------------------------------------------------------------- /src/__tests__/index.ts: -------------------------------------------------------------------------------- 1 | import Acl from '../index' 2 | import { Policy } from '../../types/index.d' 3 | 4 | interface MixinUser { 5 | can?: Function 6 | } 7 | 8 | class User { 9 | [key: string]: any 10 | } 11 | 12 | class Apple { 13 | [key: string]: any 14 | } 15 | class Job { 16 | [key: string]: any 17 | 18 | constructor(data?: any) { 19 | Object.assign(this, data || {}) 20 | } 21 | } 22 | 23 | describe('The basics', () => { 24 | test('Acl mixin', () => { 25 | const acl = new Acl() 26 | acl.mixin(User) 27 | const user: MixinUser = new User() 28 | expect(user.can).toBeDefined() 29 | }) 30 | 31 | test('Global rules', () => { 32 | const acl = new Acl() 33 | acl.rule('purgeInactive', (user: User) => user.isAdmin) 34 | expect(acl.can({ isAdmin: true } as User, 'purgeInactive')).toBe(true) 35 | expect(acl.can({ isAdmin: false }, 'purgeInactive')).toBe(false) 36 | acl.rule('contact', true) 37 | expect(acl.can({}, 'contact')).toBe(true) 38 | acl.rule('linger', false) 39 | expect(acl.can({}, 'linger')).toBe(false) 40 | acl.rule('pillage', false) 41 | expect(acl.can({}, 'pillage')).toBe(false) 42 | }) 43 | 44 | test('Cannot eat apples (no rule)', () => { 45 | const acl = new Acl() 46 | acl.mixin(User) 47 | const user = new User() 48 | expect(user.can('eat', 'Apple')).toBe(false) 49 | }) 50 | 51 | test('Cannot eat apples (!test)', () => { 52 | const acl = new Acl() 53 | acl.mixin(User) 54 | acl.rule(['eat'], 'Apple', false) 55 | const user = new User() 56 | expect(user.can('eat', 'Apple')).toBe(false) 57 | }) 58 | 59 | test('Can eat apples (string)', () => { 60 | const acl = new Acl() 61 | acl.mixin(User) 62 | acl.rule(['eat'], 'Apple') 63 | const user = new User() 64 | expect(user.can('eat', 'Apple')).toBe(true) 65 | }) 66 | 67 | test('Can eat apples (Class)', () => { 68 | const acl = new Acl() 69 | acl.mixin(User) 70 | acl.rule(['eat'], Apple) 71 | const user = new User() 72 | expect(user.can('eat', Apple)).toBe(true) 73 | }) 74 | 75 | test('Can eat apples (Object)', () => { 76 | const acl = new Acl() 77 | acl.mixin(User) 78 | acl.rule(['eat'], Apple) 79 | const user = new User() 80 | expect(user.can('eat', new Apple())).toBe(true) 81 | }) 82 | 83 | test('Can eat apples (params)', () => { 84 | const acl = new Acl() 85 | acl.mixin(User) 86 | acl.rule(['eat'], Apple, function (_0, _1, _2, param: string) { 87 | expect(param).toBe('worm') 88 | return true 89 | }) 90 | const user = new User() 91 | expect(user.can('eat', new Apple(), 'worm')).toBe(true) 92 | }) 93 | }) 94 | 95 | describe('Multiple', () => { 96 | test('Can eat some apples', () => { 97 | const acl = new Acl() 98 | acl.mixin(User) 99 | acl.rule('eat', Apple, (_, a) => !Boolean(a.rotten)) 100 | const user = new User() 101 | const fine = new Apple() 102 | const rotten = new Apple() 103 | rotten.rotten = true 104 | expect(user.can('eat', fine)).toBe(true) 105 | expect(user.can('eat', rotten)).toBe(false) 106 | expect(acl.some(user, 'eat', [fine, rotten])).toBe(true) 107 | expect(acl.some(user, 'eat', [rotten, rotten])).toBe(false) 108 | }) 109 | 110 | test('Can eat some apples and jobs', () => { 111 | const acl = new Acl() 112 | acl.rule('eat', Apple) 113 | acl.rule('eat', Job) 114 | const user = new User() 115 | expect(acl.can(user, 'eat', new Apple())).toBe(true) 116 | expect(acl.can(user, 'eat', new Job())).toBe(true) 117 | expect(acl.some(user, 'eat', [new Apple(), new Job()])).toBe(true) 118 | }) 119 | 120 | test('Can eat every apple', () => { 121 | const acl = new Acl() 122 | acl.mixin(User) 123 | acl.rule('eat', Apple, (_, a) => !Boolean(a.rotten)) 124 | const user = new User() 125 | const fine = new Apple() 126 | const rotten = new Apple() 127 | rotten.rotten = true 128 | expect(user.can('eat', fine)).toBe(true) 129 | expect(user.can('eat', rotten)).toBe(false) 130 | expect(acl.every(user, 'eat', [fine, rotten])).toBe(false) 131 | expect(acl.every(user, 'eat', [fine, new Apple()])).toBe(true) 132 | }) 133 | 134 | test('User can eat some apples', () => { 135 | const acl = new Acl() 136 | acl.mixin(User) 137 | acl.rule('eat', Apple, (_, a) => !Boolean(a.rotten)) 138 | const user = new User() 139 | const fine = new Apple() 140 | const rotten = new Apple() 141 | rotten.rotten = true 142 | expect(user.can.some('eat', [fine, rotten])).toBe(true) 143 | }) 144 | 145 | test('User can eat every apple', () => { 146 | const acl = new Acl() 147 | acl.mixin(User) 148 | acl.rule('eat', Apple, (_, a) => !Boolean(a.rotten)) 149 | const user = new User() 150 | const fine = new Apple() 151 | const rotten = new Apple() 152 | rotten.rotten = true 153 | expect(user.can.every('eat', [fine, rotten])).toBe(false) 154 | expect(user.can.every('eat', [fine, new Job()])).toBe(false) 155 | }) 156 | }) 157 | 158 | describe('Strict mode', () => { 159 | test('Throws on unknown verb', () => { 160 | const acl = new Acl({ strict: true }) 161 | acl.mixin(User) 162 | acl.rule('grow', Apple) 163 | const user = new User() 164 | expect(user.can.bind(user, 'eat', new Apple())).toThrow( 165 | 'Unknown verb "eat"', 166 | ) 167 | }) 168 | 169 | test('Throws on unknown verb object', () => { 170 | const acl = new Acl({ strict: true }) 171 | acl.mixin(User) 172 | const user = new User() 173 | expect(user.can.bind(user, 'eat', new Apple())).toThrow( 174 | 'No rules for verb object "Apple"', 175 | ) 176 | }) 177 | }) 178 | 179 | describe('Registry and mapper', () => { 180 | test('Can register a class', () => { 181 | const acl = new Acl({ strict: true }) 182 | class Foo {} 183 | acl.register(Foo, 'User') 184 | expect(acl.registry.has(Foo)).toBe(true) 185 | expect(acl.registry.get(Foo)).toBe('User') 186 | acl.rule('greet', Foo) 187 | expect(acl.can({}, 'greet', new Foo())).toBe(true) 188 | }) 189 | 190 | test('Custom mapper', () => { 191 | const acl = new Acl({ strict: true }) 192 | const item = { type: 'Item' } 193 | acl.rule('lock', 'Item') 194 | expect(acl.can.bind(acl, {}, 'lock', item)).toThrow( 195 | 'No rules for verb object "Object"', 196 | ) 197 | acl.verbObjectMapper = (s: string | { [key: string]: any }) => 198 | typeof s === 'string' ? s : s.type 199 | expect(acl.can({}, 'lock', item)).toBe(true) 200 | }) 201 | }) 202 | 203 | describe('Reset and remove', () => { 204 | test('Reset', () => { 205 | const acl = new Acl() 206 | function JobPolicy(this: Policy) { 207 | this.view = true 208 | } 209 | const job = new Job() 210 | const apple = new Apple() 211 | acl.strict = true 212 | acl.register(Job, 'Job') 213 | acl.rule('eat', 'Apple') 214 | acl.policy(JobPolicy, Job) 215 | expect(acl.registry.has(Job)).toBe(true) 216 | expect(acl.can({}, 'view', job)).toBe(true) 217 | expect(acl.can({}, 'eat', apple)).toBe(true) 218 | acl.reset() 219 | expect(acl.registry.has(Job)).toBe(false) 220 | expect(acl.can.bind(acl, {}, 'view', job)).toThrow( 221 | 'No rules for verb object "Job"', 222 | ) 223 | expect(acl.can.bind(acl, {}, 'eat', apple)).toThrow( 224 | 'No rules for verb object "Apple"', 225 | ) 226 | }) 227 | 228 | test('Remove rules', () => { 229 | const acl = new Acl() 230 | const apple = new Apple() 231 | acl.strict = true 232 | acl.rule('eat', 'Apple') 233 | acl.rule('discard', 'Apple') 234 | expect(acl.can({}, 'eat', apple)).toBe(true) 235 | expect(acl.can({}, 'discard', apple)).toBe(true) 236 | acl.removeRules(apple) 237 | expect(acl.can.bind(acl, {}, 'eat', apple)).toThrow( 238 | 'No rules for verb object "Apple"', 239 | ) 240 | expect(acl.can.bind(acl, {}, 'discard', apple)).toThrow( 241 | 'No rules for verb object "Apple"', 242 | ) 243 | }) 244 | 245 | test('Remove rules, single', () => { 246 | const acl = new Acl() 247 | const apple = new Apple() 248 | acl.strict = true 249 | acl.register(Apple, 'Apple') 250 | acl.rule('eat', 'Apple') 251 | acl.rule('discard', 'Apple') 252 | expect(acl.can({}, 'eat', apple)).toBe(true) 253 | expect(acl.can({}, 'discard', apple)).toBe(true) 254 | acl.removeRules(apple, 'discard') 255 | expect(acl.registry.has(Apple)).toBe(true) 256 | expect(acl.can({}, 'eat', apple)).toBe(true) 257 | expect(acl.can.bind(acl, {}, 'discard', apple)).toThrow( 258 | 'Unknown verb "discard"', 259 | ) 260 | }) 261 | 262 | test('Remove policy', () => { 263 | const acl = new Acl() 264 | function JobPolicy(this: Policy) { 265 | this.view = true 266 | } 267 | const job = new Job() 268 | acl.strict = true 269 | acl.register(Job, 'Job') 270 | acl.policy(JobPolicy, Job) 271 | expect(acl.can({}, 'view', job)).toBe(true) 272 | acl.removePolicy(job) 273 | expect(acl.registry.has(Job)).toBe(true) 274 | expect(acl.can.bind(acl, {}, 'view', job)).toThrow( 275 | 'No rules for verb object "Job"', 276 | ) 277 | }) 278 | 279 | test('Remove all', () => { 280 | const acl = new Acl() 281 | function JobPolicy(this: Policy) { 282 | this.view = true 283 | } 284 | const job = new Job() 285 | const apple = new Apple() 286 | acl.strict = true 287 | acl.register(Job, 'Job') 288 | acl.rule('eat', 'Apple') 289 | acl.policy(JobPolicy, Job) 290 | expect(acl.registry.has(Job)).toBe(true) 291 | expect(acl.can({}, 'view', job)).toBe(true) 292 | expect(acl.can({}, 'eat', apple)).toBe(true) 293 | acl.removeAll(job) 294 | acl.removeAll(apple) 295 | expect(acl.registry.has(Job)).toBe(true) 296 | expect(acl.can.bind(acl, {}, 'view', job)).toThrow( 297 | 'No rules for verb object "Job"', 298 | ) 299 | expect(acl.can.bind(acl, {}, 'eat', apple)).toThrow( 300 | 'No rules for verb object "Apple"', 301 | ) 302 | }) 303 | }) 304 | 305 | describe('More complex cases', () => { 306 | test('Can create jobs', () => { 307 | const acl = new Acl() 308 | acl.mixin(User) 309 | const owner = new User() 310 | const coworker = new User() 311 | 312 | const data = { 313 | company: { 314 | users: [ 315 | { user: owner, role: 'owner' }, 316 | { user: coworker, role: 'coworker' }, 317 | ], 318 | }, 319 | } 320 | 321 | const company = { 322 | users: [ 323 | { user: owner, role: 'owner' }, 324 | { user: coworker, role: 'coworker' }, 325 | ], 326 | } 327 | 328 | const job = new Job({ 329 | users: [ 330 | { user: owner, role: 'owner' }, 331 | { user: coworker, role: 'coworker' }, 332 | ], 333 | }) 334 | 335 | acl.rule(['create'], Job, (user?: object): boolean => { 336 | return Boolean( 337 | company.users.find((rel) => rel.user === user && rel.role === 'owner'), 338 | ) 339 | }) 340 | 341 | acl.rule(['view'], Job, (user, verbObject) => { 342 | return ( 343 | verbObject.users.find((rel: any) => rel.user === user) || 344 | data.company.users.find( 345 | (rel) => rel.user === user && rel.role === 'owner', 346 | ) 347 | ) 348 | }) 349 | 350 | expect(owner.can('create', Job)).toBe(true) 351 | expect(coworker.can('create', Job)).toBe(false) 352 | 353 | expect(owner.can('view', job)).toBe(true) 354 | expect(coworker.can('view', job)).toBe(true) 355 | }) 356 | 357 | test('Policy', () => { 358 | const acl = new Acl() 359 | acl.mixin(User) 360 | const owner = new User() 361 | const coworker = new User() 362 | 363 | const data = { 364 | company: { 365 | users: [ 366 | { user: owner, role: 'owner' }, 367 | { user: coworker, role: 'coworker' }, 368 | ], 369 | }, 370 | } 371 | 372 | const job = new Job({ 373 | users: [ 374 | { user: owner, role: 'owner' }, 375 | { user: coworker, role: 'coworker' }, 376 | ], 377 | }) 378 | 379 | const policy = { 380 | create: function (user: any) { 381 | return data.company.users.find( 382 | (rel) => rel.user === user && rel.role === 'owner', 383 | ) 384 | }, 385 | view: function (user: any, verbObject: any) { 386 | return ( 387 | verbObject.users.find((rel: any) => rel.user === user) || 388 | data.company.users.find( 389 | (rel) => rel.user === user && rel.role === 'owner', 390 | ) 391 | ) 392 | }, 393 | } 394 | 395 | acl.policy(policy, Job) 396 | 397 | expect(owner.can('create', Job)).toBe(true) 398 | expect(coworker.can('create', Job)).toBe(false) 399 | 400 | expect(owner.can('view', job)).toBe(true) 401 | expect(coworker.can('view', job)).toBe(true) 402 | }) 403 | 404 | test('Policy newed', () => { 405 | const acl = new Acl() 406 | function JobPolicy(this: Policy) { 407 | this.view = true 408 | } 409 | acl.policy(JobPolicy, Job) 410 | expect(acl.policies.get('Job')).toBeInstanceOf(JobPolicy) 411 | expect(acl.can({}, 'view', new Job())).toBe(true) 412 | }) 413 | 414 | test('Policy overwrites rules', () => { 415 | const acl = new Acl() 416 | function JobPolicy(this: Policy) { 417 | this.view = true 418 | } 419 | const job = new Job() 420 | acl.rule('edit', 'Job') 421 | acl.policy(JobPolicy, Job) 422 | acl.rule('edit', 'Job') 423 | expect(acl.can({}, 'edit', job)).toBe(false) 424 | expect(acl.can({}, 'view', job)).toBe(true) 425 | }) 426 | 427 | test('Policy beforeAll', () => { 428 | const acl = new Acl() 429 | function JobPolicy(this: Policy) { 430 | this.beforeAll = function (verb: string, user: any) { 431 | if (user.isAdmin) { 432 | return true 433 | } 434 | if (verb === 'beLazy') { 435 | return false 436 | } 437 | } 438 | this.view = true 439 | this.edit = false 440 | this.beLazy = true 441 | } 442 | const job = new Job() 443 | acl.policy(JobPolicy, Job) 444 | expect(acl.can({}, 'view', job)).toBe(true) 445 | expect(acl.can({ isAdmin: true }, 'view', job)).toBe(true) 446 | expect(acl.can({}, 'edit', job)).toBe(false) 447 | expect(acl.can({ isAdmin: true }, 'edit', job)).toBe(true) 448 | expect(acl.can({}, 'beLazy', job)).toBe(false) 449 | expect(acl.can({ isAdmin: true }, 'beLazy', job)).toBe(true) 450 | }) 451 | }) 452 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Verb, VerbObject, Test, Policy, Options } from '../types' 2 | import { Verb, VerbObject, Test, Policy, Options } from '../types' 3 | 4 | type VerbObjectName = string | undefined 5 | 6 | type VerbObjectOrTest = VerbObject | boolean 7 | 8 | const assumeGlobal = (obj: any): boolean => 9 | typeof obj === 'boolean' || 10 | typeof obj === 'undefined' || 11 | (typeof obj === 'function' && obj.name === '') 12 | 13 | /** 14 | * Simple ACL library for the browser inspired by Laravel's guards and policies. 15 | * 16 | * Examples: 17 | * 18 | * ```javascript 19 | * acl.rule('create', Post) 20 | * acl.rule('edit', Post, (user, post) => post.userId === user.id) 21 | * ``` 22 | */ 23 | class Acl { 24 | static GlobalRule = 'GLOBAL_RULE' 25 | 26 | strict: boolean 27 | rules: Map }> 28 | policies: Map 29 | registry: WeakMap 30 | 31 | /** 32 | * browser-acl 33 | * 34 | * @access public 35 | */ 36 | constructor({ strict = false }: Options = {}) { 37 | this.strict = strict 38 | this.rules = new Map() 39 | this.policies = new Map() 40 | this.registry = new WeakMap() 41 | } 42 | 43 | /** 44 | * You add rules by providing a verb, a verb object and an optional 45 | * test (that otherwise defaults to true). 46 | * 47 | * If the test is a function it will be evaluated with the params: 48 | * user, verbObject, and verbObjectName. The test value is ultimately evaluated 49 | * for truthiness. 50 | * 51 | * Examples: 52 | * 53 | * ```javascript 54 | * acl.rule('create', Post) 55 | * acl.rule('edit', Post, (user, post) => post.userId === user.id) 56 | * acl.rule('edit', Post, (user, post, verb, additionalParameter, secondAdditionalParameter) => true) 57 | * acl.rule('delete', Post, false) // deleting disabled 58 | * acl.rule('purgeInactive', user => user.isAdmin) // global rule 59 | * ``` 60 | * 61 | * @access public 62 | */ 63 | rule( 64 | verbs: Verb | Verb[], 65 | verbObject: VerbObjectOrTest, 66 | test: Test = true, 67 | ) { 68 | let verbObject_: VerbObject 69 | if (assumeGlobal(verbObject)) { 70 | test = typeof verbObject === 'undefined' ? true : (verbObject as Test) 71 | verbObject_ = Acl.GlobalRule 72 | } else { 73 | verbObject_ = verbObject as VerbObject 74 | } 75 | const verbObjectName = this.verbObjectMapper(verbObject_) 76 | const verbs_ = Array.isArray(verbs) ? verbs : [verbs] 77 | verbs_.forEach((verb) => { 78 | const rules = this.rules.get(verbObjectName) || {} 79 | rules[verb] = test 80 | this.rules.set(verbObjectName, rules) 81 | }) 82 | return this 83 | } 84 | 85 | /** 86 | * You can group related rules into policies for a verb object. The policies 87 | * properties are verbs and they can plain values or functions. 88 | * 89 | * If the policy is a function it will be new'ed up before use. 90 | * 91 | * ```javascript 92 | * class Post { 93 | * constructor() { 94 | * this.view = true // no need for a functon 95 | * this.delete = false // not really necessary since an abscent 96 | * // verb has the same result 97 | * } 98 | * beforeAll(verb, user, ...theRest) { 99 | * if (user.isAdmin) { 100 | * return true 101 | * } 102 | * // return nothing (undefined) to pass it on to the other rules 103 | * } 104 | * edit(user, post, verb, additionalParameter, secondAdditionalParameter) { 105 | * return post.id === user.id 106 | * } 107 | * } 108 | * ``` 109 | * 110 | * Policies are useful for grouping rules and adding more complex logic. 111 | * 112 | * @access public 113 | */ 114 | policy(policy: Policy, verbObject: VerbObject) { 115 | const policy_ = 116 | typeof policy === 'function' ? new (policy as any)() : policy 117 | const verbObjectName = this.verbObjectMapper(verbObject) 118 | this.policies.set(verbObjectName, policy_) 119 | return this 120 | } 121 | 122 | /** 123 | * Explicitly map a class or constructor function to a name. 124 | * 125 | * You would want to do this in case your code is heavily 126 | * minified in which case the default mapper cannot use the 127 | * simple "reflection" to resolve the verb object name. 128 | * 129 | * Note: If you override the verbObjectMapper this is not used, 130 | * bud it can be used manually through `this.registry`. 131 | * 132 | * @access public 133 | */ 134 | register(klass: Function, verbObjectName: string) { 135 | this.registry.set(klass, verbObjectName) 136 | return this 137 | } 138 | 139 | /** 140 | * Performs a test if a user can perform action on verb object. 141 | * 142 | * The action is a verb and the verb object can be anything the 143 | * verbObjectMapper can map to a verb object name. 144 | * 145 | * E.g. if you can to test if a user can delete a post you would 146 | * pass the actual post. Where as if you are testing us a user 147 | * can create a post you would pass the class function or a 148 | * string. 149 | * 150 | * ```javascript 151 | * acl.can(user, 'create', Post) 152 | * acl.can(user, 'edit', post) 153 | * acl.can(user, 'edit', post, additionalParameter, secondAdditionalParameter) 154 | * ``` 155 | * 156 | * Note that these are also available on the user if you've used 157 | * the mixin: 158 | * 159 | * ```javascript 160 | * user.can('create', Post) 161 | * user.can('edit', post) 162 | * ``` 163 | * 164 | * @access public 165 | */ 166 | can( 167 | user: Object, 168 | verb: Verb, 169 | verbObject: VerbObject | undefined = undefined, 170 | ...args: any[] 171 | ) { 172 | verbObject = typeof verbObject === 'undefined' ? Acl.GlobalRule : verbObject 173 | const verbObjectName = this.verbObjectMapper(verbObject) 174 | 175 | const policy = this.policies.get(verbObjectName) 176 | const rules = policy || this.rules.get(verbObjectName) 177 | 178 | if (typeof rules === 'undefined') { 179 | if (this.strict) { 180 | throw new Error(`No rules for verb object "${verbObjectName}"`) 181 | } 182 | return false 183 | } 184 | 185 | if (policy && typeof policy.beforeAll === 'function') { 186 | const result = policy.beforeAll( 187 | verb, 188 | user, 189 | verbObject, 190 | verbObjectName, 191 | ...args, 192 | ) 193 | if (typeof result !== 'undefined') { 194 | return result 195 | } 196 | } 197 | 198 | if (typeof rules[verb] === 'function') { 199 | return Boolean(rules[verb](user, verbObject, verbObjectName, ...args)) 200 | } 201 | 202 | if (this.strict && typeof rules[verb] === 'undefined') { 203 | throw new Error(`Unknown verb "${verb}"`) 204 | } 205 | 206 | return Boolean(rules[verb]) 207 | } 208 | 209 | /** 210 | * Like can but verb object is an array where only some has to be 211 | * true for the rule to match. 212 | * 213 | * Note the verb objects do not need to be of the same kind. 214 | * 215 | * @access public 216 | */ 217 | some(user: object, verb: Verb, verbObjects: VerbObject[], ...args: any[]) { 218 | return verbObjects.some((s) => this.can(user, verb, s, ...args)) 219 | } 220 | 221 | /** 222 | * Like can but verbObject is an array where all has to be 223 | * true for the rule to match. 224 | * 225 | * Note the verb objects do not need to be of the same kind. 226 | * 227 | * @access public 228 | */ 229 | every(user: Object, verb: Verb, verbObjects: VerbObject[], ...args: any[]) { 230 | return verbObjects.every((s) => this.can(user, verb, s, ...args)) 231 | } 232 | 233 | /** 234 | * Mix in augments your user class with a `can` function object. This 235 | * is optional and you can always call `can` directly on your 236 | * Acl instance. 237 | * 238 | * ``` 239 | * user.can() 240 | * user.can.some() 241 | * user.can.every() 242 | * ``` 243 | * 244 | * @access public 245 | */ 246 | mixin(User: Function) { 247 | const acl = this 248 | User.prototype.can = function ( 249 | verb: Verb, 250 | verbObject: VerbObject, 251 | ...args: any[] 252 | ) { 253 | return acl.can(this, verb, verbObject, ...args) 254 | } 255 | User.prototype.can.every = function ( 256 | verb: Verb, 257 | verbObjects: VerbObject[], 258 | ...args: any[] 259 | ) { 260 | return acl.every(this, verb, verbObjects, ...args) 261 | } 262 | User.prototype.can.some = function ( 263 | verb: Verb, 264 | verbObjects: VerbObject[], 265 | ...args: any[] 266 | ) { 267 | return acl.some(this, verb, verbObjects, ...args) 268 | } 269 | return this 270 | } 271 | 272 | /** 273 | * Rules are grouped by verb objects and this default mapper tries to 274 | * map any non falsy input to a verb object name. 275 | * 276 | * This is important when you want to try a verb against a rule 277 | * passing in an instance of a class. 278 | * 279 | * - strings becomes verb objects 280 | * - function's names are used for verb object 281 | * - object's constructor name is used for verb object 282 | * 283 | * Override this function if your models do not match this approach. 284 | * 285 | * E.g. say that you are using plain data objects with a type property 286 | * to indicate the type of the object. 287 | * 288 | * ```javascript 289 | * acl.verbObjectMapper = s => typeof s === 'string' ? s : s.type 290 | * ``` 291 | * 292 | * `can` will now use this function when you pass in your objects. 293 | * 294 | * ```javascript 295 | * acl.rule('edit', 'book', (user, book) => user.id === book.authorId) 296 | * const thing = {title: 'The Silmarillion', authorId: 1, type: 'book'} 297 | * acl.can(user, 'edit', thing) 298 | * ``` 299 | * 300 | * In the example above the 'thing' will follow the rules for 'book'. The 301 | * user can edit the book if they are the author. 302 | * 303 | * See {@link #register register()} for how to manually map 304 | * classes to verb object name. 305 | * 306 | * @access public 307 | */ 308 | verbObjectMapper(verbObject: VerbObject): VerbObjectName { 309 | if (typeof verbObject === 'string') { 310 | return verbObject 311 | } 312 | if (this.registry.has(verbObject)) { 313 | return this.registry.get(verbObject) 314 | } 315 | if (this.registry.has(verbObject.constructor)) { 316 | return this.registry.get(verbObject.constructor) 317 | } 318 | if (typeof verbObject === 'function') { 319 | return verbObject.name 320 | } 321 | return verbObject.constructor.name 322 | } 323 | 324 | /** 325 | * Removes all rules, policies, and registrations 326 | */ 327 | reset() { 328 | this.rules = new Map() 329 | this.policies = new Map() 330 | this.registry = new WeakMap() 331 | return this 332 | } 333 | 334 | /** 335 | * Remove rules for verb object 336 | * 337 | * Optionally limit to a single verb. 338 | */ 339 | removeRules(verbObject: VerbObject, verb: Verb | null = null) { 340 | const verbObjectName = this.verbObjectMapper(verbObject) 341 | if (this.rules.has(verbObjectName)) { 342 | if (verb) { 343 | const rules = this.rules.get(verbObjectName) 344 | if (rules) { 345 | delete rules[verb] 346 | } 347 | return this 348 | } 349 | this.rules.delete(verbObjectName) 350 | } 351 | return this 352 | } 353 | 354 | /** 355 | * Remove policy for verb object 356 | */ 357 | removePolicy(verbObject: VerbObject) { 358 | const verbObjectName = this.verbObjectMapper(verbObject) 359 | this.policies.delete(verbObjectName) 360 | return this 361 | } 362 | 363 | /** 364 | * Convenience method for removing all rules and policies for a verb object 365 | */ 366 | removeAll(verbObject: VerbObject) { 367 | this.removeRules(verbObject) 368 | this.removePolicy(verbObject) 369 | return this 370 | } 371 | } 372 | 373 | export default Acl 374 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "esModuleInterop": true 5 | }, 6 | "exclude": ["node_modules", "dist", "**/__tests__"] 7 | } 8 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Disable all 'smarts' and require you to be explicit. 3 | */ 4 | export interface Options { 5 | strict?: boolean 6 | } 7 | 8 | /** 9 | * Something that possibly has a beforeAll and otherwise 10 | * just a string-access. 11 | */ 12 | export interface Policy { 13 | beforeAll?: Function 14 | [key: string]: any 15 | } 16 | 17 | /** 18 | * Verbs or action: view, edit, delete, restore, etc. 19 | */ 20 | export type Verb = string 21 | 22 | /** 23 | * The object of the verb. E.g. in the sentence: 'user edits post' here 24 | * 'post' is the verb object. 25 | */ 26 | export type VerbObject = string | Function | object 27 | 28 | /** 29 | * A callback with determines of a user can perform an action 30 | */ 31 | export type TestFunction = (user?: U, ...args: any[]) => boolean 32 | 33 | /** 34 | * The test for allowing the user to perform action 35 | */ 36 | export type Test = boolean | TestFunction 37 | --------------------------------------------------------------------------------