├── .npmrc ├── .prettierignore ├── eslint.config.js ├── .gitignore ├── tsconfig.json ├── stubs ├── main.ts ├── abilities.stub ├── make │ └── policy │ │ └── main.stub └── initialize_bouncer_middleware.stub ├── .github └── workflows │ ├── checks.yml │ ├── labels.yml │ ├── stale.yml │ └── release.yml ├── .editorconfig ├── bin └── test.ts ├── tests ├── helpers.ts ├── response.spec.ts ├── configure.spec.ts ├── index_policies.spec.ts ├── commands │ └── make_policy.spec.ts ├── abilities_builder.spec.ts ├── authorization_exception.spec.ts ├── plugins │ └── edge.spec.ts └── bouncer │ ├── abilities.spec.ts │ └── policies.spec.ts ├── src ├── debug.ts ├── decorators │ └── action.ts ├── base_policy.ts ├── abilities_builder.ts ├── response.ts ├── ability.ts ├── assembler_hooks │ └── index_policies.ts ├── plugins │ └── edge.ts ├── errors.ts ├── types.ts ├── policy_authorizer.ts └── bouncer.ts ├── index.ts ├── LICENSE.md ├── configure.ts ├── README.md ├── providers └── bouncer_provider.ts ├── commands └── make_policy.ts └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.md 4 | config.json 5 | .eslintrc.json 6 | package.json 7 | *.html 8 | *.txt 9 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg({ 3 | ignores: ['coverage'], 4 | }) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_STORE 4 | .nyc_output 5 | .idea 6 | .vscode/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | *.log 10 | build 11 | dist 12 | yarn.lock 13 | shrinkwrap.yaml 14 | package-lock.json 15 | test/__app 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build", 6 | "experimentalDecorators": true, 7 | "emitDecoratorMetadata": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /stubs/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export const stubsRoot = import.meta.dirname 11 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | test: 9 | uses: adonisjs/.github/.github/workflows/test.yml@next 10 | 11 | lint: 12 | uses: adonisjs/.github/.github/workflows/lint.yml@next 13 | 14 | typecheck: 15 | uses: adonisjs/.github/.github/workflows/typecheck.yml@next 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | workflow_dispatch: 4 | permissions: 5 | issues: write 6 | jobs: 7 | labels: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: EndBug/label-sync@v2 12 | with: 13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml' 14 | delete-other-labels: true 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { assert } from '@japa/assert' 3 | import { snapshot } from '@japa/snapshot' 4 | import { fileSystem } from '@japa/file-system' 5 | import { expectTypeOf } from '@japa/expect-type' 6 | import { configure, processCLIArgs, run } from '@japa/runner' 7 | 8 | processCLIArgs(process.argv.splice(2)) 9 | configure({ 10 | files: ['tests/**/*.spec.ts'], 11 | plugins: [assert(), expectTypeOf(), snapshot(), fileSystem()], 12 | }) 13 | 14 | run() 15 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Emitter } from '@adonisjs/core/events' 11 | import { AppFactory } from '@adonisjs/core/factories/app' 12 | import type { BouncerEvents } from '../src/types.ts' 13 | 14 | const BASE_URL = new URL('./tmp', import.meta.url) 15 | 16 | export const createEmitter = () => { 17 | return new Emitter(new AppFactory().create(BASE_URL, () => {})) 18 | } 19 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncerq 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { debuglog } from 'node:util' 11 | 12 | /** 13 | * Debug logger instance for the AdonisJS bouncer package. 14 | * This provides a namespaced debug logger that can be controlled 15 | * via the DEBUG environment variable. 16 | * 17 | * @example 18 | * ```js 19 | * debug('executing ability "%s"', abilityName) 20 | * ``` 21 | */ 22 | export default debuglog('adonisjs:bouncer') 23 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export * as errors from './src/errors.ts' 11 | export { Bouncer } from './src/bouncer.ts' 12 | export { configure } from './configure.ts' 13 | export { stubsRoot } from './stubs/main.ts' 14 | export { BasePolicy } from './src/base_policy.ts' 15 | export { AuthorizationResponse } from './src/response.ts' 16 | export { action, allowGuest } from './src/decorators/action.ts' 17 | export { indexPolicies } from './src/assembler_hooks/index_policies.ts' 18 | -------------------------------------------------------------------------------- /stubs/abilities.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('app/abilities/main.ts') }) 3 | }}} 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Bouncer abilities 7 | |-------------------------------------------------------------------------- 8 | | 9 | | You may export multiple abilities from this file and pre-register them 10 | | when creating the Bouncer instance. 11 | | 12 | | Pre-registered policies and abilities can be referenced as a string by their 13 | | name. Also they are must if want to perform authorization inside Edge 14 | | templates. 15 | | 16 | */ 17 | 18 | import { Bouncer } from '@adonisjs/bouncer' 19 | 20 | /** 21 | * Delete the following ability to start from 22 | * scratch 23 | */ 24 | export const editUser = Bouncer.ability(() => { 25 | return true 26 | }) 27 | -------------------------------------------------------------------------------- /stubs/make/policy/main.stub: -------------------------------------------------------------------------------- 1 | {{#var policyName = generators.policyName(entity.name)}} 2 | {{#var policyFileName = generators.policyFileName(entity.name)}} 3 | {{#var modelName = generators.modelName(model.name)}} 4 | {{#var modelFileName = generators.modelFileName(model.name)}} 5 | {{#var modelImportPath = generators.importPath('#models', model.path, modelFileName.replace(/\.ts$/, ''))}} 6 | {{{ 7 | exports({ 8 | to: app.policiesPath(entity.path, policyFileName) 9 | }) 10 | }}} 11 | import User from '#models/user' 12 | import {{modelName}} from '{{modelImportPath}}' 13 | import { BasePolicy } from '@adonisjs/bouncer' 14 | import type { AuthorizerResponse } from '@adonisjs/bouncer/types' 15 | 16 | export default class {{ policyName }} extends BasePolicy { 17 | {{#each actions as action}} 18 | {{action}}(user: User): AuthorizerResponse { 19 | return false 20 | } 21 | {{/each}} 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 0 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still need help on this issue' 13 | stale-pr-message: 'This pull request has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still intend to submit this pull request' 14 | close-issue-message: 'This issue has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still need help on this issue' 15 | close-pr-message: 'This pull request has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still intend to submit this pull request' 16 | days-before-stale: 21 17 | days-before-close: 5 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2022 Harminder Virk, contributors 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | permissions: 4 | contents: write 5 | id-token: write 6 | jobs: 7 | checks: 8 | uses: ./.github/workflows/checks.yml 9 | release: 10 | needs: checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 24 20 | 21 | - name: git config 22 | run: | 23 | git config user.name "${GITHUB_ACTOR}" 24 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 25 | 26 | - name: Init npm config 27 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 28 | env: 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | 31 | - run: npm install 32 | 33 | - run: npm run release major -- --ci --preRelease=next 34 | env: 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /configure.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type Configure from '@adonisjs/core/commands/configure' 11 | import { stubsRoot } from './stubs/main.ts' 12 | 13 | /** 14 | * Configures the package 15 | */ 16 | export async function configure(command: Configure) { 17 | const codemods = await command.createCodemods() 18 | 19 | /** 20 | * Publish stubs to define abilities and collect 21 | * policies 22 | */ 23 | await codemods.makeUsingStub(stubsRoot, 'abilities.stub', {}) 24 | 25 | /** 26 | * Register provider 27 | */ 28 | await codemods.updateRcFile((rcFile) => { 29 | rcFile.addCommand('@adonisjs/bouncer/commands') 30 | rcFile.addProvider('@adonisjs/bouncer/bouncer_provider') 31 | }) 32 | 33 | /** 34 | * Publish and register middleware 35 | */ 36 | await codemods.makeUsingStub(stubsRoot, 'initialize_bouncer_middleware.stub', { 37 | entity: command.app.generators.createEntity('initialize_bouncer'), 38 | }) 39 | await codemods.registerMiddleware('router', [ 40 | { 41 | path: '#middleware/initialize_bouncer_middleware', 42 | }, 43 | ]) 44 | } 45 | -------------------------------------------------------------------------------- /tests/response.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { AuthorizationResponse } from '../src/response.ts' 12 | 13 | test.group('AuthorizationResponse', () => { 14 | test('create denied response', ({ assert }) => { 15 | const response = AuthorizationResponse.deny() 16 | assert.isUndefined(response.message) 17 | assert.isUndefined(response.status) 18 | }) 19 | 20 | test('create denied response with custom status code and message', ({ assert }) => { 21 | const response = AuthorizationResponse.deny('Post not found', 404) 22 | assert.equal(response.message, 'Post not found') 23 | assert.equal(response.status, 404) 24 | }) 25 | test('create denied response with translation', ({ assert }) => { 26 | const response = AuthorizationResponse.deny('Post not found', 404).t('errors.not_found') 27 | assert.equal(response.message, 'Post not found') 28 | assert.equal(response.status, 404) 29 | assert.deepEqual(response.translation, { identifier: 'errors.not_found', data: undefined }) 30 | }) 31 | 32 | test('create allowed response', ({ assert }) => { 33 | const response = AuthorizationResponse.allow() 34 | assert.isUndefined(response.message) 35 | assert.isUndefined(response.status) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /stubs/initialize_bouncer_middleware.stub: -------------------------------------------------------------------------------- 1 | {{#var middlewareName = generators.middlewareName(entity.name)}} 2 | {{#var middlewareFileName = generators.middlewareFileName(entity.name)}} 3 | {{{ 4 | exports({ to: app.middlewarePath(entity.path, middlewareFileName) }) 5 | }}} 6 | import * as abilities from '#abilities/main' 7 | import { policies } from '#generated/policies' 8 | 9 | import { Bouncer } from '@adonisjs/bouncer' 10 | import type { HttpContext } from '@adonisjs/core/http' 11 | import type { NextFn } from '@adonisjs/core/types/http' 12 | 13 | /** 14 | * Init bouncer middleware is used to create a bouncer instance 15 | * during an HTTP request 16 | */ 17 | export default class {{ middlewareName }} { 18 | async handle(ctx: HttpContext, next: NextFn) { 19 | /** 20 | * Create bouncer instance for the ongoing HTTP request. 21 | * We will pull the user from the HTTP context. 22 | */ 23 | ctx.bouncer = new Bouncer( 24 | () => ctx.auth.user || null, 25 | abilities, 26 | policies 27 | ).setContainerResolver(ctx.containerResolver) 28 | 29 | /** 30 | * Share bouncer helpers with Edge templates. 31 | */ 32 | if ('view' in ctx) { 33 | ctx.view.share(ctx.bouncer.edgeHelpers) 34 | } 35 | 36 | return next() 37 | } 38 | } 39 | 40 | declare module '@adonisjs/core/http' { 41 | export interface HttpContext { 42 | bouncer: Bouncer< 43 | Exclude, 44 | typeof abilities, 45 | typeof policies 46 | > 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/decorators/action.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { BasePolicy } from '../base_policy.ts' 11 | 12 | /** 13 | * Define bouncer action metadata on a policy class method. 14 | * This decorator allows configuring how policy methods behave, 15 | * particularly regarding guest access. 16 | * 17 | * @param options Configuration options for the policy action 18 | * 19 | * @example 20 | * ```js 21 | * class PostPolicy extends BasePolicy { 22 | * @action({ allowGuest: true }) 23 | * view(user, post) { 24 | * return post.isPublished 25 | * } 26 | * } 27 | * ``` 28 | */ 29 | export function action(options: { allowGuest: boolean }) { 30 | /** 31 | * Decorator function that applies action metadata to policy methods 32 | * 33 | * @param target Policy instance 34 | * @param property Method name being decorated 35 | */ 36 | return function (target: BasePolicy, property: string) { 37 | const Policy = target.constructor as typeof BasePolicy 38 | Policy.boot() 39 | Policy.setActionMetaData(property, options) 40 | } 41 | } 42 | 43 | /** 44 | * Allow guests on a policy action. This is a convenience decorator 45 | * that applies the action decorator with allowGuest set to true. 46 | * 47 | * @example 48 | * ```js 49 | * class PostPolicy extends BasePolicy { 50 | * @allowGuest() 51 | * view(user, post) { 52 | * return post.isPublished 53 | * } 54 | * } 55 | * ``` 56 | */ 57 | export function allowGuest() { 58 | return action({ allowGuest: true }) 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @adonisjs/bouncer 2 | 3 |
4 | 5 | [![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] 6 | 7 | ## Introduction 8 | AdonisJS bouncer provides JavaScript first API to implementation authorization checks in AdonisJS applications. 9 | 10 | ## Official Documentation 11 | The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/authorization) 12 | 13 | ## Contributing 14 | One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework. 15 | 16 | We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework. 17 | 18 | ## Code of Conduct 19 | In order to ensure that the AdonisJS community is welcoming to all, please review and abide by the [Code of Conduct](https://github.com/adonisjs/.github/blob/main/docs/CODE_OF_CONDUCT.md). 20 | 21 | ## License 22 | AdonisJS bouncer is open-sourced software licensed under the [MIT license](LICENSE.md). 23 | 24 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/bouncer/checks.yml?style=for-the-badge 25 | [gh-workflow-url]: https://github.com/adonisjs/bouncer/actions/workflows/checks.yml "Github action" 26 | 27 | [npm-image]: https://img.shields.io/npm/v/@adonisjs/bouncer/latest.svg?style=for-the-badge&logo=npm 28 | [npm-url]: https://www.npmjs.com/package/@adonisjs/bouncer/v/latest "npm" 29 | 30 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 31 | 32 | [license-url]: LICENSE.md 33 | [license-image]: https://img.shields.io/github/license/adonisjs/bouncer?style=for-the-badge 34 | -------------------------------------------------------------------------------- /src/base_policy.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { defineStaticProperty } from '@poppinss/utils' 11 | 12 | /** 13 | * Base policy to define custom bouncer policies. All policies should 14 | * extend this class to inherit the action metadata system. 15 | * 16 | * @example 17 | * ```js 18 | * class PostPolicy extends BasePolicy { 19 | * @allowGuest() 20 | * view(user, post) { 21 | * return post.isPublished 22 | * } 23 | * 24 | * edit(user, post) { 25 | * return user.id === post.authorId 26 | * } 27 | * } 28 | * ``` 29 | */ 30 | export abstract class BasePolicy { 31 | /** 32 | * Whether the policy class has been booted 33 | */ 34 | static booted: boolean = false 35 | 36 | /** 37 | * Metadata for policy actions, including guest access permissions 38 | */ 39 | static actionsMetaData: Record = {} 40 | 41 | /** 42 | * Initialize the policy class and set up action metadata inheritance 43 | */ 44 | static boot() { 45 | if (!this.hasOwnProperty('booted')) { 46 | this.booted = false 47 | } 48 | if (this.booted === false) { 49 | this.booted = true 50 | defineStaticProperty(this, 'actionsMetaData', { initialValue: {}, strategy: 'inherit' }) 51 | } 52 | } 53 | 54 | /** 55 | * Set metadata for a action name 56 | * 57 | * @param actionName Name of the policy action method 58 | * @param options Configuration options for the action 59 | */ 60 | static setActionMetaData(actionName: string, options: { allowGuest: boolean }) { 61 | this.boot() 62 | this.actionsMetaData[actionName] = options 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /providers/bouncer_provider.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { ApplicationService } from '@adonisjs/core/types' 11 | 12 | import { Bouncer } from '../src/bouncer.ts' 13 | import type { BouncerEvents } from '../src/types.ts' 14 | 15 | declare module '@adonisjs/core/types' { 16 | export interface EventsList extends BouncerEvents {} 17 | } 18 | 19 | /** 20 | * AdonisJS service provider for the Bouncer package. This provider handles 21 | * the registration of Edge template tags and configures the event emitter 22 | * for authorization events. 23 | * 24 | * The provider automatically: 25 | * - Registers @can and @cannot Edge template tags when Edge.js is available 26 | * - Configures the Bouncer to use the application's event emitter 27 | * 28 | * @example 29 | * ```js 30 | * // The provider is automatically registered in AdonisJS 31 | * // No manual setup required - it runs during application boot 32 | * ``` 33 | */ 34 | export default class BouncerProvider { 35 | /** 36 | * Create a new BouncerProvider instance 37 | * 38 | * @param app AdonisJS application service instance 39 | */ 40 | constructor(protected app: ApplicationService) {} 41 | 42 | /** 43 | * Boot the bouncer provider. This method is called during application 44 | * startup and handles the registration of Edge plugins and event emitter 45 | * configuration. 46 | * 47 | * @example 48 | * ```js 49 | * // Called automatically by AdonisJS during boot process 50 | * await provider.boot() 51 | * ``` 52 | */ 53 | async boot() { 54 | if (this.app.usingEdgeJS) { 55 | const edge = await import('edge.js') 56 | const { edgePluginBouncer } = await import('../src/plugins/edge.js') 57 | edge.default.use(edgePluginBouncer) 58 | } 59 | 60 | Bouncer.emitter = await this.app.container.make('emitter') 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/configure.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/session 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { fileURLToPath } from 'node:url' 12 | import { IgnitorFactory } from '@adonisjs/core/factories' 13 | import Configure from '@adonisjs/core/commands/configure' 14 | 15 | import { stubsRoot } from '../stubs/main.ts' 16 | const BASE_URL = new URL('./tmp/', import.meta.url) 17 | 18 | test.group('Configure', (group) => { 19 | group.each.setup(({ context }) => { 20 | context.fs.baseUrl = BASE_URL 21 | context.fs.basePath = fileURLToPath(BASE_URL) 22 | }) 23 | 24 | test('register provider and publish stubs', async ({ fs, assert }) => { 25 | const ignitor = new IgnitorFactory() 26 | .withCoreProviders() 27 | .withCoreConfig() 28 | .create(BASE_URL, { 29 | importer: (filePath) => { 30 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 31 | return import(new URL(filePath, BASE_URL).href) 32 | } 33 | 34 | return import(filePath) 35 | }, 36 | }) 37 | 38 | await fs.createJson('tsconfig.json', {}) 39 | await fs.create('start/kernel.ts', `router.use([])`) 40 | await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) 41 | 42 | const app = ignitor.createApp('web') 43 | await app.init() 44 | await app.boot() 45 | 46 | const ace = await app.container.make('ace') 47 | const command = await ace.create(Configure, ['../../index.js']) 48 | await command.exec() 49 | 50 | const stubsManager = await app.stubs.create() 51 | const abilitiesStub = await stubsManager 52 | .build('abilities.stub', { source: stubsRoot }) 53 | .then((stub) => stub.prepare({})) 54 | 55 | await assert.fileContains('adonisrc.ts', '@adonisjs/bouncer/commands') 56 | await assert.fileContains('adonisrc.ts', '@adonisjs/bouncer/bouncer_provider') 57 | await assert.fileContains('app/abilities/main.ts', abilitiesStub.contents) 58 | await assert.fileContains( 59 | 'app/middleware/initialize_bouncer_middleware.ts', 60 | `export default class InitializeBouncerMiddleware {` 61 | ) 62 | }).disableTimeout() 63 | }) 64 | -------------------------------------------------------------------------------- /src/abilities_builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { ability } from './ability.ts' 11 | import { type AuthorizerResponse, type BouncerAbility, type BouncerAuthorizer } from './types.ts' 12 | 13 | /** 14 | * Abilities builder exposes a chainable API to fluently create an object 15 | * of abilities by chaining the ".define" method. This provides a fluent 16 | * interface for building collections of authorization abilities. 17 | * 18 | * @example 19 | * ```js 20 | * const abilities = new AbilitiesBuilder({}) 21 | * .define('editPost', (user, post) => user.id === post.authorId) 22 | * .define('viewPost', (user, post) => post.isPublished, { allowGuest: true }) 23 | * ``` 24 | */ 25 | export class AbilitiesBuilder>> { 26 | /** 27 | * Create a new AbilitiesBuilder instance 28 | * 29 | * @param abilities Initial abilities object to build upon 30 | */ 31 | constructor(public abilities: Abilities) {} 32 | 33 | /** 34 | * Helper to convert a user defined authorizer function to a bouncer ability 35 | * 36 | * @param name Unique name for the ability 37 | * @param authorizer Authorization function 38 | * @param options Optional configuration for the ability 39 | * 40 | * @example 41 | * ```js 42 | * builder.define('editPost', (user, post) => user.id === post.authorId) 43 | * ``` 44 | */ 45 | define>( 46 | name: Name, 47 | authorizer: Authorizer, 48 | options?: { allowGuest: boolean } 49 | ) { 50 | this.abilities[name] = ability(options || { allowGuest: false }, authorizer) as any 51 | 52 | return this as unknown as AbilitiesBuilder< 53 | Abilities & { 54 | [K in Name]: Authorizer extends ( 55 | user: infer User, 56 | ...args: infer Args 57 | ) => AuthorizerResponse | Promise 58 | ? { 59 | allowGuest: false 60 | original: Authorizer 61 | execute(user: User | null, ...args: Args): ReturnType 62 | } 63 | : never 64 | } 65 | > 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/response.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /** 11 | * Represents the response from an authorization check, containing 12 | * information about whether access was granted or denied. 13 | * 14 | * @example 15 | * ```js 16 | * const response = AuthorizationResponse.deny('Access denied', 403) 17 | * response.t('errors.forbidden', { resource: 'post' }) 18 | * ``` 19 | */ 20 | export class AuthorizationResponse { 21 | /** 22 | * Create a deny response 23 | * 24 | * @param message Optional message explaining why access was denied 25 | * @param statusCode Optional HTTP status code for the response 26 | * 27 | * @example 28 | * ```js 29 | * AuthorizationResponse.deny('Insufficient permissions', 403) 30 | * ``` 31 | */ 32 | static deny(message?: string, statusCode?: number) { 33 | const response = new AuthorizationResponse(false) 34 | response.message = message 35 | response.status = statusCode 36 | return response 37 | } 38 | 39 | /** 40 | * Create an allowed response 41 | * 42 | * @example 43 | * ```js 44 | * AuthorizationResponse.allow() 45 | * ``` 46 | */ 47 | static allow() { 48 | return new AuthorizationResponse(true) 49 | } 50 | 51 | /** 52 | * HTTP status for the authorization response 53 | */ 54 | declare status?: number 55 | 56 | /** 57 | * Response message 58 | */ 59 | declare message?: string 60 | 61 | /** 62 | * Translation identifier to use for creating the 63 | * authorization response 64 | */ 65 | declare translation?: { 66 | identifier: string 67 | data?: Record 68 | } 69 | 70 | /** 71 | * Whether the authorization was successful 72 | */ 73 | constructor(public authorized: boolean) {} 74 | 75 | /** 76 | * Define the translation identifier for the authorization response 77 | * 78 | * @param identifier Translation key to use for internationalization 79 | * @param data Optional data to pass to the translation function 80 | * 81 | * @example 82 | * ```js 83 | * response.t('errors.access_denied', { resource: 'posts' }) 84 | * ``` 85 | */ 86 | t(identifier: string, data?: Record) { 87 | this.translation = { identifier, data } 88 | return this 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/ability.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { AuthorizationResponse } from './response.ts' 11 | import { type AuthorizerResponse, type BouncerAbility, type BouncerAuthorizer } from './types.ts' 12 | 13 | type AuthorizerToAbility = Authorizer extends ( 14 | user: infer User, 15 | ...args: infer Args 16 | ) => AuthorizerResponse | Promise 17 | ? { 18 | allowGuest: false 19 | original: Authorizer 20 | execute(user: User | null, ...args: Args): AuthorizerResponse | Promise 21 | } 22 | : never 23 | 24 | /** 25 | * Helper to convert a user defined authorizer function to a bouncer ability. 26 | * The ability function wraps authorization logic and handles guest access control. 27 | * 28 | * @param options Configuration options for the ability 29 | * @param authorizer Authorization function to wrap 30 | * 31 | * @example 32 | * ```js 33 | * const editPost = ability((user, post) => { 34 | * return user.id === post.authorId 35 | * }) 36 | * 37 | * const viewPost = ability({ allowGuest: true }, (user, post) => { 38 | * return post.isPublished || (user && user.id === post.authorId) 39 | * }) 40 | * ``` 41 | */ 42 | export function ability>( 43 | options: { allowGuest: boolean }, 44 | authorizer: Authorizer 45 | ): AuthorizerToAbility 46 | export function ability>( 47 | authorizer: Authorizer 48 | ): AuthorizerToAbility 49 | export function ability>( 50 | authorizerOrOptions: Authorizer | { allowGuest: boolean }, 51 | authorizer?: Authorizer 52 | ) { 53 | if (typeof authorizerOrOptions === 'function') { 54 | return { 55 | allowGuest: false, 56 | original: authorizerOrOptions, 57 | execute(user, ...args) { 58 | if (user === null && !this.allowGuest) { 59 | return AuthorizationResponse.deny() 60 | } 61 | return this.original(user, ...args) 62 | }, 63 | } satisfies BouncerAbility 64 | } else { 65 | return { 66 | allowGuest: authorizerOrOptions?.allowGuest || false, 67 | original: authorizer!, 68 | execute(user, ...args) { 69 | if (user === null && !this.allowGuest) { 70 | return AuthorizationResponse.deny() 71 | } 72 | return this.original(user, ...args) 73 | }, 74 | } satisfies BouncerAbility 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/index_policies.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { Kernel } from '@adonisjs/core/ace' 12 | import stringHelpers from '@adonisjs/core/helpers/string' 13 | import { IndexGenerator } from '@adonisjs/assembler/index_generator' 14 | import { indexPolicies } from '../src/assembler_hooks/index_policies.ts' 15 | 16 | test.group('Index policies', () => { 17 | test('generate policies index', async ({ assert, fs }) => { 18 | const cliUi = Kernel.create().ui 19 | cliUi.switchMode('raw') 20 | 21 | await fs.create('app/policies/post_policy.ts', '') 22 | await fs.create('app/policies/post/published_policy.ts', '') 23 | 24 | const generator = new IndexGenerator(stringHelpers.toUnixSlash(fs.basePath), cliUi.logger) 25 | const indexer = indexPolicies() 26 | 27 | indexer.run({} as any, {} as any, generator) 28 | await generator.generate() 29 | 30 | await assert.fileExists('.adonisjs/server/policies.ts') 31 | await assert.fileContains('.adonisjs/server/policies.ts', [ 32 | `export const policies = {`, 33 | `PostPolicy: () => import('#policies/post_policy')`, 34 | `PostPublishedPolicy: () => import('#policies/post/published_policy')`, 35 | ]) 36 | assert.isDefined( 37 | cliUi.logger.getLogs().find(({ message }) => message.includes('.adonisjs/server/policies.ts')) 38 | ) 39 | }) 40 | 41 | test('generate policies index inside a DDD project', async ({ assert, fs }) => { 42 | const cliUi = Kernel.create().ui 43 | cliUi.switchMode('raw') 44 | 45 | await fs.create('app/posts/policies/post_policy.ts', '') 46 | await fs.create('app/users/policies/user_policy.ts', '') 47 | 48 | const generator = new IndexGenerator(stringHelpers.toUnixSlash(fs.basePath), cliUi.logger) 49 | const indexer = indexPolicies({ 50 | source: 'app', 51 | importAlias: '#app', 52 | glob: ['**/policies/**/*.ts'], 53 | }) 54 | 55 | indexer.run({} as any, {} as any, generator) 56 | await generator.generate() 57 | 58 | await assert.fileExists('.adonisjs/server/policies.ts') 59 | await assert.fileContains('.adonisjs/server/policies.ts', [ 60 | `export const policies = {`, 61 | `PostPolicy: () => import('#app/posts/policies/post_policy')`, 62 | `UserPolicy: () => import('#app/users/policies/user_policy')`, 63 | ]) 64 | assert.isDefined( 65 | cliUi.logger.getLogs().find(({ message }) => message.includes('.adonisjs/server/policies.ts')) 66 | ) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/assembler_hooks/index_policies.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { type CommonHooks } from '@adonisjs/assembler/types' 11 | import stringHelpers from '@adonisjs/core/helpers/string' 12 | 13 | export function indexPolicies(config?: { 14 | /** Source directory for policies */ 15 | source?: string 16 | /** Import alias for policies */ 17 | importAlias?: string 18 | /** Glob patterns for matching policies files */ 19 | glob?: string[] 20 | }) { 21 | const policies = Object.assign( 22 | { 23 | source: 'app/policies', 24 | importAlias: '#policies', 25 | withSharedProps: false, 26 | output: '.adonisjs/server/policies.ts', 27 | }, 28 | config 29 | ) 30 | 31 | return { 32 | run(_, __, indexGenerator) { 33 | indexGenerator.add('policies', { 34 | source: policies.source, 35 | glob: policies.glob, 36 | importAlias: policies.importAlias, 37 | output: policies.output, 38 | as(vfs, buffer, ___, helpers) { 39 | buffer.write('export const policies = {').indent() 40 | 41 | const policiesFilesList = vfs.asList() 42 | Object.keys(policiesFilesList).forEach((key) => { 43 | /** 44 | * Policy name segments without the policy suffix. 45 | */ 46 | const policyNameSegments = stringHelpers 47 | .create(key) 48 | .removeSuffix('policy') 49 | .toString() 50 | .split('/') 51 | .filter((segment) => segment !== 'policies') 52 | 53 | const policyName = policyNameSegments[policyNameSegments.length - 1] 54 | const parentDir = policyNameSegments[policyNameSegments.length - 2] 55 | 56 | /** 57 | * When the policy name and its parent folder has the same name, 58 | * we skip the parent name from the output key. 59 | */ 60 | if ( 61 | parentDir && 62 | (parentDir === policyName || stringHelpers.plural(policyName) === parentDir) 63 | ) { 64 | policyNameSegments.splice(-2, 1) 65 | } 66 | 67 | policyNameSegments.push('policy') 68 | buffer.write( 69 | `${stringHelpers.pascalCase(policyNameSegments.join('/'))}: () => import('${helpers.toImportPath(policiesFilesList[key])}'),` 70 | ) 71 | }) 72 | buffer.dedent().writeLine('}') 73 | }, 74 | }) 75 | }, 76 | } satisfies CommonHooks['init'][number] 77 | } 78 | -------------------------------------------------------------------------------- /commands/make_policy.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@adonisjs/core/helpers/string' 11 | import { BaseCommand, args, flags } from '@adonisjs/core/ace' 12 | import type { CommandOptions } from '@adonisjs/core/types/ace' 13 | 14 | import { stubsRoot } from '../stubs/main.ts' 15 | 16 | /** 17 | * AdonisJS Ace command for generating bouncer policy classes. This command 18 | * creates new policy files with optional method stubs. 19 | * 20 | * @example 21 | * ```sh 22 | * node ace make:policy PostPolicy 23 | * node ace make:policy UserPolicy view edit delete --model=User 24 | * node ace make:policy PostPolicy 25 | * ``` 26 | */ 27 | export default class MakePolicy extends BaseCommand { 28 | /** 29 | * Command name used to invoke this command 30 | */ 31 | static commandName = 'make:policy' 32 | 33 | /** 34 | * Human-readable description of the command 35 | */ 36 | static description = 'Make a new bouncer policy class' 37 | 38 | /** 39 | * Command configuration options 40 | */ 41 | static options: CommandOptions = { 42 | allowUnknownFlags: true, 43 | } 44 | 45 | /** 46 | * The name of the policy file to create 47 | */ 48 | @args.string({ description: 'Name of the policy file' }) 49 | declare name: string 50 | 51 | /** 52 | * Optional array of method names to pre-define on the policy class 53 | */ 54 | @args.spread({ description: 'Method names to pre-define on the policy', required: false }) 55 | declare actions?: string[] 56 | 57 | /** 58 | * The model name for which to generate the policy 59 | */ 60 | @flags.string({ description: 'The name of the policy model' }) 61 | declare model?: string 62 | 63 | /** 64 | * Execute the make:policy command. This method handles the entire 65 | * policy generation workflow including prompting for registration, 66 | * creating the policy file from a stub, and optionally registering 67 | * the policy in the main policies file. 68 | * 69 | * @example 70 | * ```bash 71 | * # Creates PostPolicy.ts with view, edit, delete methods 72 | * node ace make:policy PostPolicy view edit delete 73 | * 74 | * # Creates UserPolicy.ts without auto-registration 75 | * node ace make:policy UserPolicy --no-register 76 | * ``` 77 | */ 78 | async run(): Promise { 79 | const codemods = await this.createCodemods() 80 | await codemods.makeUsingStub(stubsRoot, 'make/policy/main.stub', { 81 | flags: this.parsed.flags, 82 | actions: this.actions?.map((action) => string.camelCase(action)) || [], 83 | entity: this.app.generators.createEntity(this.name), 84 | model: this.app.generators.createEntity(this.model || this.name), 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/plugins/edge.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { PluginFn } from 'edge.js/types' 11 | import debug from '../debug.ts' 12 | 13 | /** 14 | * The edge plugin for Bouncer to perform authorization checks 15 | * within templates. This plugin registers @can and @cannot tags 16 | * for conditional rendering based on user permissions. 17 | * 18 | * @example 19 | * ```edge 20 | * @can('editPost', post) 21 | * 22 | * @end 23 | * 24 | * @cannot('PostPolicy.delete', post) 25 | *

You cannot delete this post

26 | * @end 27 | * ``` 28 | */ 29 | export const edgePluginBouncer: PluginFn = (edge) => { 30 | debug('registering bouncer tags with edge') 31 | 32 | edge.registerTag({ 33 | tagName: 'can', 34 | seekable: true, 35 | block: true, 36 | compile(parser, buffer, token) { 37 | const expression = parser.utils.transformAst( 38 | parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), 39 | token.filename, 40 | parser 41 | ) 42 | 43 | const openingBrace = expression.type !== 'SequenceExpression' ? '(' : '' 44 | const closingBrace = expression.type !== 'SequenceExpression' ? ')' : '' 45 | const parameters = parser.utils.stringify(expression) 46 | const methodCall = `can${openingBrace}${parameters}${closingBrace}` 47 | 48 | /** 49 | * Write an if statement 50 | */ 51 | buffer.writeStatement( 52 | `if (await state.bouncer.${methodCall}) {`, 53 | token.filename, 54 | token.loc.start.line 55 | ) 56 | 57 | /** 58 | * Process component children using the parser 59 | */ 60 | token.children.forEach((child) => { 61 | parser.processToken(child, buffer) 62 | }) 63 | 64 | /** 65 | * Close if statement 66 | */ 67 | buffer.writeStatement(`}`, token.filename, token.loc.start.line) 68 | }, 69 | }) 70 | 71 | edge.registerTag({ 72 | tagName: 'cannot', 73 | seekable: true, 74 | block: true, 75 | compile(parser, buffer, token) { 76 | const expression = parser.utils.transformAst( 77 | parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), 78 | token.filename, 79 | parser 80 | ) 81 | 82 | const openingBrace = expression.type !== 'SequenceExpression' ? '(' : '' 83 | const closingBrace = expression.type !== 'SequenceExpression' ? ')' : '' 84 | const parameters = parser.utils.stringify(expression) 85 | const methodCall = `cannot${openingBrace}${parameters}${closingBrace}` 86 | 87 | /** 88 | * Write an if statement 89 | */ 90 | buffer.writeStatement( 91 | `if (await state.bouncer.${methodCall}) {`, 92 | token.filename, 93 | token.loc.start.line 94 | ) 95 | 96 | /** 97 | * Process component children using the parser 98 | */ 99 | token.children.forEach((child) => { 100 | parser.processToken(child, buffer) 101 | }) 102 | 103 | /** 104 | * Close if statement 105 | */ 106 | buffer.writeStatement(`}`, token.filename, token.loc.start.line) 107 | }, 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /tests/commands/make_policy.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { AceFactory } from '@adonisjs/core/factories' 12 | import MakePolicy from '../../commands/make_policy.ts' 13 | 14 | test.group('MakePolicy', () => { 15 | test('make policy class using the stub', async ({ assert, fs }) => { 16 | await fs.createJson('tsconfig.json', {}) 17 | await fs.create('app/policies/main.ts', `export const policies = {}`) 18 | 19 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 20 | await ace.app.init() 21 | ace.ui.switchMode('raw') 22 | 23 | const command = await ace.create(MakePolicy, ['post']) 24 | await command.exec() 25 | command.assertSucceeded() 26 | 27 | command.assertLog('green(DONE:) create app/policies/post_policy.ts') 28 | 29 | await assert.fileContains('app/policies/post_policy.ts', [ 30 | `import Post from '#models/post'`, 31 | `import User from '#models/user'`, 32 | `import { BasePolicy } from '@adonisjs/bouncer'`, 33 | `import type { AuthorizerResponse } from '@adonisjs/bouncer/types'`, 34 | `export default class PostPolicy extends BasePolicy`, 35 | ]) 36 | }) 37 | 38 | test('make policy class inside nested directories', async ({ assert, fs }) => { 39 | await fs.createJson('tsconfig.json', {}) 40 | await fs.create('app/policies/main.ts', `export const policies = {}`) 41 | 42 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 43 | await ace.app.init() 44 | ace.ui.switchMode('raw') 45 | 46 | const command = await ace.create(MakePolicy, ['post/published', '--model=post']) 47 | command.prompt 48 | .trap('Do you want to register the policy inside the app/policies/main.ts file?') 49 | .accept() 50 | 51 | await command.exec() 52 | command.assertSucceeded() 53 | 54 | command.assertLog('green(DONE:) create app/policies/post/published_policy.ts') 55 | await assert.fileContains('app/policies/post/published_policy.ts', [ 56 | `import Post from '#models/post'`, 57 | `import User from '#models/user'`, 58 | `import { BasePolicy } from '@adonisjs/bouncer'`, 59 | `import type { AuthorizerResponse } from '@adonisjs/bouncer/types'`, 60 | `export default class PublishedPolicy extends BasePolicy`, 61 | ]) 62 | }) 63 | 64 | test('define policy with actions', async ({ assert, fs }) => { 65 | await fs.createJson('tsconfig.json', {}) 66 | await fs.create('app/policies/main.ts', `export const policies = {}`) 67 | 68 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 69 | await ace.app.init() 70 | ace.ui.switchMode('raw') 71 | 72 | const command = await ace.create(MakePolicy, ['post', 'view', 'edit', 'delete']) 73 | command.prompt 74 | .trap('Do you want to register the policy inside the app/policies/main.ts file?') 75 | .accept() 76 | 77 | await command.exec() 78 | command.assertSucceeded() 79 | 80 | command.assertLog('green(DONE:) create app/policies/post_policy.ts') 81 | await assert.fileContains('app/policies/post_policy.ts', [ 82 | `import Post from '#models/post'`, 83 | `import User from '#models/user'`, 84 | `view(user: User): AuthorizerResponse`, 85 | `edit(user: User): AuthorizerResponse`, 86 | `delete(user: User): AuthorizerResponse`, 87 | ]) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /tests/abilities_builder.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { Bouncer } from '../src/bouncer.ts' 12 | 13 | test.group('AbilitiesBuilder', () => { 14 | test('define abilities using abilities builder', ({ assert, expectTypeOf }) => { 15 | class User { 16 | declare email: string 17 | constructor(public id: number) {} 18 | } 19 | class Post { 20 | constructor(public userId: number) {} 21 | } 22 | 23 | const { abilities } = Bouncer.define('editPost', (user: User, post: Post) => { 24 | return user.id === post.userId 25 | }) 26 | .define('deletePost', (user: User, post: Post) => { 27 | return user.id === post.userId 28 | }) 29 | .define('createPost', () => { 30 | return true 31 | }) 32 | 33 | assert.properties(abilities, ['editPost', 'deletePost', 'createPost']) 34 | expectTypeOf(abilities).toEqualTypeOf< 35 | { 36 | editPost: { 37 | allowGuest: false 38 | original: (user: User, post: Post) => boolean 39 | execute(user: User | null, post: Post): boolean 40 | } 41 | } & { 42 | deletePost: { 43 | allowGuest: false 44 | original: (user: User, post: Post) => boolean 45 | execute(user: User | null, post: Post): boolean 46 | } 47 | } & { 48 | createPost: { 49 | allowGuest: false 50 | original: () => true 51 | execute(user: unknown): true 52 | } 53 | } 54 | >() 55 | }) 56 | 57 | test('authorize using abilities created using the abilities builder', async ({ assert }) => { 58 | class User { 59 | declare email: string 60 | constructor(public id: number) {} 61 | } 62 | class Post { 63 | constructor(public userId: number) {} 64 | } 65 | 66 | const { abilities } = Bouncer.define('editPost', (user: User, post: Post) => { 67 | return user.id === post.userId 68 | }) 69 | .define('deletePost', (user: User, post: Post) => { 70 | return user.id === post.userId 71 | }) 72 | .define('createPost', () => { 73 | return true 74 | }) 75 | 76 | const bouncer = new Bouncer(new User(1)) 77 | assert.isTrue(await bouncer.allows(abilities.createPost)) 78 | assert.isTrue(await bouncer.allows(abilities.editPost, new Post(1))) 79 | assert.isTrue(await bouncer.allows(abilities.deletePost, new Post(1))) 80 | 81 | assert.isTrue(await bouncer.denies(abilities.editPost, new Post(2))) 82 | assert.isTrue(await bouncer.denies(abilities.deletePost, new Post(2))) 83 | }) 84 | 85 | test('authorize by pre-registering builder abilites', async ({ assert }) => { 86 | class User { 87 | declare email: string 88 | constructor(public id: number) {} 89 | } 90 | class Post { 91 | constructor(public userId: number) {} 92 | } 93 | 94 | const { abilities } = Bouncer.define('editPost', (user: User, post: Post) => { 95 | return user.id === post.userId 96 | }) 97 | .define('deletePost', (user: User, post: Post) => { 98 | return user.id === post.userId 99 | }) 100 | .define('createPost', () => { 101 | return true 102 | }) 103 | 104 | const bouncer = new Bouncer(new User(1), abilities) 105 | assert.isTrue(await bouncer.allows('createPost')) 106 | assert.isTrue(await bouncer.allows('editPost', new Post(1))) 107 | assert.isTrue(await bouncer.allows('deletePost', new Post(1))) 108 | 109 | assert.isTrue(await bouncer.denies('editPost', new Post(2))) 110 | assert.isTrue(await bouncer.denies('deletePost', new Post(2))) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | 12 | import type { I18n } from '@adonisjs/i18n' 13 | import { Exception } from '@adonisjs/core/exceptions' 14 | import type { HttpContext } from '@adonisjs/core/http' 15 | import type { AuthorizationResponse } from './response.ts' 16 | 17 | /** 18 | * AuthorizationException is raised by bouncer when an ability or 19 | * policy denies access to a user for a given resource. This exception 20 | * provides rich error information including status codes and internationalization support. 21 | * 22 | * @example 23 | * ```js 24 | * throw new E_AUTHORIZATION_FAILURE(response) 25 | * ``` 26 | */ 27 | class AuthorizationException extends Exception { 28 | formSubmissionMethods = ['POST', 'PUT', 'PATCH', 'DELETE'] 29 | 30 | /** 31 | * Default error message 32 | */ 33 | message = 'Access denied' 34 | 35 | /** 36 | * Default HTTP status code 37 | */ 38 | status = 403 39 | 40 | /** 41 | * Error code identifier 42 | */ 43 | code = 'E_AUTHORIZATION_FAILURE' 44 | 45 | /** 46 | * Error identifier to lookup translation message 47 | */ 48 | identifier = 'errors.E_AUTHORIZATION_FAILURE' 49 | 50 | /** 51 | * Create a new AuthorizationException 52 | * 53 | * @param response Authorization response containing denial information 54 | * @param options Optional error configuration 55 | */ 56 | constructor( 57 | public response: AuthorizationResponse, 58 | options?: ErrorOptions & { 59 | code?: string 60 | status?: number 61 | } 62 | ) { 63 | super(response.message, options) 64 | } 65 | 66 | /** 67 | * Returns the message to be sent in the HTTP response. 68 | * Feel free to override this method and return a custom 69 | * response. 70 | * 71 | * @param ctx HTTP context for accessing i18n and other services 72 | */ 73 | getResponseMessage(ctx: HttpContext) { 74 | /** 75 | * Give preference to response message and then fallback 76 | * to error message 77 | */ 78 | const message = this.response.message || this.message 79 | 80 | /** 81 | * Use translation when using i18n package 82 | */ 83 | if ('i18n' in ctx) { 84 | /** 85 | * Give preference to response translation and fallback to static 86 | * identifier. 87 | */ 88 | const identifier = this.response.translation?.identifier || this.identifier 89 | const data = this.response.translation?.data || {} 90 | return (ctx.i18n as I18n).t(identifier, data, message) 91 | } 92 | 93 | return message 94 | } 95 | 96 | /** 97 | * Handle the authorization exception and send appropriate HTTP response 98 | * 99 | * @param _ The exception instance (not used) 100 | * @param ctx HTTP context for sending the response 101 | */ 102 | async handle(_: AuthorizationException, ctx: HttpContext) { 103 | const status = this.response.status || this.status 104 | const message = this.getResponseMessage(ctx) 105 | const performRedirect = this.formSubmissionMethods.includes(ctx.request.method()) 106 | 107 | switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { 108 | case 'html': 109 | case null: 110 | if (performRedirect && 'session' in ctx) { 111 | ctx.session.flashExcept([ 112 | '_csrf', 113 | '_method', 114 | 'password', 115 | 'password_confirmation', 116 | 'passwordConfirmation', 117 | ]) 118 | ctx.session.flash('error', message) 119 | ctx.session.flashErrors({ [this.code]: message }) 120 | ctx.response.redirect('back', true) 121 | return 122 | } 123 | ctx.response.status(status).send(message) 124 | break 125 | case 'json': 126 | ctx.response.status(status).send({ 127 | errors: [ 128 | { 129 | message, 130 | }, 131 | ], 132 | }) 133 | break 134 | case 'application/vnd.api+json': 135 | ctx.response.status(status).send({ 136 | errors: [ 137 | { 138 | code: this.code, 139 | title: message, 140 | }, 141 | ], 142 | }) 143 | break 144 | } 145 | } 146 | } 147 | 148 | export const E_AUTHORIZATION_FAILURE = AuthorizationException 149 | -------------------------------------------------------------------------------- /tests/authorization_exception.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { E_AUTHORIZATION_FAILURE } from '../src/errors.ts' 12 | import { AuthorizationResponse } from '../src/response.ts' 13 | import { I18nManagerFactory } from '@adonisjs/i18n/factories' 14 | import { HttpContextFactory } from '@adonisjs/core/factories/http' 15 | 16 | test.group('AuthorizationException', () => { 17 | test('make HTTP response with default message and status code', async ({ assert }) => { 18 | const exception = new E_AUTHORIZATION_FAILURE(new AuthorizationResponse(false)) 19 | const ctx = new HttpContextFactory().create() 20 | 21 | await exception.handle(exception, ctx) 22 | assert.equal(ctx.response.getBody(), 'Access denied') 23 | assert.equal(ctx.response.getStatus(), 403) 24 | }) 25 | 26 | test('make HTTP response with response error message and status code', async ({ assert }) => { 27 | const exception = new E_AUTHORIZATION_FAILURE(AuthorizationResponse.deny('Post not found', 404)) 28 | const ctx = new HttpContextFactory().create() 29 | 30 | await exception.handle(exception, ctx) 31 | assert.equal(ctx.response.getBody(), 'Post not found') 32 | assert.equal(ctx.response.getStatus(), 404) 33 | }) 34 | 35 | test('use default translation identifier for message when using i18n', async ({ assert }) => { 36 | const i18nManager = new I18nManagerFactory() 37 | .merge({ 38 | config: { 39 | loaders: [ 40 | () => { 41 | return { 42 | async load() { 43 | return { 44 | en: { 45 | 'errors.E_AUTHORIZATION_FAILURE': 'Access denied from translations', 46 | }, 47 | } 48 | }, 49 | } 50 | }, 51 | ], 52 | }, 53 | }) 54 | .create() 55 | 56 | await i18nManager.loadTranslations() 57 | 58 | const exception = new E_AUTHORIZATION_FAILURE(AuthorizationResponse.deny('Post not found', 404)) 59 | const ctx = new HttpContextFactory().create() 60 | ctx.i18n = i18nManager.locale('en') 61 | 62 | await exception.handle(exception, ctx) 63 | assert.equal(ctx.response.getBody(), 'Access denied from translations') 64 | assert.equal(ctx.response.getStatus(), 404) 65 | }) 66 | 67 | test('use response translation identifier for message when using i18n', async ({ assert }) => { 68 | const i18nManager = new I18nManagerFactory() 69 | .merge({ 70 | config: { 71 | loaders: [ 72 | () => { 73 | return { 74 | async load() { 75 | return { 76 | en: { 77 | 'errors.E_AUTHORIZATION_FAILURE': 'Access denied from translations', 78 | 'errors.not_found': 'Page not found', 79 | }, 80 | } 81 | }, 82 | } 83 | }, 84 | ], 85 | }, 86 | }) 87 | .create() 88 | 89 | await i18nManager.loadTranslations() 90 | 91 | const exception = new E_AUTHORIZATION_FAILURE( 92 | AuthorizationResponse.deny('Post not found', 404).t('errors.not_found') 93 | ) 94 | const ctx = new HttpContextFactory().create() 95 | ctx.i18n = i18nManager.locale('en') 96 | 97 | await exception.handle(exception, ctx) 98 | assert.equal(ctx.response.getBody(), 'Page not found') 99 | assert.equal(ctx.response.getStatus(), 404) 100 | }) 101 | 102 | test('get JSON response', async ({ assert }) => { 103 | const exception = new E_AUTHORIZATION_FAILURE(new AuthorizationResponse(false)) 104 | const ctx = new HttpContextFactory().create() 105 | ctx.request.request.headers.accept = 'application/json' 106 | 107 | await exception.handle(exception, ctx) 108 | assert.deepEqual(ctx.response.getBody(), { errors: [{ message: 'Access denied' }] }) 109 | assert.equal(ctx.response.getStatus(), 403) 110 | }) 111 | 112 | test('get JSONAPI response', async ({ assert }) => { 113 | const exception = new E_AUTHORIZATION_FAILURE(new AuthorizationResponse(false)) 114 | const ctx = new HttpContextFactory().create() 115 | ctx.request.request.headers.accept = 'application/vnd.api+json' 116 | 117 | await exception.handle(exception, ctx) 118 | assert.deepEqual(ctx.response.getBody(), { 119 | errors: [{ code: 'E_AUTHORIZATION_FAILURE', title: 'Access denied' }], 120 | }) 121 | assert.equal(ctx.response.getStatus(), 403) 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/bouncer", 3 | "version": "4.0.0-next.1", 4 | "description": "Authorization layer for AdonisJS", 5 | "engines": { 6 | "node": ">=24.0.0" 7 | }, 8 | "main": "build/index.js", 9 | "type": "module", 10 | "files": [ 11 | "build", 12 | "!build/bin", 13 | "!build/tests" 14 | ], 15 | "exports": { 16 | ".": "./build/index.js", 17 | "./types": "./build/src/types.js", 18 | "./commands": "./build/commands/main.js", 19 | "./bouncer_provider": "./build/providers/bouncer_provider.js", 20 | "./plugins/edge": "./build/src/plugins/edge.js" 21 | }, 22 | "scripts": { 23 | "pretest": "npm run lint", 24 | "test": "cross-env NODE_DEBUG=adonisjs:bouncer npm run quick:test", 25 | "lint": "eslint .", 26 | "format": "prettier --write .", 27 | "typecheck": "tsc --noEmit", 28 | "clean": "del-cli build", 29 | "copy:templates": "copyfiles \"stubs/**/*.stub\" --up=\"1\" build", 30 | "precompile": "npm run lint && npm run clean", 31 | "compile": "tsdown && tsc --emitDeclarationOnly --declaration", 32 | "postcompile": "npm run copy:templates && npm run index:commands", 33 | "build": "npm run compile", 34 | "version": "npm run build", 35 | "prepublishOnly": "npm run build", 36 | "release": "release-it", 37 | "index:commands": "adonis-kit index build/commands", 38 | "quick:test": "node --import=@poppinss/ts-exec --enable-source-maps bin/test.ts" 39 | }, 40 | "devDependencies": { 41 | "@adonisjs/assembler": "^8.0.0-next.26", 42 | "@adonisjs/core": "^7.0.0-next.16", 43 | "@adonisjs/eslint-config": "^3.0.0-next.5", 44 | "@adonisjs/i18n": "^3.0.0-next.2", 45 | "@adonisjs/prettier-config": "^1.4.5", 46 | "@adonisjs/session": "^8.0.0-next.1", 47 | "@adonisjs/tsconfig": "^2.0.0-next.3", 48 | "@japa/assert": "^4.2.0", 49 | "@japa/expect-type": "^2.0.4", 50 | "@japa/file-system": "^3.0.0", 51 | "@japa/runner": "^5.0.0", 52 | "@japa/snapshot": "^2.0.10", 53 | "@poppinss/ts-exec": "^1.4.1", 54 | "@release-it/conventional-changelog": "^10.0.4", 55 | "c8": "^10.1.3", 56 | "copyfiles": "^2.4.1", 57 | "cross-env": "^10.1.0", 58 | "del-cli": "^7.0.0", 59 | "edge.js": "^6.4.0", 60 | "eslint": "^9.39.2", 61 | "reflect-metadata": "^0.2.2", 62 | "release-it": "^19.1.0", 63 | "tsdown": "^0.18.1", 64 | "typescript": "^5.9.3" 65 | }, 66 | "dependencies": { 67 | "@poppinss/utils": "^7.0.0-next.4" 68 | }, 69 | "peerDependencies": { 70 | "@adonisjs/assembler": "^8.0.0-next.26", 71 | "@adonisjs/core": "^7.0.0-next.16", 72 | "@adonisjs/i18n": "^3.0.0-next.2", 73 | "edge.js": "^6.4.0" 74 | }, 75 | "peerDependenciesMeta": { 76 | "@adonisjs/assembler": { 77 | "optional": true 78 | }, 79 | "@adonisjs/i18n": { 80 | "optional": true 81 | }, 82 | "edge.js": { 83 | "optional": true 84 | } 85 | }, 86 | "homepage": "https://github.com/adonisjs/bouncer#readme", 87 | "repository": { 88 | "type": "git", 89 | "url": "git+https://github.com/adonisjs/bouncer.git" 90 | }, 91 | "bugs": { 92 | "url": "https://github.com/adonisjs/bouncer/issues" 93 | }, 94 | "keywords": [ 95 | "authorization", 96 | "adonisjs" 97 | ], 98 | "author": "Harminder Virk ", 99 | "contributors": [ 100 | "Julien Ripouteau " 101 | ], 102 | "license": "MIT", 103 | "publishConfig": { 104 | "access": "public", 105 | "provenance": true 106 | }, 107 | "tsdown": { 108 | "entry": [ 109 | "./index.ts", 110 | "./src/types.ts", 111 | "./providers/bouncer_provider.ts", 112 | "./commands/make_policy.ts", 113 | "./src/plugins/edge.ts" 114 | ], 115 | "outDir": "./build", 116 | "clean": true, 117 | "format": "esm", 118 | "minify": "dce-only", 119 | "fixedExtension": false, 120 | "dts": false, 121 | "treeshake": false, 122 | "sourcemaps": false, 123 | "target": "esnext" 124 | }, 125 | "release-it": { 126 | "git": { 127 | "requireCleanWorkingDir": true, 128 | "requireUpstream": true, 129 | "commitMessage": "chore(release): ${version}", 130 | "tagAnnotation": "v${version}", 131 | "push": true, 132 | "tagName": "v${version}" 133 | }, 134 | "github": { 135 | "release": true 136 | }, 137 | "npm": { 138 | "publish": true, 139 | "skipChecks": true 140 | }, 141 | "plugins": { 142 | "@release-it/conventional-changelog": { 143 | "preset": { 144 | "name": "angular" 145 | } 146 | } 147 | } 148 | }, 149 | "c8": { 150 | "reporter": [ 151 | "text", 152 | "html" 153 | ], 154 | "exclude": [ 155 | "tests/**" 156 | ] 157 | }, 158 | "prettier": "@adonisjs/prettier-config" 159 | } 160 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { AuthorizationResponse } from './response.ts' 11 | 12 | /** 13 | * Returns a list of methods from a policy class that could be 14 | * used with a specific bouncer instance for a given user. This utility 15 | * type filters policy methods to only include those that match the 16 | * BouncerAuthorizer signature. 17 | * 18 | * @template User The user type for the bouncer instance 19 | * @template Policy The policy class type 20 | * 21 | * @example 22 | * ```typescript 23 | * class PostPolicy { 24 | * view(user: User, post: Post) { return true } 25 | * edit(user: User, post: Post) { return user.id === post.authorId } 26 | * nonAuthMethod() { return 'not an auth method' } 27 | * } 28 | * 29 | * // Only 'view' and 'edit' will be included 30 | * type Methods = GetPolicyMethods 31 | * ``` 32 | */ 33 | export type GetPolicyMethods = { 34 | [K in keyof Policy]: Policy[K] extends BouncerAuthorizer ? K : never 35 | }[keyof Policy] 36 | 37 | /** 38 | * Narrowing the list of abilities that can be used for 39 | * a specific bouncer instance for a given user. This utility type 40 | * filters abilities to only include those that are compatible with 41 | * the specified user type. 42 | * 43 | * @template User The user type for the bouncer instance 44 | * @template Abilities The abilities record type 45 | * 46 | * @example 47 | * ```typescript 48 | * const abilities = { 49 | * editPost: ability((user: User, post: Post) => user.id === post.authorId), 50 | * viewPublicPost: ability((user: Guest, post: Post) => post.isPublic) 51 | * } 52 | * 53 | * // Only abilities compatible with User type will be included 54 | * type UserAbilities = NarrowAbilitiesForAUser 55 | * ``` 56 | */ 57 | export type NarrowAbilitiesForAUser< 58 | User, 59 | Abilities extends Record> | undefined, 60 | > = { 61 | [K in keyof Abilities]: Abilities[K] extends BouncerAbility ? K : never 62 | }[keyof Abilities] 63 | 64 | /** 65 | * A response that can be returned by an authorizer function. This union type 66 | * allows authorizers to return either a simple boolean for basic allow/deny 67 | * decisions or a full AuthorizationResponse object for more detailed responses 68 | * with custom messages, status codes, and translation support. 69 | * 70 | * @example 71 | * ```typescript 72 | * // Simple boolean response 73 | * const simpleAuth = (user: User, post: Post): AuthorizerResponse => { 74 | * return user.id === post.authorId 75 | * } 76 | * 77 | * // Detailed response with message 78 | * const detailedAuth = (user: User, post: Post): AuthorizerResponse => { 79 | * if (user.id === post.authorId) { 80 | * return AuthorizationResponse.allow() 81 | * } 82 | * return AuthorizationResponse.deny('You can only edit your own posts', 403) 83 | * } 84 | * ``` 85 | */ 86 | export type AuthorizerResponse = boolean | AuthorizationResponse 87 | 88 | /** 89 | * The callback function that authorizes an ability. It should always 90 | * accept the user as the first argument, followed by additional 91 | * arguments that provide context for the authorization decision. 92 | * 93 | * @template User The user type being authorized 94 | * @param user The user object making the request 95 | * @param args Additional arguments needed for authorization (e.g., resources, context) 96 | * 97 | * @example 98 | * ```typescript 99 | * // Synchronous authorizer 100 | * const editPost: BouncerAuthorizer = (user, post: Post) => { 101 | * return user.id === post.authorId 102 | * } 103 | * 104 | * // Asynchronous authorizer 105 | * const deletePost: BouncerAuthorizer = async (user, post: Post) => { 106 | * const isAuthor = user.id === post.authorId 107 | * const isAdmin = await user.hasRole('admin') 108 | * return isAuthor || isAdmin 109 | * } 110 | * 111 | * // With multiple arguments 112 | * const viewPost: BouncerAuthorizer = (user, post: Post, context: Context) => { 113 | * return post.isPublic || user.id === post.authorId || context.isPreview 114 | * } 115 | * ``` 116 | */ 117 | export type BouncerAuthorizer = ( 118 | user: User, 119 | ...args: any[] 120 | ) => AuthorizerResponse | Promise 121 | 122 | /** 123 | * Representation of a known bouncer ability. This object wraps an authorizer 124 | * function with additional metadata and execution logic, including support 125 | * for guest users and consistent execution patterns. 126 | * 127 | * @template User The user type for this ability 128 | * 129 | * @example 130 | * ```typescript 131 | * const editPostAbility: BouncerAbility = { 132 | * allowGuest: false, 133 | * original: (user, post) => user.id === post.authorId, 134 | * execute: async (user, post) => { 135 | * if (user === null && !this.allowGuest) { 136 | * return AuthorizationResponse.deny() 137 | * } 138 | * return this.original(user, post) 139 | * } 140 | * } 141 | * 142 | * // Created using the ability helper 143 | * const viewPostAbility = ability((user, post) => { 144 | * return post.isPublic || (user && user.id === post.authorId) 145 | * }) 146 | * ``` 147 | */ 148 | export type BouncerAbility = { 149 | /** 150 | * Whether this ability allows guest (null) users 151 | */ 152 | allowGuest: boolean 153 | 154 | /** 155 | * The original authorizer function provided by the developer 156 | */ 157 | original: BouncerAuthorizer 158 | 159 | /** 160 | * Execute the ability with proper guest handling and response normalization 161 | * 162 | * @param user The user object or null for guests 163 | * @param args Additional arguments for the authorization check 164 | */ 165 | execute(user: User | null, ...args: any[]): AuthorizerResponse | Promise 166 | } 167 | 168 | /** 169 | * Response builder is used to normalize response to 170 | * an instanceof AuthorizationResponse. This function ensures that 171 | * all authorization responses are consistently typed and formatted, 172 | * converting simple boolean responses to AuthorizationResponse objects. 173 | * 174 | * @param response The raw response from an authorizer (boolean or AuthorizationResponse) 175 | * 176 | * @example 177 | * ```typescript 178 | * const builder: ResponseBuilder = (response) => { 179 | * if (typeof response === 'boolean') { 180 | * return new AuthorizationResponse(response) 181 | * } 182 | * return response 183 | * } 184 | * 185 | * // Usage 186 | * const normalized1 = builder(true) // AuthorizationResponse { authorized: true } 187 | * const normalized2 = builder(AuthorizationResponse.deny('Access denied')) // Unchanged 188 | * ``` 189 | */ 190 | export type ResponseBuilder = (response: boolean | AuthorizationResponse) => AuthorizationResponse 191 | 192 | /** 193 | * Events emitted by bouncer during authorization operations. These events 194 | * allow developers to listen for authorization attempts and implement 195 | * custom logging, auditing, or other side effects. 196 | * 197 | * @example 198 | * ```typescript 199 | * // Listen for authorization events 200 | * emitter.on('authorization:finished', (event) => { 201 | * console.log(`User ${event.user?.id} attempted ${event.action}`) 202 | * console.log(`Result: ${event.response.authorized ? 'ALLOWED' : 'DENIED'}`) 203 | * 204 | * if (!event.response.authorized) { 205 | * // Log failed authorization attempts 206 | * auditLogger.logFailedAuthorization({ 207 | * user: event.user, 208 | * action: event.action, 209 | * parameters: event.parameters, 210 | * reason: event.response.message 211 | * }) 212 | * } 213 | * }) 214 | * ``` 215 | */ 216 | export type BouncerEvents = { 217 | /** 218 | * Emitted when an authorization check is completed 219 | */ 220 | 'authorization:finished': { 221 | /** 222 | * The user who attempted the action (may be null for guests) 223 | */ 224 | user: any 225 | 226 | /** 227 | * The name of the action or ability that was checked 228 | */ 229 | action: string 230 | 231 | /** 232 | * Arguments passed to the authorization function 233 | */ 234 | parameters: any[] 235 | 236 | /** 237 | * The final authorization response 238 | */ 239 | response: AuthorizationResponse 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /tests/plugins/edge.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Edge } from 'edge.js' 11 | import { test } from '@japa/runner' 12 | import { Bouncer } from '../../src/bouncer.ts' 13 | import { edgePluginBouncer } from '../../src/plugins/edge.ts' 14 | import { BasePolicy } from '../../src/base_policy.ts' 15 | 16 | test.group('Edge plugin | compile', (group) => { 17 | group.tap((t) => 18 | t.skip(process.platform === 'win32', 'Skipping on windows because of newline breaks') 19 | ) 20 | 21 | test('assert @can tag compiled output', async ({ assert }) => { 22 | const edge = new Edge() 23 | edge.use(edgePluginBouncer) 24 | edge.createRenderer() 25 | 26 | const output = edge.asyncCompiler.compileRaw( 27 | `@can('editPost', post) 28 | Can edit post 29 | @else 30 | Cannot edit post 31 | @end 32 | ` 33 | ) 34 | 35 | assert.deepEqual(output.toString().split('\n'), [ 36 | `async function anonymous(template,state,$context`, 37 | `) {`, 38 | `let out = "";`, 39 | `let $lineNumber = 1;`, 40 | `let $filename = "eval.edge";`, 41 | `try {`, 42 | `if (await state.bouncer.can('editPost', state.post)) {`, 43 | `out += "\\n";`, 44 | `out += " Can edit post";`, 45 | `} else {`, 46 | `out += "\\n";`, 47 | `out += " Cannot edit post";`, 48 | `}`, 49 | `out += "\\n";`, 50 | `out += " ";`, 51 | `} catch (error) {`, 52 | `template.reThrow(error, $filename, $lineNumber);`, 53 | `}`, 54 | `return out;`, 55 | `}`, 56 | ]) 57 | }) 58 | 59 | test('assert @cannot tag compiled output', async ({ assert }) => { 60 | const edge = new Edge() 61 | edge.use(edgePluginBouncer) 62 | edge.createRenderer() 63 | 64 | const output = edge.asyncCompiler.compileRaw( 65 | `@cannot('editPost', post) 66 | Cannot edit post 67 | @end 68 | ` 69 | ) 70 | 71 | assert.deepEqual(output.toString().split('\n'), [ 72 | `async function anonymous(template,state,$context`, 73 | `) {`, 74 | `let out = "";`, 75 | `let $lineNumber = 1;`, 76 | `let $filename = "eval.edge";`, 77 | `try {`, 78 | `if (await state.bouncer.cannot('editPost', state.post)) {`, 79 | `out += "\\n";`, 80 | `out += " Cannot edit post";`, 81 | `}`, 82 | `out += "\\n";`, 83 | `out += " ";`, 84 | `} catch (error) {`, 85 | `template.reThrow(error, $filename, $lineNumber);`, 86 | `}`, 87 | `return out;`, 88 | `}`, 89 | ]) 90 | }) 91 | }) 92 | 93 | test.group('Edge plugin | abilities', () => { 94 | test('use @can tag to authorize', async ({ assert }) => { 95 | class User { 96 | declare id: number 97 | declare email: string 98 | } 99 | 100 | const editPost = Bouncer.ability((_: User) => false) 101 | const bouncer = new Bouncer(new User(), { editPost }) 102 | 103 | const edge = new Edge() 104 | edge.use(edgePluginBouncer) 105 | 106 | const text = await edge.share(bouncer.edgeHelpers).renderRaw(` 107 | @can('editPost') 108 | Can edit post 109 | @else 110 | Cannot edit post 111 | @end 112 | `) 113 | 114 | assert.equal(text.trim(), 'Cannot edit post') 115 | }) 116 | 117 | test('use @cannot tag to authorize', async ({ assert }) => { 118 | class User { 119 | declare id: number 120 | declare email: string 121 | } 122 | 123 | const editPost = Bouncer.ability((_: User) => false) 124 | const bouncer = new Bouncer(new User(), { editPost }) 125 | 126 | const edge = new Edge() 127 | edge.use(edgePluginBouncer) 128 | 129 | const text = await edge.share(bouncer.edgeHelpers).renderRaw(` 130 | @cannot('editPost') 131 | Cannot edit post 132 | @else 133 | Can edit post 134 | @end 135 | `) 136 | 137 | assert.equal(text.trim(), 'Cannot edit post') 138 | }) 139 | 140 | test('pass additional params via @can tag', async ({ assert }) => { 141 | class User { 142 | declare email: string 143 | constructor(public id: number) {} 144 | } 145 | 146 | class Post { 147 | constructor(public userId: number) {} 148 | } 149 | 150 | const editPost = Bouncer.ability((user: User, post: Post) => user.id === post.userId) 151 | const bouncer = new Bouncer(new User(1), { editPost }) 152 | 153 | const edge = new Edge() 154 | edge.use(edgePluginBouncer) 155 | 156 | const text = await edge.share(bouncer.edgeHelpers).renderRaw( 157 | ` 158 | @can('editPost', posts[0]) 159 | Can edit post 1 160 | @end 161 | @can('editPost', posts[1]) 162 | Can edit post 2 163 | @end 164 | `, 165 | { 166 | posts: [new Post(1), new Post(2)], 167 | } 168 | ) 169 | 170 | assert.equal(text.trim(), 'Can edit post 1') 171 | }) 172 | 173 | test('pass additional params via @cannot tag', async ({ assert }) => { 174 | class User { 175 | declare email: string 176 | constructor(public id: number) {} 177 | } 178 | 179 | class Post { 180 | constructor(public userId: number) {} 181 | } 182 | 183 | const editPost = Bouncer.ability((user: User, post: Post) => user.id === post.userId) 184 | const bouncer = new Bouncer(new User(1), { editPost }) 185 | 186 | const edge = new Edge() 187 | edge.use(edgePluginBouncer) 188 | 189 | const text = await edge.share(bouncer.edgeHelpers).renderRaw( 190 | ` 191 | @cannot('editPost', posts[0]) 192 | Cannot edit post 1 193 | @end 194 | @cannot('editPost', posts[1]) 195 | Cannot edit post 2 196 | @end 197 | `, 198 | { 199 | posts: [new Post(1), new Post(2)], 200 | } 201 | ) 202 | 203 | assert.equal(text.trim(), 'Cannot edit post 2') 204 | }) 205 | }) 206 | 207 | test.group('Edge plugin | policies', () => { 208 | test('use @can tag to authorize', async ({ assert }) => { 209 | class User { 210 | declare id: number 211 | declare email: string 212 | } 213 | 214 | class PostPolicy extends BasePolicy { 215 | edit(_: User) { 216 | return false 217 | } 218 | } 219 | 220 | const bouncer = new Bouncer( 221 | new User(), 222 | {}, 223 | { 224 | PostPolicy: async () => { 225 | return { 226 | default: PostPolicy, 227 | } 228 | }, 229 | } 230 | ) 231 | 232 | const edge = new Edge() 233 | edge.use(edgePluginBouncer) 234 | 235 | const text = await edge.share(bouncer.edgeHelpers).renderRaw(` 236 | @can('PostPolicy.edit') 237 | Can edit post 238 | @else 239 | Cannot edit post 240 | @end 241 | `) 242 | 243 | assert.equal(text.trim(), 'Cannot edit post') 244 | }) 245 | 246 | test('use @cannot tag to authorize', async ({ assert }) => { 247 | class User { 248 | declare id: number 249 | declare email: string 250 | } 251 | 252 | class PostPolicy extends BasePolicy { 253 | edit(_: User) { 254 | return false 255 | } 256 | } 257 | 258 | const bouncer = new Bouncer( 259 | new User(), 260 | {}, 261 | { 262 | PostPolicy: async () => { 263 | return { 264 | default: PostPolicy, 265 | } 266 | }, 267 | } 268 | ) 269 | 270 | const edge = new Edge() 271 | edge.use(edgePluginBouncer) 272 | 273 | const text = await edge.share(bouncer.edgeHelpers).renderRaw(` 274 | @cannot('PostPolicy.edit') 275 | Cannot edit post 276 | @else 277 | Can edit post 278 | @end 279 | `) 280 | 281 | assert.equal(text.trim(), 'Cannot edit post') 282 | }) 283 | 284 | test('pass additional params via @can tag', async ({ assert }) => { 285 | class User { 286 | declare email: string 287 | constructor(public id: number) {} 288 | } 289 | 290 | class Post { 291 | constructor(public userId: number) {} 292 | } 293 | 294 | class PostPolicy extends BasePolicy { 295 | edit(user: User, post: Post) { 296 | return user.id === post.userId 297 | } 298 | } 299 | 300 | const bouncer = new Bouncer( 301 | new User(1), 302 | {}, 303 | { 304 | PostPolicy: async () => { 305 | return { 306 | default: PostPolicy, 307 | } 308 | }, 309 | } 310 | ) 311 | 312 | const edge = new Edge() 313 | edge.use(edgePluginBouncer) 314 | 315 | const text = await edge.share(bouncer.edgeHelpers).renderRaw( 316 | ` 317 | @can('PostPolicy.edit', posts[0]) 318 | Can edit post 1 319 | @end 320 | @can('PostPolicy.edit', posts[1]) 321 | Can edit post 2 322 | @end 323 | `, 324 | { 325 | posts: [new Post(1), new Post(2)], 326 | } 327 | ) 328 | 329 | assert.equal(text.trim(), 'Can edit post 1') 330 | }) 331 | 332 | test('pass additional params via @cannot tag', async ({ assert }) => { 333 | class User { 334 | declare email: string 335 | constructor(public id: number) {} 336 | } 337 | 338 | class Post { 339 | constructor(public userId: number) {} 340 | } 341 | 342 | class PostPolicy extends BasePolicy { 343 | edit(user: User, post: Post) { 344 | return user.id === post.userId 345 | } 346 | } 347 | 348 | const bouncer = new Bouncer( 349 | new User(1), 350 | {}, 351 | { 352 | PostPolicy: async () => { 353 | return { 354 | default: PostPolicy, 355 | } 356 | }, 357 | } 358 | ) 359 | 360 | const edge = new Edge() 361 | edge.use(edgePluginBouncer) 362 | 363 | const text = await edge.share(bouncer.edgeHelpers).renderRaw( 364 | ` 365 | @cannot('PostPolicy.edit', posts[0]) 366 | Cannot edit post 1 367 | @end 368 | @cannot('PostPolicy.edit', posts[1]) 369 | Cannot edit post 2 370 | @end 371 | `, 372 | { 373 | posts: [new Post(1), new Post(2)], 374 | } 375 | ) 376 | 377 | assert.equal(text.trim(), 'Cannot edit post 2') 378 | }) 379 | }) 380 | -------------------------------------------------------------------------------- /src/policy_authorizer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { RuntimeException } from '@adonisjs/core/exceptions' 11 | import type { EmitterLike } from '@adonisjs/core/types/events' 12 | import type { ContainerResolver } from '@adonisjs/core/container' 13 | import { type Constructor, type LazyImport } from '@adonisjs/core/types/common' 14 | 15 | import debug from './debug.ts' 16 | import { type BasePolicy } from './base_policy.ts' 17 | import { E_AUTHORIZATION_FAILURE } from './errors.ts' 18 | import { AuthorizationResponse } from './response.ts' 19 | import type { 20 | BouncerEvents, 21 | ResponseBuilder, 22 | GetPolicyMethods, 23 | AuthorizerResponse, 24 | } from './types.ts' 25 | 26 | /** 27 | * Map of known policies, so that we can avoid re-importing them 28 | * for every use 29 | */ 30 | const KNOWN_POLICIES_CACHE: Map> = new Map() 31 | 32 | /** 33 | * Exposes the API to authorize a user using a pre-defined policy. 34 | * PolicyAuthorizer handles the execution of policy methods and manages 35 | * policy lifecycle including before/after hooks. 36 | * 37 | * @example 38 | * ```js 39 | * const authorizer = new PolicyAuthorizer(user, PostPolicy, responseBuilder) 40 | * const canEdit = await authorizer.allows('edit', post) 41 | * await authorizer.authorize('delete', post) 42 | * ``` 43 | */ 44 | export class PolicyAuthorizer< 45 | User extends Record, 46 | Policy extends Constructor, 47 | > { 48 | /** 49 | * Cached policy class reference 50 | */ 51 | #policy?: Policy 52 | 53 | /** 54 | * Policy importer function or direct policy class reference 55 | */ 56 | #policyImporter: LazyImport | Policy 57 | 58 | /** 59 | * Reference to the resolved user 60 | */ 61 | #user?: User | null 62 | 63 | /** 64 | * Reference to the IoC container resolver. It is needed 65 | * to optionally construct policy class instances 66 | */ 67 | #containerResolver?: ContainerResolver 68 | 69 | /** 70 | * Emitter to emit events 71 | */ 72 | #emitter?: EmitterLike 73 | 74 | /** 75 | * Response builder is used to normalize bouncer responses 76 | */ 77 | #responseBuilder: ResponseBuilder 78 | 79 | /** 80 | * Create a new PolicyAuthorizer instance 81 | * 82 | * @param user User object or null for guest 83 | * @param policy Policy class or lazy import function 84 | * @param responseBuilder Function to normalize authorization responses 85 | */ 86 | constructor( 87 | user: User | null, 88 | policy: LazyImport | Policy, 89 | responseBuilder: ResponseBuilder 90 | ) { 91 | this.#user = user 92 | this.#policyImporter = policy 93 | this.#responseBuilder = responseBuilder 94 | } 95 | 96 | /** 97 | * Check if a policy method allows guest users 98 | * 99 | * @param Policy Policy class to check 100 | * @param action Method name to check 101 | */ 102 | #policyAllowsGuests(Policy: Constructor, action: string): boolean { 103 | const actionsMetaData = 104 | 'actionsMetaData' in Policy && 105 | (Policy.actionsMetaData as (typeof BasePolicy)['actionsMetaData']) 106 | 107 | if (!actionsMetaData || !actionsMetaData[action]) { 108 | return false 109 | } 110 | 111 | return !!actionsMetaData[action].allowGuest 112 | } 113 | 114 | /** 115 | * Check to see if policy is defined as a class 116 | */ 117 | #isPolicyAClass(policy: LazyImport | Policy): policy is Policy { 118 | return typeof policy === 'function' && /^class(\s+|{)/.test(policy.toString()) 119 | } 120 | 121 | /** 122 | * Resolves the policy from the importer and caches it for 123 | * repetitive use. 124 | */ 125 | async #resolvePolicy(): Promise> { 126 | /** 127 | * Prefer local reference (if exists) 128 | */ 129 | if (this.#policy && !('hot' in import.meta)) { 130 | return this.#policy 131 | } 132 | 133 | /** 134 | * Read from cache if exists 135 | */ 136 | if (KNOWN_POLICIES_CACHE.has(this.#policyImporter)) { 137 | debug('reading policy from the imports cache %O', this.#policyImporter) 138 | return KNOWN_POLICIES_CACHE.get(this.#policyImporter)! 139 | } 140 | 141 | /** 142 | * Import policy using the importer if a lazy import function 143 | * is provided, otherwise we consider policy to be a class 144 | */ 145 | const policyOrImport = this.#policyImporter 146 | if (this.#isPolicyAClass(policyOrImport)) { 147 | this.#policy = policyOrImport 148 | } else { 149 | debug('lazily importing policy %O', this.#policyImporter) 150 | const policyExports = await policyOrImport() 151 | this.#policy = policyExports.default 152 | } 153 | 154 | /** 155 | * Cache the resolved value 156 | */ 157 | if (!('hot' in import.meta)) { 158 | KNOWN_POLICIES_CACHE.set(this.#policyImporter, this.#policy) 159 | } 160 | return this.#policy 161 | } 162 | 163 | /** 164 | * Emits the event and sends normalized response 165 | */ 166 | #emitAndRespond(action: any, result: boolean | AuthorizationResponse, args: any[]) { 167 | const response = this.#responseBuilder(result) 168 | if (this.#emitter) { 169 | this.#emitter.emit('authorization:finished', { 170 | user: this.#user, 171 | action: `${this.#policy?.name}.${action}`, 172 | response, 173 | parameters: args, 174 | }) 175 | } 176 | 177 | return response 178 | } 179 | 180 | /** 181 | * Executes the after hook on policy and handles various 182 | * flows around using original or modified response. 183 | */ 184 | async #executeAfterHook( 185 | policy: any, 186 | action: any, 187 | result: boolean | AuthorizationResponse, 188 | args: any[] 189 | ): Promise { 190 | /** 191 | * Return the action response when no after is defined 192 | */ 193 | if (typeof policy.after !== 'function') { 194 | return this.#emitAndRespond(action, result, args) 195 | } 196 | 197 | const modifiedResponse = await policy.after(this.#user, action, result, ...args) 198 | 199 | /** 200 | * If modified response is a valid authorizer response, when use that 201 | * modified response 202 | */ 203 | if ( 204 | typeof modifiedResponse === 'boolean' || 205 | modifiedResponse instanceof AuthorizationResponse 206 | ) { 207 | return this.#emitAndRespond(action, modifiedResponse, args) 208 | } 209 | 210 | /** 211 | * Otherwise fallback to original response 212 | */ 213 | return this.#emitAndRespond(action, result, args) 214 | } 215 | 216 | /** 217 | * Set a container resolver to use for resolving policies 218 | * 219 | * @param containerResolver IoC container resolver for constructing policy instances 220 | */ 221 | setContainerResolver(containerResolver?: ContainerResolver): this { 222 | this.#containerResolver = containerResolver 223 | return this 224 | } 225 | 226 | /** 227 | * Define the event emitter instance to use for emitting 228 | * authorization events 229 | * 230 | * @param emitter Event emitter instance 231 | */ 232 | setEmitter(emitter?: EmitterLike): this { 233 | this.#emitter = emitter 234 | return this 235 | } 236 | 237 | /** 238 | * Execute an action from the list of pre-defined actions 239 | * 240 | * @param action Policy method name to execute 241 | * @param args Arguments to pass to the policy method 242 | * 243 | * @example 244 | * ```js 245 | * const result = await authorizer.execute('edit', post) 246 | * ``` 247 | */ 248 | async execute>>( 249 | action: Method, 250 | ...args: InstanceType[Method] extends ( 251 | user: User, 252 | ...args: infer Args 253 | ) => AuthorizerResponse | Promise 254 | ? Args 255 | : never 256 | ): Promise { 257 | const Policy = await this.#resolvePolicy() 258 | 259 | /** 260 | * Create an instance of the class either using the container 261 | * resolver or manually. 262 | */ 263 | const policyInstance = this.#containerResolver 264 | ? await this.#containerResolver.make(Policy) 265 | : new Policy() 266 | 267 | /** 268 | * Ensure the method exists on the policy class otherwise 269 | * raise an exception 270 | */ 271 | if (typeof policyInstance[action] !== 'function') { 272 | throw new RuntimeException( 273 | `Cannot find method "${action as string}" on "[class ${Policy.name}]"` 274 | ) 275 | } 276 | 277 | /** 278 | * Execute before hook and shortcircuit if before hook returns 279 | * a valid authorizer response 280 | */ 281 | let hookResponse: unknown 282 | if (typeof policyInstance.before === 'function') { 283 | hookResponse = await policyInstance.before(this.#user, action, ...args) 284 | } 285 | if (typeof hookResponse === 'boolean' || hookResponse instanceof AuthorizationResponse) { 286 | return this.#executeAfterHook(policyInstance, action, hookResponse, args) 287 | } 288 | 289 | /** 290 | * Disallow action for guest users 291 | */ 292 | if (this.#user === null && !this.#policyAllowsGuests(Policy, action as string)) { 293 | return this.#executeAfterHook(policyInstance, action, AuthorizationResponse.deny(), args) 294 | } 295 | 296 | /** 297 | * Invoke action manually and normalize its response 298 | */ 299 | const response = await policyInstance[action](this.#user, ...args) 300 | return this.#executeAfterHook(policyInstance, action, response, args) 301 | } 302 | 303 | /** 304 | * Check if a user is allowed to perform an action using 305 | * one of the known policy methods 306 | * 307 | * @param action Policy method name to check 308 | * @param args Arguments to pass to the policy method 309 | * 310 | * @example 311 | * ```js 312 | * const canEdit = await authorizer.allows('edit', post) 313 | * ``` 314 | */ 315 | async allows>>( 316 | action: Method, 317 | ...args: InstanceType[Method] extends ( 318 | user: User, 319 | ...args: infer Args 320 | ) => AuthorizerResponse | Promise 321 | ? Args 322 | : never 323 | ): Promise { 324 | const response = await this.execute(action, ...args) 325 | return response.authorized 326 | } 327 | 328 | /** 329 | * Check if a user is denied from performing an action using 330 | * one of the known policy methods 331 | * 332 | * @param action Policy method name to check 333 | * @param args Arguments to pass to the policy method 334 | * 335 | * @example 336 | * ```js 337 | * const cannotEdit = await authorizer.denies('edit', post) 338 | * ``` 339 | */ 340 | async denies>>( 341 | action: Method, 342 | ...args: InstanceType[Method] extends ( 343 | user: User, 344 | ...args: infer Args 345 | ) => AuthorizerResponse | Promise 346 | ? Args 347 | : never 348 | ): Promise { 349 | const response = await this.execute(action, ...args) 350 | return !response.authorized 351 | } 352 | 353 | /** 354 | * Authorize a user against a given policy action 355 | * 356 | * @param action Policy method name to authorize 357 | * @param args Arguments to pass to the policy method 358 | * @throws E_AUTHORIZATION_FAILURE 359 | * 360 | * @example 361 | * ```js 362 | * await authorizer.authorize('edit', post) 363 | * ``` 364 | */ 365 | async authorize>>( 366 | action: Method, 367 | ...args: InstanceType[Method] extends ( 368 | user: User, 369 | ...args: infer Args 370 | ) => AuthorizerResponse | Promise 371 | ? Args 372 | : never 373 | ): Promise { 374 | const response = await this.execute(action, ...args) 375 | if (!response.authorized) { 376 | throw new E_AUTHORIZATION_FAILURE(response) 377 | } 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /tests/bouncer/abilities.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/boucner 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { createEmitter } from '../helpers.ts' 13 | import { Bouncer } from '../../src/bouncer.ts' 14 | import { AuthorizationResponse } from '../../src/response.ts' 15 | 16 | test.group('Bouncer | actions | types', () => { 17 | test('assert allowed actions by reference', async () => { 18 | class User { 19 | declare id: number 20 | declare email: string 21 | } 22 | class Admin { 23 | declare adminId: number 24 | } 25 | 26 | const editPost = Bouncer.ability((_: User) => false) 27 | const editStaff = Bouncer.ability((_: Admin) => false) 28 | const bouncer = new Bouncer(new User()) 29 | await bouncer.execute(editPost) 30 | 31 | /** 32 | * Since, the editStaff action needs an instance of Admin 33 | * class, it cannot used with a bouncer instance created 34 | * for the User class 35 | */ 36 | // @ts-expect-error 37 | await bouncer.execute(editStaff) 38 | 39 | /** 40 | * Since, we have not passed any predefined actions to buncer, we 41 | * cannot call them as string 42 | */ 43 | // @ts-expect-error 44 | await bouncer.execute('editStaff') 45 | }).throws(`Invalid bouncer ability "'editStaff'"`) 46 | 47 | test('assert allowed pre-defined actions', async () => { 48 | class User { 49 | declare id: number 50 | declare email: string 51 | } 52 | class Admin { 53 | declare adminId: number 54 | } 55 | 56 | const editPost = Bouncer.ability((_: User) => false) 57 | const editStaff = Bouncer.ability((_: Admin) => false) 58 | const bouncer = new Bouncer(new User(), { editPost, editStaff }) 59 | 60 | await bouncer.execute('editPost') 61 | 62 | /** 63 | * Since, the editStaff action needs an instance of Admin 64 | * class, it cannot used with a bouncer instance created 65 | * for the User class 66 | */ 67 | // @ts-expect-error 68 | await bouncer.execute('editStaff') 69 | // @ts-expect-error 70 | await bouncer.execute(editStaff) 71 | }) 72 | 73 | test('infer arguments accepted by an action', async () => { 74 | class User { 75 | declare id: number 76 | declare email: string 77 | } 78 | class Post { 79 | declare userId: null 80 | declare title: string 81 | } 82 | 83 | const editPost = Bouncer.ability((_: User, __: Post) => { 84 | return false 85 | }) 86 | const bouncer = new Bouncer(new User(), { editPost }) 87 | 88 | bouncer.allows('editPost', new Post()) 89 | bouncer.allows(editPost, new Post()) 90 | 91 | /** 92 | * Since, the editPost action needs post instance, it gives 93 | * type error if do not pass the parameter 94 | */ 95 | // @ts-expect-error 96 | bouncer.allows('editPost') 97 | 98 | /** 99 | * Since, the editPost action needs post instance, it gives 100 | * type error if do not pass the parameter 101 | */ 102 | // @ts-expect-error 103 | bouncer.allows(editPost) 104 | }) 105 | 106 | test('assert allowed actions by reference with union of users', async () => { 107 | class User { 108 | declare id: number 109 | declare email: string 110 | } 111 | class Admin { 112 | declare adminId: number 113 | } 114 | 115 | const editPost = Bouncer.ability((_: User | Admin) => false) 116 | const editStaff = Bouncer.ability((_: User | Admin) => false) 117 | 118 | const bouncer = new Bouncer(new User()) 119 | const bouncer1 = new Bouncer(new User()) 120 | 121 | await bouncer.allows(editPost) 122 | await bouncer.allows(editStaff) 123 | 124 | await bouncer1.allows(editPost) 125 | await bouncer1.allows(editStaff) 126 | 127 | /** 128 | * Since, we have not passed any predefined actions to buncer, we 129 | * cannot call them as string 130 | */ 131 | // @ts-expect-error 132 | await bouncer.allows('editStaff') 133 | /** 134 | * Since, we have not passed any predefined actions to buncer, we 135 | * cannot call them as string 136 | */ 137 | // @ts-expect-error 138 | await bouncer1.allows('editStaff') 139 | }).throws(`Invalid bouncer ability "'editStaff'"`) 140 | 141 | test('assert allowed pre-defined actions with union of users', async () => { 142 | class User { 143 | declare id: number 144 | declare email: string 145 | } 146 | class Admin { 147 | declare adminId: number 148 | } 149 | 150 | const editPost = Bouncer.ability((_: User | Admin) => false) 151 | const editStaff = Bouncer.ability((_: User | Admin) => false) 152 | const actions = { editPost, editStaff } 153 | 154 | const bouncer = new Bouncer(new User(), actions) 155 | const bouncer1 = new Bouncer(new User(), actions) 156 | 157 | await bouncer.allows('editPost') 158 | await bouncer.allows('editStaff') 159 | await bouncer.allows(editStaff) 160 | 161 | await bouncer1.allows('editPost') 162 | await bouncer1.allows('editStaff') 163 | await bouncer1.allows(editStaff) 164 | }) 165 | }) 166 | 167 | test.group('Bouncer | actions', () => { 168 | test('execute action by reference', async ({ assert, cleanup }, done) => { 169 | class User { 170 | declare id: number 171 | declare email: string 172 | } 173 | 174 | const emitter = createEmitter() 175 | const editPost = Bouncer.ability((_: User) => false) 176 | const bouncer = new Bouncer(new User()) 177 | Bouncer.emitter = emitter 178 | cleanup(() => (Bouncer.emitter = undefined)) 179 | 180 | emitter.on('authorization:finished', (event) => { 181 | assert.instanceOf(event.user, User) 182 | assert.deepEqual(event.parameters, []) 183 | assert.instanceOf(event.response, AuthorizationResponse) 184 | done() 185 | }) 186 | 187 | const response = await bouncer.execute(editPost) 188 | assert.instanceOf(response, AuthorizationResponse) 189 | assert.equal(response.authorized, false) 190 | }).waitForDone() 191 | 192 | test('execute action from pre-defined list', async ({ assert, cleanup }, done) => { 193 | class User { 194 | declare id: number 195 | declare email: string 196 | } 197 | 198 | const emitter = createEmitter() 199 | const editPost = Bouncer.ability((_: User) => false) 200 | const bouncer = new Bouncer(new User(), { editPost }) 201 | Bouncer.emitter = emitter 202 | cleanup(() => (Bouncer.emitter = undefined)) 203 | 204 | emitter.on('authorization:finished', (event) => { 205 | assert.instanceOf(event.user, User) 206 | assert.equal(event.action, 'editPost') 207 | assert.deepEqual(event.parameters, []) 208 | assert.instanceOf(event.response, AuthorizationResponse) 209 | done() 210 | }) 211 | 212 | const response = await bouncer.execute('editPost') 213 | assert.instanceOf(response, AuthorizationResponse) 214 | assert.equal(response.authorized, false) 215 | }).waitForDone() 216 | 217 | test('pass arguments to the action', async ({ assert }) => { 218 | class User { 219 | declare email: string 220 | constructor(public id: number) {} 221 | } 222 | class Post { 223 | constructor(public userId: number) {} 224 | } 225 | 226 | const editPost = Bouncer.ability((user: User, post: Post) => { 227 | return post.userId === user.id 228 | }) 229 | 230 | const bouncer = new Bouncer(new User(1), { editPost }) 231 | 232 | const response = await bouncer.execute('editPost', new Post(1)) 233 | assert.instanceOf(response, AuthorizationResponse) 234 | assert.equal(response.authorized, true) 235 | 236 | const referenceResponse = await bouncer.execute(editPost, new Post(2)) 237 | assert.instanceOf(referenceResponse, AuthorizationResponse) 238 | assert.equal(referenceResponse.authorized, false) 239 | }) 240 | 241 | test('check if user is allowed or denied to perform an action', async ({ assert }) => { 242 | class User { 243 | declare id: number 244 | declare email: string 245 | } 246 | 247 | const editPost = Bouncer.ability((_: User) => false) 248 | const bouncer = new Bouncer(new User(), { editPost }) 249 | 250 | assert.isFalse(await bouncer.allows(editPost)) 251 | assert.isTrue(await bouncer.denies(editPost)) 252 | 253 | assert.isFalse(await bouncer.allows('editPost')) 254 | assert.isTrue(await bouncer.denies('editPost')) 255 | }) 256 | 257 | test('deny access for guest users', async ({ assert }) => { 258 | class User { 259 | declare id: number 260 | declare email: string 261 | } 262 | 263 | const editPost = Bouncer.ability((_: User) => { 264 | throw new Error('Never executed to be invoked for guest users') 265 | }) 266 | const actions = { editPost } 267 | 268 | const bouncer = new Bouncer(null, actions) 269 | 270 | assert.isFalse(await bouncer.allows(editPost)) 271 | assert.isTrue(await bouncer.denies(editPost)) 272 | 273 | assert.isFalse(await bouncer.allows('editPost')) 274 | assert.isTrue(await bouncer.denies('editPost')) 275 | }) 276 | 277 | test('execute action when guest users are allowed', async ({ assert }) => { 278 | class User { 279 | declare id: number 280 | declare email: string 281 | } 282 | 283 | const editPost = Bouncer.ability({ allowGuest: true }, (_: User | null) => { 284 | return true 285 | }) 286 | const actions = { editPost } 287 | 288 | const bouncer = new Bouncer(null, actions) 289 | 290 | assert.isTrue(await bouncer.allows(editPost)) 291 | assert.isFalse(await bouncer.denies(editPost)) 292 | 293 | assert.isTrue(await bouncer.allows('editPost')) 294 | assert.isFalse(await bouncer.denies('editPost')) 295 | }) 296 | }) 297 | 298 | test.group('Bouncer | actions | userResolver', () => { 299 | test('execute action by reference', async ({ assert }) => { 300 | class User { 301 | declare id: number 302 | declare email: string 303 | } 304 | 305 | const editPost = Bouncer.ability((_: User) => false) 306 | const bouncer = new Bouncer(() => new User()) 307 | 308 | const response = await bouncer.execute(editPost) 309 | assert.instanceOf(response, AuthorizationResponse) 310 | assert.equal(response.authorized, false) 311 | }) 312 | 313 | test('execute action from pre-defined list', async ({ assert }) => { 314 | class User { 315 | declare id: number 316 | declare email: string 317 | } 318 | 319 | const editPost = Bouncer.ability((_: User) => false) 320 | const bouncer = new Bouncer(() => new User(), { editPost }) 321 | 322 | const response = await bouncer.execute('editPost') 323 | assert.instanceOf(response, AuthorizationResponse) 324 | assert.equal(response.authorized, false) 325 | }) 326 | 327 | test('pass arguments to the action', async ({ assert }) => { 328 | class User { 329 | declare email: string 330 | constructor(public id: number) {} 331 | } 332 | class Post { 333 | constructor(public userId: number) {} 334 | } 335 | 336 | const editPost = Bouncer.ability((user: User, post: Post) => { 337 | return post.userId === user.id 338 | }) 339 | 340 | const bouncer = new Bouncer(() => new User(1), { editPost }) 341 | 342 | const response = await bouncer.execute('editPost', new Post(1)) 343 | assert.instanceOf(response, AuthorizationResponse) 344 | assert.equal(response.authorized, true) 345 | 346 | const referenceResponse = await bouncer.execute(editPost, new Post(2)) 347 | assert.instanceOf(referenceResponse, AuthorizationResponse) 348 | assert.equal(referenceResponse.authorized, false) 349 | }) 350 | 351 | test('check if user is allowed or denied to perform an action', async ({ assert }) => { 352 | class User { 353 | declare id: number 354 | declare email: string 355 | } 356 | 357 | const editPost = Bouncer.ability((_: User) => false) 358 | const bouncer = new Bouncer(() => new User(), { editPost }) 359 | 360 | assert.isFalse(await bouncer.allows(editPost)) 361 | assert.isTrue(await bouncer.denies(editPost)) 362 | 363 | assert.isFalse(await bouncer.allows('editPost')) 364 | assert.isTrue(await bouncer.denies('editPost')) 365 | }) 366 | 367 | test('deny access for guest users', async ({ assert }) => { 368 | class User { 369 | declare id: number 370 | declare email: string 371 | } 372 | 373 | const editPost = Bouncer.ability((_: User) => { 374 | throw new Error('Never executed to be invoked for guest users') 375 | }) 376 | const actions = { editPost } 377 | 378 | const bouncer = new Bouncer(() => null, actions) 379 | 380 | assert.isFalse(await bouncer.allows(editPost)) 381 | assert.isTrue(await bouncer.denies(editPost)) 382 | 383 | assert.isFalse(await bouncer.allows('editPost')) 384 | assert.isTrue(await bouncer.denies('editPost')) 385 | }) 386 | 387 | test('execute action when guest users are allowed', async ({ assert }) => { 388 | class User { 389 | declare id: number 390 | declare email: string 391 | } 392 | 393 | const editPost = Bouncer.ability({ allowGuest: true }, (_: User | null) => { 394 | return true 395 | }) 396 | const actions = { editPost } 397 | 398 | const bouncer = new Bouncer(() => null, actions) 399 | 400 | assert.isTrue(await bouncer.allows(editPost)) 401 | assert.isFalse(await bouncer.denies(editPost)) 402 | 403 | assert.isTrue(await bouncer.allows('editPost')) 404 | assert.isFalse(await bouncer.denies('editPost')) 405 | }) 406 | 407 | test('authorize action by reference', async ({ assert }) => { 408 | class User { 409 | declare id: number 410 | declare email: string 411 | } 412 | 413 | const editPost = Bouncer.ability((_: User) => false) 414 | const bouncer = new Bouncer(() => new User()) 415 | 416 | await assert.rejects(() => bouncer.authorize(editPost), 'Access denied') 417 | }) 418 | 419 | test('authorize action from pre-defined list', async ({ assert }) => { 420 | class User { 421 | declare id: number 422 | declare email: string 423 | } 424 | 425 | const editPost = Bouncer.ability((_: User) => false) 426 | const bouncer = new Bouncer(() => new User(), { editPost }) 427 | 428 | await assert.rejects(() => bouncer.authorize('editPost'), 'Access denied') 429 | }) 430 | }) 431 | -------------------------------------------------------------------------------- /src/bouncer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { inspect } from 'node:util' 11 | import { RuntimeException } from '@adonisjs/core/exceptions' 12 | import type { EmitterLike } from '@adonisjs/core/types/events' 13 | import { type ContainerResolver } from '@adonisjs/core/container' 14 | import { 15 | type Constructor, 16 | type LazyImport, 17 | type UnWrapLazyImport, 18 | } from '@adonisjs/core/types/common' 19 | 20 | import debug from './debug.ts' 21 | import { AuthorizationResponse } from './response.ts' 22 | import { E_AUTHORIZATION_FAILURE } from './errors.ts' 23 | import { ability as createAbility } from './ability.ts' 24 | import { AbilitiesBuilder } from './abilities_builder.ts' 25 | import { PolicyAuthorizer } from './policy_authorizer.ts' 26 | import type { 27 | BouncerEvents, 28 | BouncerAbility, 29 | ResponseBuilder, 30 | BouncerAuthorizer, 31 | AuthorizerResponse, 32 | NarrowAbilitiesForAUser, 33 | } from './types.ts' 34 | 35 | /** 36 | * Bouncer exposes the API to evaluate bouncer abilities and policies to 37 | * verify if a user is authorized to perform the given action. 38 | * 39 | * @example 40 | * ```js 41 | * const bouncer = new Bouncer(user, abilities, policies) 42 | * 43 | * // Check ability 44 | * const canEdit = await bouncer.allows('editPost', post) 45 | * 46 | * // Use policy 47 | * const canView = await bouncer.with('PostPolicy').allows('view', post) 48 | * 49 | * // Authorize (throws on failure) 50 | * await bouncer.authorize('deletePost', post) 51 | * ``` 52 | */ 53 | export class Bouncer< 54 | User extends Record, 55 | Abilities extends Record> | undefined = undefined, 56 | Policies extends Record>> | undefined = undefined, 57 | > { 58 | /** 59 | * Response builder is used to normalize bouncer responses 60 | */ 61 | static responseBuilder: ResponseBuilder = (response) => { 62 | return typeof response === 'boolean' ? new AuthorizationResponse(response) : response 63 | } 64 | 65 | /** 66 | * Define an ability using the AbilityBuilder 67 | * 68 | * @param name Unique name for the ability 69 | * @param authorizer Function that determines if the action is allowed 70 | * @param options Optional configuration for the ability 71 | * 72 | * @example 73 | * ```js 74 | * const abilities = Bouncer.define('editPost', (user, post) => { 75 | * return user.id === post.authorId 76 | * }) 77 | * ``` 78 | */ 79 | static define>( 80 | name: Name, 81 | authorizer: Authorizer, 82 | options?: { allowGuest: boolean } 83 | ) { 84 | return new AbilitiesBuilder({}).define(name, authorizer, options) 85 | } 86 | 87 | /** 88 | * Emitter to emit events 89 | */ 90 | static emitter?: EmitterLike 91 | 92 | /** 93 | * Define a bouncer ability from a callback 94 | */ 95 | static ability = createAbility 96 | 97 | /** 98 | * User resolver to lazily resolve the user 99 | */ 100 | #userOrResolver: User | (() => User | null) | null 101 | 102 | /** 103 | * Reference to the resolved user 104 | */ 105 | #user?: User | null 106 | 107 | /** 108 | * Pre-defined abilities 109 | */ 110 | abilities?: Abilities 111 | 112 | /** 113 | * Pre-defined policies 114 | */ 115 | policies?: Policies 116 | 117 | /** 118 | * Reference to the container resolver to construct 119 | * policy classes. 120 | */ 121 | #containerResolver?: ContainerResolver 122 | 123 | /** 124 | * An object with helpers to be shared with Edge for 125 | * performing authorization. 126 | */ 127 | edgeHelpers: { 128 | bouncer: { 129 | parent: Bouncer 130 | can(action: string, ...args: any[]): Promise 131 | cannot(action: string, ...args: any[]): Promise 132 | } 133 | } = { 134 | bouncer: { 135 | parent: this, 136 | can(action: string, ...args: any[]) { 137 | const [policyName, ...policyMethods] = action.split('.') 138 | if (policyMethods.length) { 139 | return this.parent.with(policyName as any).allows(policyMethods.join('.'), ...args) 140 | } 141 | return this.parent.allows(policyName as any, ...args) 142 | }, 143 | cannot(action: string, ...args: any[]) { 144 | const [policyName, ...policyMethods] = action.split('.') 145 | if (policyMethods.length) { 146 | return this.parent.with(policyName as any).denies(policyMethods.join('.'), ...args) 147 | } 148 | return this.parent.denies(policyName as any, ...args) 149 | }, 150 | }, 151 | } 152 | 153 | /** 154 | * Create a new Bouncer instance 155 | * 156 | * @param userOrResolver User object or function to resolve the user 157 | * @param abilities Pre-defined abilities for authorization 158 | * @param policies Pre-defined policies for authorization 159 | */ 160 | constructor( 161 | userOrResolver: User | (() => User | null) | null, 162 | abilities?: Abilities, 163 | policies?: Policies 164 | ) { 165 | this.#userOrResolver = userOrResolver 166 | this.abilities = abilities 167 | this.policies = policies 168 | } 169 | 170 | /** 171 | * Returns reference to the user object 172 | */ 173 | #getUser() { 174 | if (this.#user === undefined) { 175 | if (typeof this.#userOrResolver === 'function') { 176 | this.#user = this.#userOrResolver() 177 | } else { 178 | this.#user = this.#userOrResolver 179 | } 180 | } 181 | 182 | return this.#user 183 | } 184 | 185 | /** 186 | * Emits the event and sends normalized response 187 | */ 188 | #emitAndRespond(ability: string, result: boolean | AuthorizationResponse, args: any[]) { 189 | const response = Bouncer.responseBuilder(result) 190 | if (Bouncer.emitter) { 191 | Bouncer.emitter.emit('authorization:finished', { 192 | user: this.#user, 193 | action: ability, 194 | response, 195 | parameters: args, 196 | }) 197 | } 198 | 199 | return response 200 | } 201 | 202 | /** 203 | * Returns an instance of PolicyAuthorizer. PolicyAuthorizer is 204 | * used to authorize user and actions using a given policy 205 | * 206 | * @param policy Policy class or policy name to use for authorization 207 | * 208 | * @example 209 | * ```js 210 | * await bouncer.with('PostPolicy').allows('edit', post) 211 | * await bouncer.with(PostPolicy).denies('delete', post) 212 | * ``` 213 | */ 214 | with( 215 | policy: Policy 216 | ): Policies extends Record>> 217 | ? PolicyAuthorizer> 218 | : never 219 | with>(policy: Policy): PolicyAuthorizer 220 | with(policy: Policy) { 221 | if (typeof policy !== 'function') { 222 | /** 223 | * Ensure the policy is pre-registered 224 | */ 225 | if (!this.policies || !this.policies[policy]) { 226 | throw new RuntimeException(`Invalid bouncer policy "${inspect(policy)}"`) 227 | } 228 | 229 | return new PolicyAuthorizer(this.#getUser(), this.policies[policy], Bouncer.responseBuilder) 230 | .setContainerResolver(this.#containerResolver) 231 | .setEmitter(Bouncer.emitter) 232 | } 233 | 234 | return new PolicyAuthorizer(this.#getUser(), policy, Bouncer.responseBuilder) 235 | .setContainerResolver(this.#containerResolver) 236 | .setEmitter(Bouncer.emitter) 237 | } 238 | 239 | /** 240 | * Set a container resolver to use for resolving policies 241 | * 242 | * @param containerResolver IoC container resolver for constructing policy instances 243 | */ 244 | setContainerResolver(containerResolver?: ContainerResolver): this { 245 | this.#containerResolver = containerResolver 246 | return this 247 | } 248 | 249 | /** 250 | * Execute an ability by reference 251 | * 252 | * @param ability Ability instance to execute 253 | * @param args Arguments to pass to the ability 254 | * 255 | * @example 256 | * ```js 257 | * const result = await bouncer.execute(editPostAbility, post) 258 | * ``` 259 | */ 260 | execute>( 261 | ability: Ability, 262 | ...args: Ability extends { 263 | original: ( 264 | user: User, 265 | ...args: infer Args 266 | ) => AuthorizerResponse | Promise 267 | } 268 | ? Args 269 | : never 270 | ): Promise 271 | 272 | /** 273 | * Execute an ability from the list of pre-defined abilities 274 | * 275 | * @param ability Name of the pre-defined ability 276 | * @param args Arguments to pass to the ability 277 | * 278 | * @example 279 | * ```js 280 | * const result = await bouncer.execute('editPost', post) 281 | * ``` 282 | */ 283 | execute>( 284 | ability: Ability, 285 | ...args: Abilities[Ability] extends { 286 | original: ( 287 | user: User, 288 | ...args: infer Args 289 | ) => AuthorizerResponse | Promise 290 | } 291 | ? Args 292 | : never 293 | ): Promise 294 | 295 | async execute(ability: any, ...args: any[]): Promise { 296 | /** 297 | * Executing ability from a pre-defined list of abilities 298 | */ 299 | if (this.abilities && this.abilities[ability]) { 300 | debug('executing pre-registered ability "%s"', ability) 301 | return this.#emitAndRespond( 302 | ability, 303 | await this.abilities[ability].execute(this.#getUser(), ...args), 304 | args 305 | ) 306 | } 307 | 308 | /** 309 | * Ensure value is an ability reference or throw error 310 | */ 311 | if (!ability || typeof ability !== 'object' || 'execute' in ability === false) { 312 | throw new RuntimeException(`Invalid bouncer ability "${inspect(ability)}"`) 313 | } 314 | 315 | /** 316 | * Executing ability by reference 317 | */ 318 | if (debug.enabled) { 319 | debug('executing ability "%s"', ability.name) 320 | } 321 | 322 | return this.#emitAndRespond( 323 | ability.original.name, 324 | await (ability as BouncerAbility).execute(this.#getUser(), ...args), 325 | args 326 | ) 327 | } 328 | 329 | /** 330 | * Check if a user is allowed to perform an action using 331 | * the ability provided by reference 332 | * 333 | * @param ability Ability instance to check 334 | * @param args Arguments to pass to the ability 335 | * 336 | * @example 337 | * ```js 338 | * const canEdit = await bouncer.allows(editPostAbility, post) 339 | * ``` 340 | */ 341 | allows>( 342 | ability: Ability, 343 | ...args: Ability extends { 344 | original: ( 345 | user: User, 346 | ...args: infer Args 347 | ) => AuthorizerResponse | Promise 348 | } 349 | ? Args 350 | : never 351 | ): Promise 352 | 353 | /** 354 | * Check if a user is allowed to perform an action using 355 | * the ability from the pre-defined list of abilities 356 | * 357 | * @param ability Name of the pre-defined ability 358 | * @param args Arguments to pass to the ability 359 | * 360 | * @example 361 | * ```js 362 | * const canEdit = await bouncer.allows('editPost', post) 363 | * ``` 364 | */ 365 | allows>( 366 | ability: Ability, 367 | ...args: Abilities[Ability] extends { 368 | original: ( 369 | user: User, 370 | ...args: infer Args 371 | ) => AuthorizerResponse | Promise 372 | } 373 | ? Args 374 | : never 375 | ): Promise 376 | async allows(ability: any, ...args: any[]): Promise { 377 | const response = await this.execute(ability, ...args) 378 | return response.authorized 379 | } 380 | 381 | /** 382 | * Check if a user is denied from performing an action using 383 | * the ability provided by reference 384 | * 385 | * @param action Ability instance to check 386 | * @param args Arguments to pass to the ability 387 | * 388 | * @example 389 | * ```js 390 | * const cannotEdit = await bouncer.denies(editPostAbility, post) 391 | * ``` 392 | */ 393 | denies>( 394 | action: Action, 395 | ...args: Action extends { 396 | original: ( 397 | user: User, 398 | ...args: infer Args 399 | ) => AuthorizerResponse | Promise 400 | } 401 | ? Args 402 | : never 403 | ): Promise 404 | 405 | /** 406 | * Check if a user is denied from performing an action using 407 | * the ability from the pre-defined list of abilities 408 | * 409 | * @param action Name of the pre-defined ability 410 | * @param args Arguments to pass to the ability 411 | * 412 | * @example 413 | * ```js 414 | * const cannotEdit = await bouncer.denies('editPost', post) 415 | * ``` 416 | */ 417 | denies>( 418 | action: Action, 419 | ...args: Abilities[Action] extends { 420 | original: ( 421 | user: User, 422 | ...args: infer Args 423 | ) => AuthorizerResponse | Promise 424 | } 425 | ? Args 426 | : never 427 | ): Promise 428 | async denies(action: any, ...args: any[]): Promise { 429 | const response = await this.execute(action, ...args) 430 | return !response.authorized 431 | } 432 | 433 | /** 434 | * Authorize a user against for a given ability 435 | * 436 | * @param action Ability instance to authorize 437 | * @param args Arguments to pass to the ability 438 | * @throws E_AUTHORIZATION_FAILURE 439 | * 440 | * @example 441 | * ```js 442 | * await bouncer.authorize(editPostAbility, post) 443 | * ``` 444 | */ 445 | authorize>( 446 | action: Action, 447 | ...args: Action extends { 448 | original: ( 449 | user: User, 450 | ...args: infer Args 451 | ) => AuthorizerResponse | Promise 452 | } 453 | ? Args 454 | : never 455 | ): Promise 456 | 457 | /** 458 | * Authorize a user against a given ability 459 | * 460 | * @param ability Name of the pre-defined ability 461 | * @param args Arguments to pass to the ability 462 | * @throws E_AUTHORIZATION_FAILURE 463 | * 464 | * @example 465 | * ```js 466 | * await bouncer.authorize('editPost', post) 467 | * ``` 468 | */ 469 | authorize>( 470 | ability: Ability, 471 | ...args: Abilities[Ability] extends { 472 | original: ( 473 | user: User, 474 | ...args: infer Args 475 | ) => AuthorizerResponse | Promise 476 | } 477 | ? Args 478 | : never 479 | ): Promise 480 | async authorize(ability: any, ...args: any[]): Promise { 481 | const response = await this.execute(ability, ...args) 482 | if (!response.authorized) { 483 | throw new E_AUTHORIZATION_FAILURE(response) 484 | } 485 | } 486 | 487 | /** 488 | * Create AuthorizationResponse to deny access 489 | * 490 | * @param message Denial message 491 | * @param status Optional HTTP status code 492 | * 493 | * @example 494 | * ```js 495 | * return bouncer.deny('Access denied', 403) 496 | * ``` 497 | */ 498 | deny(message: string, status?: number) { 499 | return AuthorizationResponse.deny(message, status) 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /tests/bouncer/policies.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { inject } from '@adonisjs/core' 12 | import { Container } from '@adonisjs/core/container' 13 | 14 | import { createEmitter } from '../helpers.ts' 15 | import { Bouncer } from '../../src/bouncer.ts' 16 | import { BasePolicy } from '../../src/base_policy.ts' 17 | import { allowGuest } from '../../src/decorators/action.ts' 18 | import type { AuthorizerResponse } from '../../src/types.ts' 19 | import { AuthorizationResponse } from '../../src/response.ts' 20 | 21 | test.group('Bouncer | policies | types', () => { 22 | test('assert with method arguments with policy reference', async () => { 23 | class User { 24 | declare id: number 25 | declare email: string 26 | } 27 | class Admin { 28 | declare adminId: number 29 | } 30 | 31 | class PostPolicy extends BasePolicy { 32 | resolvePermissions() {} 33 | 34 | view(_: User): AuthorizerResponse { 35 | return true 36 | } 37 | 38 | viewAll(_: User): AuthorizerResponse { 39 | return false 40 | } 41 | } 42 | 43 | class StaffPolicy extends BasePolicy { 44 | resolvePermissions() {} 45 | 46 | view(_: Admin): AuthorizerResponse { 47 | return true 48 | } 49 | 50 | viewAll(_: Admin): AuthorizerResponse { 51 | return false 52 | } 53 | } 54 | 55 | const bouncer = new Bouncer(new User()) 56 | 57 | /** 58 | * Both policy references should work, because we cannot infer 59 | * in advance if all the methods of a given policy works 60 | * with a specific user type or not. 61 | */ 62 | bouncer.with(PostPolicy) 63 | bouncer.with(StaffPolicy) 64 | 65 | // @ts-expect-error 66 | bouncer.with({}) 67 | // @ts-expect-error 68 | bouncer.with('foo') 69 | // @ts-expect-error 70 | bouncer.with(new StaffPolicy()) 71 | }).throws('Invalid bouncer policy "{}"') 72 | 73 | test('assert with method arguments with pre-registered policies', async () => { 74 | class User { 75 | declare id: number 76 | declare email: string 77 | } 78 | class Admin { 79 | declare adminId: number 80 | } 81 | 82 | class PostPolicy extends BasePolicy { 83 | resolvePermissions() {} 84 | 85 | view(_: User): AuthorizerResponse { 86 | return true 87 | } 88 | 89 | viewAll(_: User): AuthorizerResponse { 90 | return false 91 | } 92 | } 93 | 94 | class StaffPolicy extends BasePolicy { 95 | resolvePermissions() {} 96 | 97 | view(_: Admin): AuthorizerResponse { 98 | return true 99 | } 100 | 101 | viewAll(_: Admin): AuthorizerResponse { 102 | return false 103 | } 104 | } 105 | 106 | const bouncer = new Bouncer(new User(), undefined, { 107 | PostPolicy: async () => { 108 | return { 109 | default: PostPolicy, 110 | } 111 | }, 112 | StaffPolicy: async () => { 113 | return { 114 | default: StaffPolicy, 115 | } 116 | }, 117 | }) 118 | 119 | /** 120 | * Both policy references should work, because we cannot infer 121 | * in advance if all the methods of a given policy works 122 | * with a specific user type or not. 123 | */ 124 | bouncer.with('PostPolicy') 125 | bouncer.with('StaffPolicy') 126 | 127 | // @ts-expect-error 128 | bouncer.with({}) 129 | // @ts-expect-error 130 | bouncer.with('foo') 131 | // @ts-expect-error 132 | bouncer.with(new StaffPolicy()) 133 | }).throws('Invalid bouncer policy "{}"') 134 | 135 | test('infer policy methods', async () => { 136 | class User { 137 | declare id: number 138 | declare email: string 139 | } 140 | class Admin { 141 | declare adminId: number 142 | } 143 | 144 | class PostPolicy extends BasePolicy { 145 | resolvePermissions() {} 146 | 147 | view(_: User): AuthorizerResponse { 148 | return true 149 | } 150 | 151 | viewAll(_: User): AuthorizerResponse { 152 | return false 153 | } 154 | } 155 | 156 | class StaffPolicy extends BasePolicy { 157 | resolvePermissions() {} 158 | 159 | view(_: Admin): AuthorizerResponse { 160 | return true 161 | } 162 | 163 | viewAll(_: Admin): AuthorizerResponse { 164 | return false 165 | } 166 | } 167 | 168 | const bouncer = new Bouncer(new User()) 169 | 170 | /** 171 | * Both policy references should work, because we cannot infer 172 | * in advance if all the methods of a given policy works 173 | * with a specific user type or not. 174 | */ 175 | await bouncer.with(PostPolicy).execute('view') 176 | await bouncer.with(PostPolicy).execute('viewAll') 177 | 178 | /** 179 | * The resolvePermission method does not accept the user 180 | * and neither returns AuthorizerResponse 181 | */ 182 | // @ts-expect-error 183 | await bouncer.with(PostPolicy).execute('resolvePermissions') 184 | 185 | /** 186 | * The StaffPolicy methods works with Admin class and hence 187 | * they cannot be used with a bouncer instance created for 188 | * the User class 189 | */ 190 | // @ts-expect-error 191 | bouncer.with(StaffPolicy).execute('view') 192 | // @ts-expect-error 193 | bouncer.with(StaffPolicy).execute('viewAll') 194 | // @ts-expect-error 195 | bouncer.with(StaffPolicy).execute('resolvePermissions') 196 | }) 197 | 198 | test('infer async policy methods', async () => { 199 | class User { 200 | declare id: number 201 | declare email: string 202 | } 203 | 204 | class PostPolicy extends BasePolicy { 205 | resolvePermissions() {} 206 | 207 | async view(_: User) { 208 | if (_) { 209 | return AuthorizationResponse.deny('Denied') 210 | } 211 | return true 212 | } 213 | 214 | async viewAll(_: User) { 215 | return false 216 | } 217 | 218 | async create(_: User) { 219 | return AuthorizationResponse.deny('Denied') 220 | } 221 | } 222 | 223 | const bouncer = new Bouncer(new User()) 224 | 225 | /** 226 | * Both policy references should work, because we cannot infer 227 | * in advance if all the methods of a given policy works 228 | * with a specific user type or not. 229 | */ 230 | await bouncer.with(PostPolicy).execute('view') 231 | await bouncer.with(PostPolicy).execute('viewAll') 232 | await bouncer.with(PostPolicy).execute('create') 233 | 234 | /** 235 | * The resolvePermission method does not accept the user 236 | * and neither returns AuthorizerResponse 237 | */ 238 | // @ts-expect-error 239 | await bouncer.with(PostPolicy).execute('resolvePermissions') 240 | }) 241 | 242 | test('infer policy methods of a pre-registered policy', async () => { 243 | class User { 244 | declare id: number 245 | declare email: string 246 | } 247 | class Admin { 248 | declare adminId: number 249 | } 250 | 251 | class PostPolicy extends BasePolicy { 252 | resolvePermissions() {} 253 | 254 | view(_: User): AuthorizerResponse { 255 | return true 256 | } 257 | 258 | viewAll(_: User): AuthorizerResponse { 259 | return false 260 | } 261 | } 262 | 263 | class StaffPolicy extends BasePolicy { 264 | resolvePermissions() {} 265 | 266 | view(_: Admin): AuthorizerResponse { 267 | return true 268 | } 269 | 270 | viewAll(_: Admin): AuthorizerResponse { 271 | return false 272 | } 273 | } 274 | 275 | const bouncer = new Bouncer(new User(), undefined, { 276 | PostPolicy: async () => { 277 | return { 278 | default: PostPolicy, 279 | } 280 | }, 281 | StaffPolicy: async () => { 282 | return { 283 | default: StaffPolicy, 284 | } 285 | }, 286 | }) 287 | 288 | /** 289 | * Both policy references should work, because we cannot infer 290 | * in advance if all the methods of a given policy works 291 | * with a specific user type or not. 292 | */ 293 | await bouncer.with('PostPolicy').execute('view') 294 | await bouncer.with('PostPolicy').execute('viewAll') 295 | 296 | /** 297 | * The resolvePermission method does not accept the user 298 | * and neither returns AuthorizerResponse 299 | */ 300 | // @ts-expect-error 301 | await bouncer.with('PostPolicy').execute('resolvePermissions') 302 | 303 | /** 304 | * The StaffPolicy methods works with Admin class and hence 305 | * they cannot be used with a bouncer instance created for 306 | * the User class 307 | */ 308 | // @ts-expect-error 309 | bouncer.with('StaffPolicy').execute('view') 310 | // @ts-expect-error 311 | bouncer.with('StaffPolicy').execute('viewAll') 312 | // @ts-expect-error 313 | bouncer.with('StaffPolicy').execute('resolvePermissions') 314 | }) 315 | 316 | test('infer async policy methods of a pre-registered policy', async () => { 317 | class User { 318 | declare id: number 319 | declare email: string 320 | } 321 | 322 | class PostPolicy extends BasePolicy { 323 | resolvePermissions() {} 324 | 325 | async view(_: User): Promise { 326 | if (_) { 327 | return AuthorizationResponse.deny('Denied') 328 | } 329 | return true 330 | } 331 | 332 | viewAll(_: User): Promise | boolean { 333 | return false 334 | } 335 | 336 | async create(_: User): Promise { 337 | return AuthorizationResponse.deny('Denied') 338 | } 339 | 340 | delete(_: User): Promise | boolean { 341 | return false 342 | } 343 | } 344 | 345 | const bouncer = new Bouncer(new User(), undefined, { 346 | PostPolicy: async () => { 347 | return { 348 | default: PostPolicy, 349 | } 350 | }, 351 | }) 352 | 353 | /** 354 | * Both policy references should work, because we cannot infer 355 | * in advance if all the methods of a given policy works 356 | * with a specific user type or not. 357 | */ 358 | await bouncer.with('PostPolicy').execute('view') 359 | await bouncer.with('PostPolicy').execute('viewAll') 360 | await bouncer.with('PostPolicy').execute('create') 361 | await bouncer.with('PostPolicy').execute('delete') 362 | 363 | /** 364 | * The resolvePermission method does not accept the user 365 | * and neither returns AuthorizerResponse 366 | */ 367 | // @ts-expect-error 368 | await bouncer.with('PostPolicy').execute('resolvePermissions') 369 | }) 370 | 371 | test('infer policy method arguments', async () => { 372 | class User { 373 | declare id: number 374 | declare email: string 375 | } 376 | class Post { 377 | declare userId: null 378 | declare title: string 379 | } 380 | 381 | class PostPolicy extends BasePolicy { 382 | resolvePermissions() {} 383 | 384 | view(user: User, post: Post): AuthorizerResponse { 385 | return user.id === post.userId 386 | } 387 | 388 | viewAll(_: User): AuthorizerResponse { 389 | return false 390 | } 391 | } 392 | 393 | const bouncer = new Bouncer(new User()) 394 | 395 | /** 396 | * Both policy references should work, because we cannot infer 397 | * in advance if all the methods of a given policy works 398 | * with a specific user type or not. 399 | */ 400 | await bouncer.with(PostPolicy).execute('view', new Post()) 401 | await bouncer.with(PostPolicy).execute('viewAll') 402 | 403 | /** 404 | * Fails because we are not passing an instance of the post 405 | * class 406 | */ 407 | // @ts-expect-error 408 | await bouncer.with(PostPolicy).execute('view') 409 | 410 | /** 411 | * The resolvePermission method does not accept the user 412 | * and neither returns AuthorizerResponse 413 | */ 414 | // @ts-expect-error 415 | await bouncer.with(PostPolicy).execute('resolvePermissions') 416 | }).throws(`Cannot read properties of undefined (reading 'userId')`) 417 | 418 | test('infer policy method arguments of a pre-registered policy', async () => { 419 | class User { 420 | declare id: number 421 | declare email: string 422 | } 423 | class Post { 424 | declare userId: null 425 | declare title: string 426 | } 427 | 428 | class PostPolicy extends BasePolicy { 429 | resolvePermissions() {} 430 | 431 | view(user: User, post: Post): AuthorizerResponse { 432 | return user.id === post.userId 433 | } 434 | 435 | viewAll(_: User): AuthorizerResponse { 436 | return false 437 | } 438 | } 439 | 440 | const bouncer = new Bouncer(new User(), undefined, { 441 | PostPolicy: async () => { 442 | return { 443 | default: PostPolicy, 444 | } 445 | }, 446 | }) 447 | 448 | /** 449 | * Both policy references should work, because we cannot infer 450 | * in advance if all the methods of a given policy works 451 | * with a specific user type or not. 452 | */ 453 | await bouncer.with('PostPolicy').execute('view', new Post()) 454 | await bouncer.with('PostPolicy').execute('viewAll') 455 | 456 | /** 457 | * Fails because we are not passing an instance of the post 458 | * class 459 | */ 460 | // @ts-expect-error 461 | await bouncer.with('PostPolicy').execute('view') 462 | 463 | /** 464 | * The resolvePermission method does not accept the user 465 | * and neither returns AuthorizerResponse 466 | */ 467 | // @ts-expect-error 468 | await bouncer.with('PostPolicy').execute('resolvePermissions') 469 | }).throws(`Cannot read properties of undefined (reading 'userId')`) 470 | 471 | test('infer policy methods for guest users', async () => { 472 | class User { 473 | declare id: number 474 | declare email: string 475 | } 476 | 477 | class PostPolicy extends BasePolicy { 478 | resolvePermissions() {} 479 | 480 | view(_: User | null): AuthorizerResponse { 481 | return true 482 | } 483 | 484 | viewAll(_: User): AuthorizerResponse { 485 | return false 486 | } 487 | } 488 | 489 | const bouncer = new Bouncer(new User()) 490 | 491 | /** 492 | * Both policy references should work, because we cannot infer 493 | * in advance if all the methods of a given policy works 494 | * with a specific user type or not. 495 | */ 496 | await bouncer.with(PostPolicy).execute('view') 497 | await bouncer.with(PostPolicy).execute('viewAll') 498 | 499 | /** 500 | * The resolvePermission method does not accept the user 501 | * and neither returns AuthorizerResponse 502 | */ 503 | // @ts-expect-error 504 | await bouncer.with(PostPolicy).execute('resolvePermissions') 505 | }) 506 | 507 | test('infer policy methods for union of users', async () => { 508 | class User { 509 | declare id: number 510 | declare email: string 511 | } 512 | class Admin { 513 | declare adminId: number 514 | } 515 | 516 | class PostPolicy extends BasePolicy { 517 | resolvePermissions() {} 518 | 519 | view(_: User | Admin): AuthorizerResponse { 520 | return true 521 | } 522 | 523 | viewAll(_: User | Admin): AuthorizerResponse { 524 | return false 525 | } 526 | } 527 | 528 | const bouncer = new Bouncer(new User()) 529 | 530 | /** 531 | * Both policy references should work, because we cannot infer 532 | * in advance if all the methods of a given policy works 533 | * with a specific user type or not. 534 | */ 535 | await bouncer.with(PostPolicy).execute('view') 536 | await bouncer.with(PostPolicy).execute('viewAll') 537 | 538 | /** 539 | * The resolvePermission method does not accept the user 540 | * and neither returns AuthorizerResponse 541 | */ 542 | // @ts-expect-error 543 | await bouncer.with(PostPolicy).execute('resolvePermissions') 544 | }) 545 | }) 546 | 547 | test.group('Bouncer | policies', () => { 548 | test('execute policy action', async ({ assert, cleanup }, done) => { 549 | class User { 550 | declare id: number 551 | declare email: string 552 | } 553 | 554 | class PostPolicy extends BasePolicy { 555 | resolvePermissions() {} 556 | 557 | view(_: User): AuthorizerResponse { 558 | return true 559 | } 560 | 561 | viewAll(_: User): AuthorizerResponse { 562 | return false 563 | } 564 | } 565 | 566 | const emitter = createEmitter() 567 | const bouncer = new Bouncer(new User()) 568 | Bouncer.emitter = emitter 569 | cleanup(() => (Bouncer.emitter = undefined)) 570 | 571 | emitter.on('authorization:finished', (event) => { 572 | assert.instanceOf(event.user, User) 573 | assert.equal(event.action, 'PostPolicy.view') 574 | assert.deepEqual(event.parameters, []) 575 | assert.instanceOf(event.response, AuthorizationResponse) 576 | done() 577 | }) 578 | 579 | const canView = await bouncer.with(PostPolicy).execute('view') 580 | assert.isTrue(canView.authorized) 581 | 582 | Bouncer.emitter = undefined 583 | 584 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 585 | assert.isFalse(canViewAll.authorized) 586 | }).waitForDone() 587 | 588 | test('execute policy action on a pre-registered policy', async ({ assert }) => { 589 | class User { 590 | declare id: number 591 | declare email: string 592 | } 593 | 594 | class PostPolicy extends BasePolicy { 595 | resolvePermissions() {} 596 | 597 | view(_: User): AuthorizerResponse { 598 | return true 599 | } 600 | 601 | viewAll(_: User): AuthorizerResponse { 602 | return false 603 | } 604 | } 605 | 606 | const bouncer = new Bouncer(new User(), undefined, { 607 | PostPolicy: async () => { 608 | return { 609 | default: PostPolicy, 610 | } 611 | }, 612 | }) 613 | 614 | const postsPolicy = bouncer.with('PostPolicy') 615 | 616 | const canView = await postsPolicy.execute('view') 617 | assert.isTrue(canView.authorized) 618 | 619 | const canViewAll = await postsPolicy.execute('viewAll') 620 | assert.isFalse(canViewAll.authorized) 621 | }) 622 | 623 | test('cache lazily imported policies', async ({ assert }) => { 624 | let importsCounter: number = 0 625 | 626 | class User { 627 | declare id: number 628 | declare email: string 629 | } 630 | 631 | class PostPolicy extends BasePolicy { 632 | resolvePermissions() {} 633 | 634 | view(_: User): AuthorizerResponse { 635 | return true 636 | } 637 | 638 | viewAll(_: User): AuthorizerResponse { 639 | return false 640 | } 641 | } 642 | 643 | const bouncer = new Bouncer(new User(), undefined, { 644 | PostPolicy: async () => { 645 | importsCounter++ 646 | return { 647 | default: PostPolicy, 648 | } 649 | }, 650 | }) 651 | const canView = await bouncer.with('PostPolicy').execute('view') 652 | assert.isTrue(canView.authorized) 653 | 654 | const canViewAll = await bouncer.with('PostPolicy').execute('viewAll') 655 | assert.isFalse(canViewAll.authorized) 656 | 657 | assert.equal(importsCounter, 1) 658 | }) 659 | 660 | test('cache lazily imported policies across bouncer instances', async ({ assert }) => { 661 | let importsCounter: number = 0 662 | 663 | class User { 664 | declare id: number 665 | declare email: string 666 | } 667 | 668 | class PostPolicy extends BasePolicy { 669 | resolvePermissions() {} 670 | 671 | view(_: User): AuthorizerResponse { 672 | return true 673 | } 674 | 675 | viewAll(_: User): AuthorizerResponse { 676 | return false 677 | } 678 | } 679 | 680 | const policies = { 681 | PostPolicy: async () => { 682 | importsCounter++ 683 | return { 684 | default: PostPolicy, 685 | } 686 | }, 687 | } 688 | 689 | const bouncer = new Bouncer(new User(), undefined, policies) 690 | const bouncer1 = new Bouncer(new User(), undefined, policies) 691 | 692 | const canView = await bouncer.with('PostPolicy').execute('view') 693 | assert.isTrue(canView.authorized) 694 | 695 | const canViewAll = await bouncer1.with('PostPolicy').execute('viewAll') 696 | assert.isFalse(canViewAll.authorized) 697 | 698 | assert.equal(importsCounter, 1) 699 | }) 700 | 701 | test('do not cache lazily imported policies when import function is not shared by reference', async ({ 702 | assert, 703 | }) => { 704 | let importsCounter: number = 0 705 | 706 | class User { 707 | declare id: number 708 | declare email: string 709 | } 710 | 711 | class PostPolicy extends BasePolicy { 712 | resolvePermissions() {} 713 | 714 | view(_: User): AuthorizerResponse { 715 | return true 716 | } 717 | 718 | viewAll(_: User): AuthorizerResponse { 719 | return false 720 | } 721 | } 722 | 723 | const bouncer = new Bouncer(new User(), undefined, { 724 | PostPolicy: async () => { 725 | importsCounter++ 726 | return { 727 | default: PostPolicy, 728 | } 729 | }, 730 | }) 731 | const bouncer1 = new Bouncer(new User(), undefined, { 732 | PostPolicy: async () => { 733 | importsCounter++ 734 | return { 735 | default: PostPolicy, 736 | } 737 | }, 738 | }) 739 | 740 | const canView = await bouncer.with('PostPolicy').execute('view') 741 | assert.isTrue(canView.authorized) 742 | 743 | const canViewAll = await bouncer1.with('PostPolicy').execute('viewAll') 744 | assert.isFalse(canViewAll.authorized) 745 | 746 | assert.equal(importsCounter, 2) 747 | }) 748 | 749 | test('deny access when authorizing for guests', async ({ assert }) => { 750 | class User { 751 | declare id: number 752 | declare email: string 753 | } 754 | 755 | class PostPolicy extends BasePolicy { 756 | resolvePermissions() {} 757 | 758 | view(_: User): AuthorizerResponse { 759 | return true 760 | } 761 | 762 | viewAll(_: User): AuthorizerResponse { 763 | return false 764 | } 765 | } 766 | 767 | const bouncer = new Bouncer(null) 768 | const canView = await bouncer.with(PostPolicy).execute('view') 769 | assert.isFalse(canView.authorized) 770 | 771 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 772 | assert.isFalse(canViewAll.authorized) 773 | }) 774 | 775 | test('invoke action that allows guest users', async ({ assert }) => { 776 | class User { 777 | declare id: number 778 | declare email: string 779 | } 780 | 781 | class PostPolicy extends BasePolicy { 782 | resolvePermissions() {} 783 | 784 | @allowGuest() 785 | view(_: User | null): AuthorizerResponse { 786 | return true 787 | } 788 | 789 | viewAll(_: User): AuthorizerResponse { 790 | return false 791 | } 792 | } 793 | 794 | const bouncer = new Bouncer(null) 795 | const canView = await bouncer.with(PostPolicy).execute('view') 796 | assert.isTrue(canView.authorized) 797 | 798 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 799 | assert.isFalse(canViewAll.authorized) 800 | }) 801 | 802 | test('throw error when policy method is not defined', async () => { 803 | class User { 804 | declare id: number 805 | declare email: string 806 | } 807 | 808 | class PostPolicy extends BasePolicy { 809 | resolvePermissions() {} 810 | 811 | view(_: User): AuthorizerResponse { 812 | return true 813 | } 814 | 815 | viewAll(_: User): AuthorizerResponse { 816 | return false 817 | } 818 | } 819 | 820 | const bouncer = new Bouncer(new User(), undefined, { 821 | PostPolicy: async () => { 822 | return { 823 | default: PostPolicy, 824 | } 825 | }, 826 | }) 827 | 828 | const postsPolicy = bouncer.with('PostPolicy') 829 | 830 | // @ts-expect-error 831 | await postsPolicy.execute('foo') 832 | }).throws('Cannot find method "foo" on "[class PostPolicy]"') 833 | 834 | test('construct policy using the container', async ({ assert }) => { 835 | class User { 836 | declare id: number 837 | declare email: string 838 | } 839 | 840 | class PermissionsResolver { 841 | resolve() { 842 | return ['can-view'] 843 | } 844 | } 845 | 846 | @inject() 847 | class PostPolicy extends BasePolicy { 848 | constructor(protected permissionsResolver: PermissionsResolver) { 849 | super() 850 | } 851 | 852 | view(_: User): AuthorizerResponse { 853 | return this.permissionsResolver.resolve().includes('can-view') 854 | } 855 | 856 | viewAll(_: User): AuthorizerResponse { 857 | return this.permissionsResolver.resolve().includes('can-view-all') 858 | } 859 | } 860 | 861 | const bouncer = new Bouncer(new User()) 862 | bouncer.setContainerResolver(new Container().createResolver()) 863 | 864 | const canView = await bouncer.with(PostPolicy).execute('view') 865 | assert.isTrue(canView.authorized) 866 | 867 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 868 | assert.isFalse(canViewAll.authorized) 869 | }) 870 | 871 | test('construct pre-registered policy using the container', async ({ assert }) => { 872 | class User { 873 | declare id: number 874 | declare email: string 875 | } 876 | 877 | class PermissionsResolver { 878 | resolve() { 879 | return ['can-view'] 880 | } 881 | } 882 | 883 | @inject() 884 | class PostPolicy extends BasePolicy { 885 | constructor(protected permissionsResolver: PermissionsResolver) { 886 | super() 887 | } 888 | 889 | view(_: User): AuthorizerResponse { 890 | return this.permissionsResolver.resolve().includes('can-view') 891 | } 892 | 893 | viewAll(_: User): AuthorizerResponse { 894 | return this.permissionsResolver.resolve().includes('can-view-all') 895 | } 896 | } 897 | 898 | const bouncer = new Bouncer(new User(), undefined, { 899 | PostPolicy: async () => { 900 | return { 901 | default: PostPolicy, 902 | } 903 | }, 904 | }) 905 | bouncer.setContainerResolver(new Container().createResolver()) 906 | 907 | const canView = await bouncer.with('PostPolicy').execute('view') 908 | assert.isTrue(canView.authorized) 909 | 910 | const canViewAll = await bouncer.with('PostPolicy').execute('viewAll') 911 | assert.isFalse(canViewAll.authorized) 912 | }) 913 | 914 | test('check if a user is allowed or denied access', async ({ assert }) => { 915 | class User { 916 | declare id: number 917 | declare email: string 918 | } 919 | 920 | class PostPolicy extends BasePolicy { 921 | resolvePermissions() {} 922 | 923 | async view(_: User): Promise { 924 | return true 925 | } 926 | 927 | viewAll(_: User): AuthorizerResponse { 928 | return false 929 | } 930 | } 931 | 932 | const bouncer = new Bouncer(new User(), undefined, { 933 | PostPolicy: async () => { 934 | return { 935 | default: PostPolicy, 936 | } 937 | }, 938 | }) 939 | 940 | const postsPolicy = bouncer.with('PostPolicy') 941 | 942 | assert.isTrue(await postsPolicy.allows('view')) 943 | assert.isFalse(await postsPolicy.allows('viewAll')) 944 | 945 | assert.isFalse(await postsPolicy.denies('view')) 946 | assert.isTrue(await postsPolicy.denies('viewAll')) 947 | }) 948 | 949 | test('authorize for an action', async ({ assert }) => { 950 | class User { 951 | declare id: number 952 | declare email: string 953 | } 954 | 955 | class PostPolicy extends BasePolicy { 956 | resolvePermissions() {} 957 | 958 | view(_: User): AuthorizerResponse { 959 | return true 960 | } 961 | 962 | viewAll(_: User): AuthorizerResponse { 963 | return false 964 | } 965 | } 966 | 967 | const bouncer = new Bouncer(new User(), undefined, { 968 | PostPolicy: async () => { 969 | return { 970 | default: PostPolicy, 971 | } 972 | }, 973 | }) 974 | 975 | const postsPolicy = bouncer.with('PostPolicy') 976 | await assert.doesNotRejects(() => postsPolicy.authorize('view')) 977 | await assert.rejects(() => postsPolicy.authorize('viewAll'), 'Access denied') 978 | }) 979 | }) 980 | 981 | test.group('Bouncer | policies | before hook', () => { 982 | test('execute action when hook returns undefined or null', async ({ assert }) => { 983 | let actionsCounter = 0 984 | 985 | class User { 986 | declare id: number 987 | declare email: string 988 | } 989 | 990 | class PostPolicy extends BasePolicy { 991 | before() { 992 | return 993 | } 994 | 995 | view(_: User): AuthorizerResponse { 996 | actionsCounter++ 997 | return true 998 | } 999 | 1000 | viewAll(_: User): AuthorizerResponse { 1001 | actionsCounter++ 1002 | return false 1003 | } 1004 | } 1005 | 1006 | const bouncer = new Bouncer(new User()) 1007 | const canView = await bouncer.with(PostPolicy).execute('view') 1008 | assert.isTrue(canView.authorized) 1009 | 1010 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1011 | assert.isFalse(canViewAll.authorized) 1012 | 1013 | assert.equal(actionsCounter, 2) 1014 | }) 1015 | 1016 | test('deny access when before hook returns false', async ({ assert }) => { 1017 | let actionsCounter = 0 1018 | 1019 | class User { 1020 | declare id: number 1021 | declare email: string 1022 | } 1023 | 1024 | class PostPolicy extends BasePolicy { 1025 | before() { 1026 | return false 1027 | } 1028 | 1029 | view(_: User): AuthorizerResponse { 1030 | actionsCounter++ 1031 | return true 1032 | } 1033 | 1034 | viewAll(_: User): AuthorizerResponse { 1035 | actionsCounter++ 1036 | return false 1037 | } 1038 | } 1039 | 1040 | const bouncer = new Bouncer(new User()) 1041 | const canView = await bouncer.with(PostPolicy).execute('view') 1042 | assert.isFalse(canView.authorized) 1043 | 1044 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1045 | assert.isFalse(canViewAll.authorized) 1046 | 1047 | assert.equal(actionsCounter, 0) 1048 | }) 1049 | 1050 | test('return custom response from before hook', async ({ assert }) => { 1051 | let actionsCounter = 0 1052 | 1053 | class User { 1054 | declare id: number 1055 | declare email: string 1056 | } 1057 | 1058 | class PostPolicy extends BasePolicy { 1059 | before() { 1060 | return AuthorizationResponse.deny('Post not found', 404) 1061 | } 1062 | 1063 | view(_: User): AuthorizerResponse { 1064 | actionsCounter++ 1065 | return true 1066 | } 1067 | 1068 | viewAll(_: User): AuthorizerResponse { 1069 | actionsCounter++ 1070 | return false 1071 | } 1072 | } 1073 | 1074 | const bouncer = new Bouncer(new User()) 1075 | const canView = await bouncer.with(PostPolicy).execute('view') 1076 | assert.isFalse(canView.authorized) 1077 | assert.equal(canView.message, 'Post not found') 1078 | assert.equal(canView.status, 404) 1079 | 1080 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1081 | assert.isFalse(canViewAll.authorized) 1082 | assert.equal(canViewAll.message, 'Post not found') 1083 | assert.equal(canViewAll.status, 404) 1084 | 1085 | assert.equal(actionsCounter, 0) 1086 | }) 1087 | }) 1088 | 1089 | test.group('Bouncer | policies | after hook', () => { 1090 | test('passthrough action response when hook returns undefined', async ({ assert }) => { 1091 | let actionsCounter = 0 1092 | 1093 | class User { 1094 | declare id: number 1095 | declare email: string 1096 | } 1097 | 1098 | class PostPolicy extends BasePolicy { 1099 | after() { 1100 | return 1101 | } 1102 | 1103 | view(_: User): AuthorizerResponse { 1104 | actionsCounter++ 1105 | return true 1106 | } 1107 | 1108 | viewAll(_: User): AuthorizerResponse { 1109 | actionsCounter++ 1110 | return false 1111 | } 1112 | } 1113 | 1114 | const bouncer = new Bouncer(new User()) 1115 | const canView = await bouncer.with(PostPolicy).execute('view') 1116 | assert.isTrue(canView.authorized) 1117 | 1118 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1119 | assert.isFalse(canViewAll.authorized) 1120 | 1121 | assert.equal(actionsCounter, 2) 1122 | }) 1123 | 1124 | test('overwrite action response from after hook', async ({ assert }) => { 1125 | let actionsCounter = 0 1126 | 1127 | class User { 1128 | declare id: number 1129 | declare email: string 1130 | } 1131 | 1132 | class PostPolicy extends BasePolicy { 1133 | after() { 1134 | return false 1135 | } 1136 | 1137 | view(_: User): AuthorizerResponse { 1138 | actionsCounter++ 1139 | return true 1140 | } 1141 | 1142 | viewAll(_: User): AuthorizerResponse { 1143 | actionsCounter++ 1144 | return false 1145 | } 1146 | } 1147 | 1148 | const bouncer = new Bouncer(new User()) 1149 | const canView = await bouncer.with(PostPolicy).execute('view') 1150 | assert.isFalse(canView.authorized) 1151 | 1152 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1153 | assert.isFalse(canViewAll.authorized) 1154 | 1155 | assert.equal(actionsCounter, 2) 1156 | }) 1157 | 1158 | test('overwrite before hook response from after response', async ({ assert }) => { 1159 | let actionsCounter = 0 1160 | 1161 | class User { 1162 | declare id: number 1163 | declare email: string 1164 | } 1165 | 1166 | class PostPolicy extends BasePolicy { 1167 | before() { 1168 | return AuthorizationResponse.deny('Post not found', 404) 1169 | } 1170 | 1171 | after() { 1172 | return AuthorizationResponse.allow() 1173 | } 1174 | 1175 | view(_: User): AuthorizerResponse { 1176 | actionsCounter++ 1177 | return true 1178 | } 1179 | 1180 | viewAll(_: User): AuthorizerResponse { 1181 | actionsCounter++ 1182 | return false 1183 | } 1184 | } 1185 | 1186 | const bouncer = new Bouncer(new User()) 1187 | const canView = await bouncer.with(PostPolicy).execute('view') 1188 | assert.isTrue(canView.authorized) 1189 | 1190 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1191 | assert.isTrue(canViewAll.authorized) 1192 | 1193 | assert.equal(actionsCounter, 0) 1194 | }) 1195 | }) 1196 | --------------------------------------------------------------------------------