├── .editorconfig ├── .github └── workflows │ ├── checks.yml │ ├── labels.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── commands └── make_policy.ts ├── configure.ts ├── eslint.config.js ├── index.ts ├── package.json ├── providers └── bouncer_provider.ts ├── src ├── abilities_builder.ts ├── ability.ts ├── base_policy.ts ├── bouncer.ts ├── debug.ts ├── decorators │ └── action.ts ├── errors.ts ├── plugins │ └── edge.ts ├── policy_authorizer.ts ├── response.ts └── types.ts ├── stubs ├── abilities.stub ├── initialize_bouncer_middleware.stub ├── main.ts ├── make │ └── policy │ │ └── main.stub └── policies.stub ├── tests ├── abilities_builder.spec.ts ├── authorization_exception.spec.ts ├── bouncer │ ├── abilities.spec.ts │ └── policies.spec.ts ├── commands │ └── make_policy.spec.ts ├── configure.spec.ts ├── helpers.ts ├── plugins │ └── edge.spec.ts └── response.spec.ts └── tsconfig.json /.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/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | test: 9 | uses: adonisjs/core/.github/workflows/test.yml@main 10 | with: 11 | install-pnpm: true 12 | 13 | lint: 14 | uses: adonisjs/.github/.github/workflows/lint.yml@main 15 | 16 | typecheck: 17 | uses: adonisjs/.github/.github/workflows/typecheck.yml@main 18 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | - name: git config 20 | run: | 21 | git config user.name "${GITHUB_ACTOR}" 22 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 23 | - name: Init npm config 24 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | - run: npm install 28 | - run: npm run release -- --ci 29 | env: 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 { slash } from '@poppinss/utils' 11 | import { extname, relative } from 'node:path' 12 | import string from '@adonisjs/core/helpers/string' 13 | import { BaseCommand, args, flags } from '@adonisjs/core/ace' 14 | import type { CommandOptions } from '@adonisjs/core/types/ace' 15 | 16 | import { stubsRoot } from '../stubs/main.js' 17 | 18 | export default class MakePolicy extends BaseCommand { 19 | static commandName = 'make:policy' 20 | static description = 'Make a new bouncer policy class' 21 | static options: CommandOptions = { 22 | allowUnknownFlags: true, 23 | } 24 | 25 | /** 26 | * The name of the policy file 27 | */ 28 | @args.string({ description: 'Name of the policy file' }) 29 | declare name: string 30 | 31 | @args.spread({ description: 'Method names to pre-define on the policy', required: false }) 32 | declare actions?: string[] 33 | 34 | @flags.boolean({ 35 | description: 'Auto register the policy inside the app/policies/main.ts file', 36 | showNegatedVariantInHelp: true, 37 | alias: 'r', 38 | }) 39 | declare register?: boolean 40 | 41 | /** 42 | * The model for which to generate the policy. 43 | */ 44 | @flags.string({ description: 'The name of the policy model' }) 45 | declare model?: string 46 | 47 | /** 48 | * Execute command 49 | */ 50 | async run(): Promise { 51 | /** 52 | * Display prompt to know if we should register the policy 53 | * file inside the "app/policies/main.ts" file. 54 | */ 55 | if (this.register === undefined) { 56 | this.register = await this.prompt.confirm( 57 | 'Do you want to register the policy inside the app/policies/main.ts file?' 58 | ) 59 | } 60 | 61 | const codemods = await this.createCodemods() 62 | const { destination } = await codemods.makeUsingStub(stubsRoot, 'make/policy/main.stub', { 63 | flags: this.parsed.flags, 64 | actions: this.actions?.map((action) => string.camelCase(action)) || [], 65 | entity: this.app.generators.createEntity(this.name), 66 | model: this.app.generators.createEntity(this.model || this.name), 67 | }) 68 | 69 | /** 70 | * Do not register when prompt has been denied or "--no-register" 71 | * flag was used 72 | */ 73 | if (!this.register) { 74 | return 75 | } 76 | 77 | /** 78 | * Creative relative path for the policy file from 79 | * the "./app/policies" directory 80 | */ 81 | const policyRelativePath = slash( 82 | relative(this.app.policiesPath(), destination).replace(extname(destination), '') 83 | ) 84 | 85 | /** 86 | * Convert the policy path to pascalCase. Remember, do not take 87 | * the basename in this case, because we want scoped policies 88 | * to be registered with their fully qualified name. 89 | */ 90 | const name = string.pascalCase(policyRelativePath) 91 | 92 | /** 93 | * Register policy 94 | */ 95 | await codemods.registerPolicies([ 96 | { 97 | name: name, 98 | path: `#policies/${policyRelativePath}`, 99 | }, 100 | ]) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /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.js' 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 | await codemods.makeUsingStub(stubsRoot, 'policies.stub', {}) 25 | 26 | /** 27 | * Register provider 28 | */ 29 | await codemods.updateRcFile((rcFile) => { 30 | rcFile.addCommand('@adonisjs/bouncer/commands') 31 | rcFile.addProvider('@adonisjs/bouncer/bouncer_provider') 32 | }) 33 | 34 | /** 35 | * Publish and register middleware 36 | */ 37 | await codemods.makeUsingStub(stubsRoot, 'initialize_bouncer_middleware.stub', { 38 | entity: command.app.generators.createEntity('initialize_bouncer'), 39 | }) 40 | await codemods.registerMiddleware('router', [ 41 | { 42 | path: '#middleware/initialize_bouncer_middleware', 43 | }, 44 | ]) 45 | } 46 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg({ 3 | ignores: ['coverage'], 4 | }) 5 | -------------------------------------------------------------------------------- /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.js' 11 | export { Bouncer } from './src/bouncer.js' 12 | export { configure } from './configure.js' 13 | export { stubsRoot } from './stubs/main.js' 14 | export { BasePolicy } from './src/base_policy.js' 15 | export { AuthorizationResponse } from './src/response.js' 16 | export { action, allowGuest } from './src/decorators/action.js' 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/bouncer", 3 | "version": "3.1.6", 4 | "description": "Authorization layer for AdonisJS", 5 | "engines": { 6 | "node": ">=18.16.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": "tsup-node && 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=ts-node-maintained/register/esm --enable-source-maps bin/test.ts" 39 | }, 40 | "devDependencies": { 41 | "@adonisjs/assembler": "^7.8.2", 42 | "@adonisjs/core": "^6.18.0", 43 | "@adonisjs/eslint-config": "^2.1.0", 44 | "@adonisjs/i18n": "^2.2.0", 45 | "@adonisjs/prettier-config": "^1.4.5", 46 | "@adonisjs/tsconfig": "^1.4.1", 47 | "@japa/assert": "^4.0.1", 48 | "@japa/expect-type": "^2.0.3", 49 | "@japa/file-system": "^2.3.2", 50 | "@japa/runner": "^4.2.0", 51 | "@japa/snapshot": "^2.0.8", 52 | "@release-it/conventional-changelog": "^10.0.1", 53 | "@swc/core": "1.10.7", 54 | "c8": "^10.1.3", 55 | "copyfiles": "^2.4.1", 56 | "cross-env": "^7.0.3", 57 | "del-cli": "^6.0.0", 58 | "edge.js": "^6.2.1", 59 | "eslint": "^9.28.0", 60 | "reflect-metadata": "^0.2.2", 61 | "release-it": "^19.0.3", 62 | "ts-node-maintained": "^10.9.5", 63 | "tsup": "^8.5.0", 64 | "typescript": "^5.8.3" 65 | }, 66 | "dependencies": { 67 | "@poppinss/utils": "^6.9.4" 68 | }, 69 | "peerDependencies": { 70 | "@adonisjs/core": "^6.17.1", 71 | "@adonisjs/i18n": "^2.2.0", 72 | "edge.js": "^6.2.1" 73 | }, 74 | "peerDependenciesMeta": { 75 | "@adonisjs/core": { 76 | "optional": true 77 | }, 78 | "@adonisjs/i18n": { 79 | "optional": true 80 | }, 81 | "edge.js": { 82 | "optional": true 83 | } 84 | }, 85 | "homepage": "https://github.com/adonisjs/bouncer#readme", 86 | "repository": { 87 | "type": "git", 88 | "url": "git+https://github.com/adonisjs/bouncer.git" 89 | }, 90 | "bugs": { 91 | "url": "https://github.com/adonisjs/bouncer/issues" 92 | }, 93 | "keywords": [ 94 | "authorization", 95 | "adonisjs" 96 | ], 97 | "author": "Harminder Virk ", 98 | "contributors": [ 99 | "Julien Ripouteau " 100 | ], 101 | "license": "MIT", 102 | "publishConfig": { 103 | "access": "public", 104 | "provenance": true 105 | }, 106 | "tsup": { 107 | "entry": [ 108 | "./index.ts", 109 | "./src/types.ts", 110 | "./providers/bouncer_provider.ts", 111 | "./commands/make_policy.ts", 112 | "./src/plugins/edge.ts" 113 | ], 114 | "outDir": "./build", 115 | "clean": true, 116 | "format": "esm", 117 | "dts": false, 118 | "sourcemap": false, 119 | "target": "esnext" 120 | }, 121 | "release-it": { 122 | "git": { 123 | "requireCleanWorkingDir": true, 124 | "requireUpstream": true, 125 | "commitMessage": "chore(release): ${version}", 126 | "tagAnnotation": "v${version}", 127 | "push": true, 128 | "tagName": "v${version}" 129 | }, 130 | "github": { 131 | "release": true 132 | }, 133 | "npm": { 134 | "publish": true, 135 | "skipChecks": true 136 | }, 137 | "plugins": { 138 | "@release-it/conventional-changelog": { 139 | "preset": { 140 | "name": "angular" 141 | } 142 | } 143 | } 144 | }, 145 | "c8": { 146 | "reporter": [ 147 | "text", 148 | "html" 149 | ], 150 | "exclude": [ 151 | "tests/**" 152 | ] 153 | }, 154 | "prettier": "@adonisjs/prettier-config" 155 | } 156 | -------------------------------------------------------------------------------- /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.js' 13 | import type { BouncerEvents } from '../src/types.js' 14 | 15 | declare module '@adonisjs/core/types' { 16 | export interface EventsList extends BouncerEvents {} 17 | } 18 | 19 | /** 20 | * Register edge tags and shares the app emitter with Bouncer 21 | */ 22 | export default class BouncerProvider { 23 | constructor(protected app: ApplicationService) {} 24 | 25 | async boot() { 26 | if (this.app.usingEdgeJS) { 27 | const edge = await import('edge.js') 28 | const { edgePluginBouncer } = await import('../src/plugins/edge.js') 29 | edge.default.use(edgePluginBouncer) 30 | } 31 | 32 | Bouncer.emitter = await this.app.container.make('emitter') 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /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.js' 11 | import { AuthorizerResponse, BouncerAbility, BouncerAuthorizer } from './types.js' 12 | 13 | /** 14 | * Abilities builder exposes a chainable API to fluently create an object 15 | * of abilities by chaining the ".define" method. 16 | */ 17 | export class AbilitiesBuilder>> { 18 | constructor(public abilities: Abilities) {} 19 | 20 | /** 21 | * Helper to convert a user defined authorizer function to a bouncer ability 22 | */ 23 | define>( 24 | name: Name, 25 | authorizer: Authorizer, 26 | options?: { allowGuest: boolean } 27 | ) { 28 | this.abilities[name] = ability(options || { allowGuest: false }, authorizer) as any 29 | 30 | return this as unknown as AbilitiesBuilder< 31 | Abilities & { 32 | [K in Name]: Authorizer extends ( 33 | user: infer User, 34 | ...args: infer Args 35 | ) => AuthorizerResponse | Promise 36 | ? { 37 | allowGuest: false 38 | original: Authorizer 39 | execute(user: User | null, ...args: Args): ReturnType 40 | } 41 | : never 42 | } 43 | > 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /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.js' 11 | import { AuthorizerResponse, BouncerAbility, BouncerAuthorizer } from './types.js' 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 | */ 27 | export function ability>( 28 | options: { allowGuest: boolean }, 29 | authorizer: Authorizer 30 | ): AuthorizerToAbility 31 | export function ability>( 32 | authorizer: Authorizer 33 | ): AuthorizerToAbility 34 | export function ability>( 35 | authorizerOrOptions: Authorizer | { allowGuest: boolean }, 36 | authorizer?: Authorizer 37 | ) { 38 | if (typeof authorizerOrOptions === 'function') { 39 | return { 40 | allowGuest: false, 41 | original: authorizerOrOptions, 42 | execute(user, ...args) { 43 | if (user === null && !this.allowGuest) { 44 | return AuthorizationResponse.deny() 45 | } 46 | return this.original(user, ...args) 47 | }, 48 | } satisfies BouncerAbility 49 | } else { 50 | return { 51 | allowGuest: authorizerOrOptions?.allowGuest || false, 52 | original: authorizer!, 53 | execute(user, ...args) { 54 | if (user === null && !this.allowGuest) { 55 | return AuthorizationResponse.deny() 56 | } 57 | return this.original(user, ...args) 58 | }, 59 | } satisfies BouncerAbility 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /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 14 | */ 15 | export abstract class BasePolicy { 16 | static booted: boolean = false 17 | static actionsMetaData: Record = {} 18 | 19 | static boot() { 20 | if (!this.hasOwnProperty('booted')) { 21 | this.booted = false 22 | } 23 | if (this.booted === false) { 24 | this.booted = true 25 | defineStaticProperty(this, 'actionsMetaData', { initialValue: {}, strategy: 'inherit' }) 26 | } 27 | } 28 | 29 | /** 30 | * Set metadata for a action name 31 | */ 32 | static setActionMetaData(actionName: string, options: { allowGuest: boolean }) { 33 | this.boot() 34 | this.actionsMetaData[actionName] = options 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /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 '@poppinss/utils' 12 | import type { EmitterLike } from '@adonisjs/core/types/events' 13 | import { type ContainerResolver } from '@adonisjs/core/container' 14 | 15 | import debug from './debug.js' 16 | import { AuthorizationResponse } from './response.js' 17 | import { E_AUTHORIZATION_FAILURE } from './errors.js' 18 | import { ability as createAbility } from './ability.js' 19 | import { AbilitiesBuilder } from './abilities_builder.js' 20 | import { PolicyAuthorizer } from './policy_authorizer.js' 21 | import type { 22 | LazyImport, 23 | Constructor, 24 | BouncerEvents, 25 | BouncerAbility, 26 | ResponseBuilder, 27 | UnWrapLazyImport, 28 | BouncerAuthorizer, 29 | AuthorizerResponse, 30 | NarrowAbilitiesForAUser, 31 | } from './types.js' 32 | 33 | /** 34 | * Bouncer exposes the API to evaluate bouncer abilities and policies to 35 | * verify if a user is authorized to perform the given action 36 | */ 37 | export class Bouncer< 38 | User extends Record, 39 | Abilities extends Record> | undefined = undefined, 40 | Policies extends Record>> | undefined = undefined, 41 | > { 42 | /** 43 | * Response builder is used to normalize bouncer responses 44 | */ 45 | static responseBuilder: ResponseBuilder = (response) => { 46 | return typeof response === 'boolean' ? new AuthorizationResponse(response) : response 47 | } 48 | 49 | /** 50 | * Define an ability using the AbilityBuilder 51 | */ 52 | static define>( 53 | name: Name, 54 | authorizer: Authorizer, 55 | options?: { allowGuest: boolean } 56 | ) { 57 | return new AbilitiesBuilder({}).define(name, authorizer, options) 58 | } 59 | 60 | /** 61 | * Emitter to emit events 62 | */ 63 | static emitter?: EmitterLike 64 | 65 | /** 66 | * Define a bouncer ability from a callback 67 | */ 68 | static ability = createAbility 69 | 70 | /** 71 | * User resolver to lazily resolve the user 72 | */ 73 | #userOrResolver: User | (() => User | null) | null 74 | 75 | /** 76 | * Reference to the resolved user 77 | */ 78 | #user?: User | null 79 | 80 | /** 81 | * Pre-defined abilities 82 | */ 83 | abilities?: Abilities 84 | 85 | /** 86 | * Pre-defined policies 87 | */ 88 | policies?: Policies 89 | 90 | /** 91 | * Reference to the container resolver to construct 92 | * policy classes. 93 | */ 94 | #containerResolver?: ContainerResolver 95 | 96 | /** 97 | * An object with helpers to be shared with Edge for 98 | * performing authorization. 99 | */ 100 | edgeHelpers: { 101 | bouncer: { 102 | parent: Bouncer 103 | can(action: string, ...args: any[]): Promise 104 | cannot(action: string, ...args: any[]): Promise 105 | } 106 | } = { 107 | bouncer: { 108 | parent: this, 109 | can(action: string, ...args: any[]) { 110 | const [policyName, ...policyMethods] = action.split('.') 111 | if (policyMethods.length) { 112 | return this.parent.with(policyName as any).allows(policyMethods.join('.'), ...args) 113 | } 114 | return this.parent.allows(policyName as any, ...args) 115 | }, 116 | cannot(action: string, ...args: any[]) { 117 | const [policyName, ...policyMethods] = action.split('.') 118 | if (policyMethods.length) { 119 | return this.parent.with(policyName as any).denies(policyMethods.join('.'), ...args) 120 | } 121 | return this.parent.denies(policyName as any, ...args) 122 | }, 123 | }, 124 | } 125 | 126 | constructor( 127 | userOrResolver: User | (() => User | null) | null, 128 | abilities?: Abilities, 129 | policies?: Policies 130 | ) { 131 | this.#userOrResolver = userOrResolver 132 | this.abilities = abilities 133 | this.policies = policies 134 | } 135 | 136 | /** 137 | * Returns reference to the user object 138 | */ 139 | #getUser() { 140 | if (this.#user === undefined) { 141 | if (typeof this.#userOrResolver === 'function') { 142 | this.#user = this.#userOrResolver() 143 | } else { 144 | this.#user = this.#userOrResolver 145 | } 146 | } 147 | 148 | return this.#user 149 | } 150 | 151 | /** 152 | * Emits the event and sends normalized response 153 | */ 154 | #emitAndRespond(abilitiy: string, result: boolean | AuthorizationResponse, args: any[]) { 155 | const response = Bouncer.responseBuilder(result) 156 | if (Bouncer.emitter) { 157 | Bouncer.emitter.emit('authorization:finished', { 158 | user: this.#user, 159 | action: abilitiy, 160 | response, 161 | parameters: args, 162 | }) 163 | } 164 | 165 | return response 166 | } 167 | 168 | /** 169 | * Returns an instance of PolicyAuthorizer. PolicyAuthorizer is 170 | * used to authorize user and actions using a given policy 171 | */ 172 | with( 173 | policy: Policy 174 | ): Policies extends Record>> 175 | ? PolicyAuthorizer> 176 | : never 177 | with>(policy: Policy): PolicyAuthorizer 178 | with(policy: Policy) { 179 | if (typeof policy !== 'function') { 180 | /** 181 | * Ensure the policy is pre-registered 182 | */ 183 | if (!this.policies || !this.policies[policy]) { 184 | throw new RuntimeException(`Invalid bouncer policy "${inspect(policy)}"`) 185 | } 186 | 187 | return new PolicyAuthorizer(this.#getUser(), this.policies[policy], Bouncer.responseBuilder) 188 | .setContainerResolver(this.#containerResolver) 189 | .setEmitter(Bouncer.emitter) 190 | } 191 | 192 | return new PolicyAuthorizer(this.#getUser(), policy, Bouncer.responseBuilder) 193 | .setContainerResolver(this.#containerResolver) 194 | .setEmitter(Bouncer.emitter) 195 | } 196 | 197 | /** 198 | * Set a container resolver to use for resolving policies 199 | */ 200 | setContainerResolver(containerResolver?: ContainerResolver): this { 201 | this.#containerResolver = containerResolver 202 | return this 203 | } 204 | 205 | /** 206 | * Execute an ability by reference 207 | */ 208 | execute>( 209 | ability: Ability, 210 | ...args: Ability extends { 211 | original: ( 212 | user: User, 213 | ...args: infer Args 214 | ) => AuthorizerResponse | Promise 215 | } 216 | ? Args 217 | : never 218 | ): Promise 219 | 220 | /** 221 | * Execute an ability from the list of pre-defined abilities 222 | */ 223 | execute>( 224 | ability: Ability, 225 | ...args: Abilities[Ability] extends { 226 | original: ( 227 | user: User, 228 | ...args: infer Args 229 | ) => AuthorizerResponse | Promise 230 | } 231 | ? Args 232 | : never 233 | ): Promise 234 | 235 | async execute(ability: any, ...args: any[]): Promise { 236 | /** 237 | * Executing ability from a pre-defined list of abilities 238 | */ 239 | if (this.abilities && this.abilities[ability]) { 240 | debug('executing pre-registered ability "%s"', ability) 241 | return this.#emitAndRespond( 242 | ability, 243 | await this.abilities[ability].execute(this.#getUser(), ...args), 244 | args 245 | ) 246 | } 247 | 248 | /** 249 | * Ensure value is an ability reference or throw error 250 | */ 251 | if (!ability || typeof ability !== 'object' || 'execute' in ability === false) { 252 | throw new RuntimeException(`Invalid bouncer ability "${inspect(ability)}"`) 253 | } 254 | 255 | /** 256 | * Executing ability by reference 257 | */ 258 | if (debug.enabled) { 259 | debug('executing ability "%s"', ability.name) 260 | } 261 | 262 | return this.#emitAndRespond( 263 | ability.original.name, 264 | await (ability as BouncerAbility).execute(this.#getUser(), ...args), 265 | args 266 | ) 267 | } 268 | 269 | /** 270 | * Check if a user is allowed to perform an action using 271 | * the ability provided by reference 272 | */ 273 | allows>( 274 | ability: Ability, 275 | ...args: Ability extends { 276 | original: ( 277 | user: User, 278 | ...args: infer Args 279 | ) => AuthorizerResponse | Promise 280 | } 281 | ? Args 282 | : never 283 | ): Promise 284 | 285 | /** 286 | * Check if a user is allowed to perform an action using 287 | * the ability from the pre-defined list of abilities 288 | */ 289 | allows>( 290 | ability: Ability, 291 | ...args: Abilities[Ability] extends { 292 | original: ( 293 | user: User, 294 | ...args: infer Args 295 | ) => AuthorizerResponse | Promise 296 | } 297 | ? Args 298 | : never 299 | ): Promise 300 | async allows(ability: any, ...args: any[]): Promise { 301 | const response = await this.execute(ability, ...args) 302 | return response.authorized 303 | } 304 | 305 | /** 306 | * Check if a user is denied from performing an action using 307 | * the ability provided by reference 308 | */ 309 | denies>( 310 | action: Action, 311 | ...args: Action extends { 312 | original: ( 313 | user: User, 314 | ...args: infer Args 315 | ) => AuthorizerResponse | Promise 316 | } 317 | ? Args 318 | : never 319 | ): Promise 320 | 321 | /** 322 | * Check if a user is denied from performing an action using 323 | * the ability from the pre-defined list of abilities 324 | */ 325 | denies>( 326 | action: Action, 327 | ...args: Abilities[Action] extends { 328 | original: ( 329 | user: User, 330 | ...args: infer Args 331 | ) => AuthorizerResponse | Promise 332 | } 333 | ? Args 334 | : never 335 | ): Promise 336 | async denies(action: any, ...args: any[]): Promise { 337 | const response = await this.execute(action, ...args) 338 | return !response.authorized 339 | } 340 | 341 | /** 342 | * Authorize a user against for a given ability 343 | * 344 | * @throws AuthorizationException 345 | */ 346 | authorize>( 347 | action: Action, 348 | ...args: Action extends { 349 | original: ( 350 | user: User, 351 | ...args: infer Args 352 | ) => AuthorizerResponse | Promise 353 | } 354 | ? Args 355 | : never 356 | ): Promise 357 | 358 | /** 359 | * Authorize a user against a given ability 360 | * 361 | * @throws {@link E_AUTHORIZATION_FAILURE} 362 | */ 363 | authorize>( 364 | ability: Ability, 365 | ...args: Abilities[Ability] extends { 366 | original: ( 367 | user: User, 368 | ...args: infer Args 369 | ) => AuthorizerResponse | Promise 370 | } 371 | ? Args 372 | : never 373 | ): Promise 374 | async authorize(ability: any, ...args: any[]): Promise { 375 | const response = await this.execute(ability, ...args) 376 | if (!response.authorized) { 377 | throw new E_AUTHORIZATION_FAILURE(response) 378 | } 379 | } 380 | 381 | /** 382 | * Create AuthorizationResponse to deny access 383 | */ 384 | deny(message: string, status?: number) { 385 | return AuthorizationResponse.deny(message, status) 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /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 | export default debuglog('adonisjs:bouncer') 13 | -------------------------------------------------------------------------------- /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.js' 11 | 12 | /** 13 | * Define bouncer action metadata on a policy class method 14 | */ 15 | export function action(options: { allowGuest: boolean }) { 16 | return function (target: BasePolicy, property: string) { 17 | const Policy = target.constructor as typeof BasePolicy 18 | Policy.boot() 19 | Policy.setActionMetaData(property, options) 20 | } 21 | } 22 | 23 | /** 24 | * Allow guests on a policy action 25 | */ 26 | export function allowGuest() { 27 | return action({ allowGuest: true }) 28 | } 29 | -------------------------------------------------------------------------------- /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 | import type { I18n } from '@adonisjs/i18n' 11 | import { Exception } from '@poppinss/utils' 12 | import type { HttpContext } from '@adonisjs/core/http' 13 | import type { AuthorizationResponse } from './response.js' 14 | 15 | /** 16 | * AuthorizationException is raised by bouncer when an ability or 17 | * policy denies access to a user for a given resource. 18 | */ 19 | class AuthorizationException extends Exception { 20 | message = 'Access denied' 21 | status = 403 22 | code = 'E_AUTHORIZATION_FAILURE' 23 | 24 | /** 25 | * Error identifier to lookup translation message 26 | */ 27 | identifier = 'errors.E_AUTHORIZATION_FAILURE' 28 | 29 | constructor( 30 | public response: AuthorizationResponse, 31 | options?: ErrorOptions & { 32 | code?: string 33 | status?: number 34 | } 35 | ) { 36 | super(response.message, options) 37 | } 38 | 39 | /** 40 | * Returns the message to be sent in the HTTP response. 41 | * Feel free to override this method and return a custom 42 | * response. 43 | */ 44 | getResponseMessage(ctx: HttpContext) { 45 | /** 46 | * Give preference to response message and then fallback 47 | * to error message 48 | */ 49 | const message = this.response.message || this.message 50 | 51 | /** 52 | * Use translation when using i18n package 53 | */ 54 | if ('i18n' in ctx) { 55 | /** 56 | * Give preference to response translation and fallback to static 57 | * identifier. 58 | */ 59 | const identifier = this.response.translation?.identifier || this.identifier 60 | const data = this.response.translation?.data || {} 61 | return (ctx.i18n as I18n).t(identifier, data, message) 62 | } 63 | 64 | return message 65 | } 66 | 67 | async handle(_: AuthorizationException, ctx: HttpContext) { 68 | const status = this.response.status || this.status 69 | const message = this.getResponseMessage(ctx) 70 | 71 | switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { 72 | case 'html': 73 | case null: 74 | ctx.response.status(status).send(message) 75 | break 76 | case 'json': 77 | ctx.response.status(status).send({ 78 | errors: [ 79 | { 80 | message, 81 | }, 82 | ], 83 | }) 84 | break 85 | case 'application/vnd.api+json': 86 | ctx.response.status(status).send({ 87 | errors: [ 88 | { 89 | code: this.code, 90 | title: message, 91 | }, 92 | ], 93 | }) 94 | break 95 | } 96 | } 97 | } 98 | 99 | export const E_AUTHORIZATION_FAILURE = AuthorizationException 100 | -------------------------------------------------------------------------------- /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.js' 12 | 13 | /** 14 | * The edge plugin for Bouncer to perform authorization checks 15 | * within templates. 16 | */ 17 | export const edgePluginBouncer: PluginFn = (edge) => { 18 | debug('registering bouncer tags with edge') 19 | 20 | edge.registerTag({ 21 | tagName: 'can', 22 | seekable: true, 23 | block: true, 24 | compile(parser, buffer, token) { 25 | const expression = parser.utils.transformAst( 26 | parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), 27 | token.filename, 28 | parser 29 | ) 30 | 31 | const openingBrace = expression.type !== 'SequenceExpression' ? '(' : '' 32 | const closingBrace = expression.type !== 'SequenceExpression' ? ')' : '' 33 | const parameters = parser.utils.stringify(expression) 34 | const methodCall = `can${openingBrace}${parameters}${closingBrace}` 35 | 36 | /** 37 | * Write an if statement 38 | */ 39 | buffer.writeStatement( 40 | `if (await state.bouncer.${methodCall}) {`, 41 | token.filename, 42 | token.loc.start.line 43 | ) 44 | 45 | /** 46 | * Process component children using the parser 47 | */ 48 | token.children.forEach((child) => { 49 | parser.processToken(child, buffer) 50 | }) 51 | 52 | /** 53 | * Close if statement 54 | */ 55 | buffer.writeStatement(`}`, token.filename, token.loc.start.line) 56 | }, 57 | }) 58 | 59 | edge.registerTag({ 60 | tagName: 'cannot', 61 | seekable: true, 62 | block: true, 63 | compile(parser, buffer, token) { 64 | const expression = parser.utils.transformAst( 65 | parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), 66 | token.filename, 67 | parser 68 | ) 69 | 70 | const openingBrace = expression.type !== 'SequenceExpression' ? '(' : '' 71 | const closingBrace = expression.type !== 'SequenceExpression' ? ')' : '' 72 | const parameters = parser.utils.stringify(expression) 73 | const methodCall = `cannot${openingBrace}${parameters}${closingBrace}` 74 | 75 | /** 76 | * Write an if statement 77 | */ 78 | buffer.writeStatement( 79 | `if (await state.bouncer.${methodCall}) {`, 80 | token.filename, 81 | token.loc.start.line 82 | ) 83 | 84 | /** 85 | * Process component children using the parser 86 | */ 87 | token.children.forEach((child) => { 88 | parser.processToken(child, buffer) 89 | }) 90 | 91 | /** 92 | * Close if statement 93 | */ 94 | buffer.writeStatement(`}`, token.filename, token.loc.start.line) 95 | }, 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /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 | import { RuntimeException } from '@poppinss/utils' 10 | import type { EmitterLike } from '@adonisjs/core/types/events' 11 | import type { ContainerResolver } from '@adonisjs/core/container' 12 | 13 | import debug from './debug.js' 14 | import { BasePolicy } from './base_policy.js' 15 | import { E_AUTHORIZATION_FAILURE } from './errors.js' 16 | import { AuthorizationResponse } from './response.js' 17 | import type { 18 | LazyImport, 19 | Constructor, 20 | BouncerEvents, 21 | ResponseBuilder, 22 | GetPolicyMethods, 23 | AuthorizerResponse, 24 | } from './types.js' 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 | */ 35 | export class PolicyAuthorizer< 36 | User extends Record, 37 | Policy extends Constructor, 38 | > { 39 | #policy?: Policy 40 | #policyImporter: LazyImport | Policy 41 | 42 | /** 43 | * Reference to the resolved user 44 | */ 45 | #user?: User | null 46 | 47 | /** 48 | * Reference to the IoC container resolver. It is needed 49 | * to optionally construct policy class instances 50 | */ 51 | #containerResolver?: ContainerResolver 52 | 53 | /** 54 | * Emitter to emit events 55 | */ 56 | #emitter?: EmitterLike 57 | 58 | /** 59 | * Response builder is used to normalize bouncer responses 60 | */ 61 | #responseBuilder: ResponseBuilder 62 | 63 | constructor( 64 | user: User | null, 65 | policy: LazyImport | Policy, 66 | responseBuilder: ResponseBuilder 67 | ) { 68 | this.#user = user 69 | this.#policyImporter = policy 70 | this.#responseBuilder = responseBuilder 71 | } 72 | 73 | /** 74 | * Check if a policy method allows guest users 75 | */ 76 | #policyAllowsGuests(Policy: Constructor, action: string): boolean { 77 | const actionsMetaData = 78 | 'actionsMetaData' in Policy && 79 | (Policy.actionsMetaData as (typeof BasePolicy)['actionsMetaData']) 80 | 81 | if (!actionsMetaData || !actionsMetaData[action]) { 82 | return false 83 | } 84 | 85 | return !!actionsMetaData[action].allowGuest 86 | } 87 | 88 | /** 89 | * Check to see if policy is defined as a class 90 | */ 91 | #isPolicyAsClass(policy: LazyImport | Policy): policy is Policy { 92 | return typeof policy === 'function' && policy.toString().startsWith('class ') 93 | } 94 | 95 | /** 96 | * Resolves the policy from the importer and caches it for 97 | * repetitive use. 98 | */ 99 | async #resolvePolicy(): Promise> { 100 | /** 101 | * Prefer local reference (if exists) 102 | */ 103 | if (this.#policy && !('hot' in import.meta)) { 104 | return this.#policy 105 | } 106 | 107 | /** 108 | * Read from cache if exists 109 | */ 110 | if (KNOWN_POLICIES_CACHE.has(this.#policyImporter)) { 111 | debug('reading policy from the imports cache %O', this.#policyImporter) 112 | return KNOWN_POLICIES_CACHE.get(this.#policyImporter)! 113 | } 114 | 115 | /** 116 | * Import policy using the importer if a lazy import function 117 | * is provided, otherwise we consider policy to be a class 118 | */ 119 | const policyOrImport = this.#policyImporter 120 | if (this.#isPolicyAsClass(policyOrImport)) { 121 | this.#policy = policyOrImport 122 | } else { 123 | debug('lazily importing policy %O', this.#policyImporter) 124 | const policyExports = await policyOrImport() 125 | this.#policy = policyExports.default 126 | } 127 | 128 | /** 129 | * Cache the resolved value 130 | */ 131 | if (!('hot' in import.meta)) { 132 | KNOWN_POLICIES_CACHE.set(this.#policyImporter, this.#policy) 133 | } 134 | return this.#policy 135 | } 136 | 137 | /** 138 | * Emits the event and sends normalized response 139 | */ 140 | #emitAndRespond(action: any, result: boolean | AuthorizationResponse, args: any[]) { 141 | const response = this.#responseBuilder(result) 142 | if (this.#emitter) { 143 | this.#emitter.emit('authorization:finished', { 144 | user: this.#user, 145 | action: `${this.#policy?.name}.${action}`, 146 | response, 147 | parameters: args, 148 | }) 149 | } 150 | 151 | return response 152 | } 153 | 154 | /** 155 | * Executes the after hook on policy and handles various 156 | * flows around using original or modified response. 157 | */ 158 | async #executeAfterHook( 159 | policy: any, 160 | action: any, 161 | result: boolean | AuthorizationResponse, 162 | args: any[] 163 | ): Promise { 164 | /** 165 | * Return the action response when no after is defined 166 | */ 167 | if (typeof policy.after !== 'function') { 168 | return this.#emitAndRespond(action, result, args) 169 | } 170 | 171 | const modifiedResponse = await policy.after(this.#user, action, result, ...args) 172 | 173 | /** 174 | * If modified response is a valid authorizer response, when use that 175 | * modified response 176 | */ 177 | if ( 178 | typeof modifiedResponse === 'boolean' || 179 | modifiedResponse instanceof AuthorizationResponse 180 | ) { 181 | return this.#emitAndRespond(action, modifiedResponse, args) 182 | } 183 | 184 | /** 185 | * Otherwise fallback to original response 186 | */ 187 | return this.#emitAndRespond(action, result, args) 188 | } 189 | 190 | /** 191 | * Set a container resolver to use for resolving policies 192 | */ 193 | setContainerResolver(containerResolver?: ContainerResolver): this { 194 | this.#containerResolver = containerResolver 195 | return this 196 | } 197 | 198 | /** 199 | * Define the event emitter instance to use for emitting 200 | * authorization events 201 | */ 202 | setEmitter(emitter?: EmitterLike): this { 203 | this.#emitter = emitter 204 | return this 205 | } 206 | 207 | /** 208 | * Execute an action from the list of pre-defined actions 209 | */ 210 | async execute>>( 211 | action: Method, 212 | ...args: InstanceType[Method] extends ( 213 | user: User, 214 | ...args: infer Args 215 | ) => AuthorizerResponse | Promise 216 | ? Args 217 | : never 218 | ): Promise { 219 | const Policy = await this.#resolvePolicy() 220 | 221 | /** 222 | * Create an instance of the class either using the container 223 | * resolver or manually. 224 | */ 225 | const policyInstance = this.#containerResolver 226 | ? await this.#containerResolver.make(Policy) 227 | : new Policy() 228 | 229 | /** 230 | * Ensure the method exists on the policy class otherwise 231 | * raise an exception 232 | */ 233 | if (typeof policyInstance[action] !== 'function') { 234 | throw new RuntimeException( 235 | `Cannot find method "${action as string}" on "[class ${Policy.name}]"` 236 | ) 237 | } 238 | 239 | /** 240 | * Execute before hook and shortcircuit if before hook returns 241 | * a valid authorizer response 242 | */ 243 | let hookResponse: unknown 244 | if (typeof policyInstance.before === 'function') { 245 | hookResponse = await policyInstance.before(this.#user, action, ...args) 246 | } 247 | if (typeof hookResponse === 'boolean' || hookResponse instanceof AuthorizationResponse) { 248 | return this.#executeAfterHook(policyInstance, action, hookResponse, args) 249 | } 250 | 251 | /** 252 | * Disallow action for guest users 253 | */ 254 | if (this.#user === null && !this.#policyAllowsGuests(Policy, action as string)) { 255 | return this.#executeAfterHook(policyInstance, action, AuthorizationResponse.deny(), args) 256 | } 257 | 258 | /** 259 | * Invoke action manually and normalize its response 260 | */ 261 | const response = await policyInstance[action](this.#user, ...args) 262 | return this.#executeAfterHook(policyInstance, action, response, args) 263 | } 264 | 265 | /** 266 | * Check if a user is allowed to perform an action using 267 | * one of the known policy methods 268 | */ 269 | async allows>>( 270 | action: Method, 271 | ...args: InstanceType[Method] extends ( 272 | user: User, 273 | ...args: infer Args 274 | ) => AuthorizerResponse | Promise 275 | ? Args 276 | : never 277 | ): Promise { 278 | const response = await this.execute(action, ...args) 279 | return response.authorized 280 | } 281 | 282 | /** 283 | * Check if a user is denied from performing an action using 284 | * one of the known policy methods 285 | */ 286 | async denies>>( 287 | action: Method, 288 | ...args: InstanceType[Method] extends ( 289 | user: User, 290 | ...args: infer Args 291 | ) => AuthorizerResponse | Promise 292 | ? Args 293 | : never 294 | ): Promise { 295 | const response = await this.execute(action, ...args) 296 | return !response.authorized 297 | } 298 | 299 | /** 300 | * Authorize a user against a given policy action 301 | * 302 | * @throws {@link E_AUTHORIZATION_FAILURE} 303 | */ 304 | async authorize>>( 305 | action: Method, 306 | ...args: InstanceType[Method] extends ( 307 | user: User, 308 | ...args: infer Args 309 | ) => AuthorizerResponse | Promise 310 | ? Args 311 | : never 312 | ): Promise { 313 | const response = await this.execute(action, ...args) 314 | if (!response.authorized) { 315 | throw new E_AUTHORIZATION_FAILURE(response) 316 | } 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /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 | export class AuthorizationResponse { 11 | /** 12 | * Create a deny response 13 | */ 14 | static deny(message?: string, statusCode?: number) { 15 | const response = new AuthorizationResponse(false) 16 | response.message = message 17 | response.status = statusCode 18 | return response 19 | } 20 | 21 | /** 22 | * Create an allowed response 23 | */ 24 | static allow() { 25 | return new AuthorizationResponse(true) 26 | } 27 | 28 | /** 29 | * HTTP status for the authorization response 30 | */ 31 | declare status?: number 32 | 33 | /** 34 | * Response message 35 | */ 36 | declare message?: string 37 | 38 | /** 39 | * Translation identifier to use for creating the 40 | * authorization response 41 | */ 42 | declare translation?: { 43 | identifier: string 44 | data?: Record 45 | } 46 | 47 | constructor(public authorized: boolean) {} 48 | 49 | /** 50 | * Define the translation identifier for the authorization response 51 | */ 52 | t(identifier: string, data?: Record) { 53 | this.translation = { identifier, data } 54 | return this 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /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.js' 11 | 12 | /** 13 | * Representation of a constructor 14 | */ 15 | export type Constructor = new (...args: any[]) => T 16 | 17 | /** 18 | * Representation of a lazy default import 19 | */ 20 | export type LazyImport = () => Promise<{ default: DefaultExport }> 21 | 22 | /** 23 | * Helper to unwrap lazy import 24 | */ 25 | export type UnWrapLazyImport> = Awaited>['default'] 26 | 27 | /** 28 | * Returns a list of methods from a policy class that could be 29 | * used with a specific bouncer instance for a given user 30 | */ 31 | export type GetPolicyMethods = { 32 | [K in keyof Policy]: Policy[K] extends BouncerAuthorizer ? K : never 33 | }[keyof Policy] 34 | 35 | /** 36 | * Narrowing the list of abilities that can be used for 37 | * a specific bouncer instance for a given user 38 | */ 39 | export type NarrowAbilitiesForAUser< 40 | User, 41 | Abilities extends Record> | undefined, 42 | > = { 43 | [K in keyof Abilities]: Abilities[K] extends BouncerAbility ? K : never 44 | }[keyof Abilities] 45 | 46 | /** 47 | * A response that can be returned by an authorizer 48 | */ 49 | export type AuthorizerResponse = boolean | AuthorizationResponse 50 | 51 | /** 52 | * The callback function that authorizes an ability. It should always 53 | * accept the user as the first argument, followed by additional 54 | * arguments. 55 | */ 56 | export type BouncerAuthorizer = ( 57 | user: User, 58 | ...args: any[] 59 | ) => AuthorizerResponse | Promise 60 | 61 | /** 62 | * Representation of a known bouncer ability 63 | */ 64 | export type BouncerAbility = { 65 | allowGuest: boolean 66 | original: BouncerAuthorizer 67 | execute(user: User | null, ...args: any[]): AuthorizerResponse | Promise 68 | } 69 | 70 | /** 71 | * Response builder is used to normalize response to 72 | * an instanceof AuthorizationResponse 73 | */ 74 | export type ResponseBuilder = (response: boolean | AuthorizationResponse) => AuthorizationResponse 75 | 76 | /** 77 | * Events emitted by bouncer 78 | */ 79 | export type BouncerEvents = { 80 | 'authorization:finished': { 81 | user: any 82 | action?: string 83 | parameters: any[] 84 | response: AuthorizationResponse 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /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/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 { policies } from '#policies/main' 7 | import * as abilities from '#abilities/main' 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 | -------------------------------------------------------------------------------- /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 | import { getDirname } from '@poppinss/utils' 11 | 12 | export const stubsRoot = getDirname(import.meta.url) 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /stubs/policies.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.policiesPath('main.ts') }) 3 | }}} 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Bouncer policies 7 | |-------------------------------------------------------------------------- 8 | | 9 | | You may define a collection of policies inside this file and pre-register 10 | | them when creating a new 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 | export const policies = {} 19 | -------------------------------------------------------------------------------- /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.js' 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 | -------------------------------------------------------------------------------- /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.js' 12 | import { AuthorizationResponse } from '../src/response.js' 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 | -------------------------------------------------------------------------------- /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.js' 13 | import { Bouncer } from '../../src/bouncer.js' 14 | import { AuthorizationResponse } from '../../src/response.js' 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 | -------------------------------------------------------------------------------- /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.js' 15 | import { Bouncer } from '../../src/bouncer.js' 16 | import { BasePolicy } from '../../src/base_policy.js' 17 | import { allowGuest } from '../../src/decorators/action.js' 18 | import type { AuthorizerResponse } from '../../src/types.js' 19 | import { AuthorizationResponse } from '../../src/response.js' 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) { 326 | if (_) { 327 | return AuthorizationResponse.deny('Denied') 328 | } 329 | return true 330 | } 331 | 332 | async viewAll(_: User) { 333 | return false 334 | } 335 | 336 | async create(_: User) { 337 | return AuthorizationResponse.deny('Denied') 338 | } 339 | } 340 | 341 | const bouncer = new Bouncer(new User(), undefined, { 342 | PostPolicy: async () => { 343 | return { 344 | default: PostPolicy, 345 | } 346 | }, 347 | }) 348 | 349 | /** 350 | * Both policy references should work, because we cannot infer 351 | * in advance if all the methods of a given policy works 352 | * with a specific user type or not. 353 | */ 354 | await bouncer.with('PostPolicy').execute('view') 355 | await bouncer.with('PostPolicy').execute('viewAll') 356 | await bouncer.with('PostPolicy').execute('create') 357 | 358 | /** 359 | * The resolvePermission method does not accept the user 360 | * and neither returns AuthorizerResponse 361 | */ 362 | // @ts-expect-error 363 | await bouncer.with('PostPolicy').execute('resolvePermissions') 364 | }) 365 | 366 | test('infer policy method arguments', async () => { 367 | class User { 368 | declare id: number 369 | declare email: string 370 | } 371 | class Post { 372 | declare userId: null 373 | declare title: string 374 | } 375 | 376 | class PostPolicy extends BasePolicy { 377 | resolvePermissions() {} 378 | 379 | view(user: User, post: Post): AuthorizerResponse { 380 | return user.id === post.userId 381 | } 382 | 383 | viewAll(_: User): AuthorizerResponse { 384 | return false 385 | } 386 | } 387 | 388 | const bouncer = new Bouncer(new User()) 389 | 390 | /** 391 | * Both policy references should work, because we cannot infer 392 | * in advance if all the methods of a given policy works 393 | * with a specific user type or not. 394 | */ 395 | await bouncer.with(PostPolicy).execute('view', new Post()) 396 | await bouncer.with(PostPolicy).execute('viewAll') 397 | 398 | /** 399 | * Fails because we are not passing an instance of the post 400 | * class 401 | */ 402 | // @ts-expect-error 403 | await bouncer.with(PostPolicy).execute('view') 404 | 405 | /** 406 | * The resolvePermission method does not accept the user 407 | * and neither returns AuthorizerResponse 408 | */ 409 | // @ts-expect-error 410 | await bouncer.with(PostPolicy).execute('resolvePermissions') 411 | }).throws(`Cannot read properties of undefined (reading 'userId')`) 412 | 413 | test('infer policy method arguments of a pre-registered policy', async () => { 414 | class User { 415 | declare id: number 416 | declare email: string 417 | } 418 | class Post { 419 | declare userId: null 420 | declare title: string 421 | } 422 | 423 | class PostPolicy extends BasePolicy { 424 | resolvePermissions() {} 425 | 426 | view(user: User, post: Post): AuthorizerResponse { 427 | return user.id === post.userId 428 | } 429 | 430 | viewAll(_: User): AuthorizerResponse { 431 | return false 432 | } 433 | } 434 | 435 | const bouncer = new Bouncer(new User(), undefined, { 436 | PostPolicy: async () => { 437 | return { 438 | default: PostPolicy, 439 | } 440 | }, 441 | }) 442 | 443 | /** 444 | * Both policy references should work, because we cannot infer 445 | * in advance if all the methods of a given policy works 446 | * with a specific user type or not. 447 | */ 448 | await bouncer.with('PostPolicy').execute('view', new Post()) 449 | await bouncer.with('PostPolicy').execute('viewAll') 450 | 451 | /** 452 | * Fails because we are not passing an instance of the post 453 | * class 454 | */ 455 | // @ts-expect-error 456 | await bouncer.with('PostPolicy').execute('view') 457 | 458 | /** 459 | * The resolvePermission method does not accept the user 460 | * and neither returns AuthorizerResponse 461 | */ 462 | // @ts-expect-error 463 | await bouncer.with('PostPolicy').execute('resolvePermissions') 464 | }).throws(`Cannot read properties of undefined (reading 'userId')`) 465 | 466 | test('infer policy methods for guest users', async () => { 467 | class User { 468 | declare id: number 469 | declare email: string 470 | } 471 | 472 | class PostPolicy extends BasePolicy { 473 | resolvePermissions() {} 474 | 475 | view(_: User | null): AuthorizerResponse { 476 | return true 477 | } 478 | 479 | viewAll(_: User): AuthorizerResponse { 480 | return false 481 | } 482 | } 483 | 484 | const bouncer = new Bouncer(new User()) 485 | 486 | /** 487 | * Both policy references should work, because we cannot infer 488 | * in advance if all the methods of a given policy works 489 | * with a specific user type or not. 490 | */ 491 | await bouncer.with(PostPolicy).execute('view') 492 | await bouncer.with(PostPolicy).execute('viewAll') 493 | 494 | /** 495 | * The resolvePermission method does not accept the user 496 | * and neither returns AuthorizerResponse 497 | */ 498 | // @ts-expect-error 499 | await bouncer.with(PostPolicy).execute('resolvePermissions') 500 | }) 501 | 502 | test('infer policy methods for union of users', async () => { 503 | class User { 504 | declare id: number 505 | declare email: string 506 | } 507 | class Admin { 508 | declare adminId: number 509 | } 510 | 511 | class PostPolicy extends BasePolicy { 512 | resolvePermissions() {} 513 | 514 | view(_: User | Admin): AuthorizerResponse { 515 | return true 516 | } 517 | 518 | viewAll(_: User | Admin): AuthorizerResponse { 519 | return false 520 | } 521 | } 522 | 523 | const bouncer = new Bouncer(new User()) 524 | 525 | /** 526 | * Both policy references should work, because we cannot infer 527 | * in advance if all the methods of a given policy works 528 | * with a specific user type or not. 529 | */ 530 | await bouncer.with(PostPolicy).execute('view') 531 | await bouncer.with(PostPolicy).execute('viewAll') 532 | 533 | /** 534 | * The resolvePermission method does not accept the user 535 | * and neither returns AuthorizerResponse 536 | */ 537 | // @ts-expect-error 538 | await bouncer.with(PostPolicy).execute('resolvePermissions') 539 | }) 540 | }) 541 | 542 | test.group('Bouncer | policies', () => { 543 | test('execute policy action', async ({ assert, cleanup }, done) => { 544 | class User { 545 | declare id: number 546 | declare email: string 547 | } 548 | 549 | class PostPolicy extends BasePolicy { 550 | resolvePermissions() {} 551 | 552 | view(_: User): AuthorizerResponse { 553 | return true 554 | } 555 | 556 | viewAll(_: User): AuthorizerResponse { 557 | return false 558 | } 559 | } 560 | 561 | const emitter = createEmitter() 562 | const bouncer = new Bouncer(new User()) 563 | Bouncer.emitter = emitter 564 | cleanup(() => (Bouncer.emitter = undefined)) 565 | 566 | emitter.on('authorization:finished', (event) => { 567 | assert.instanceOf(event.user, User) 568 | assert.equal(event.action, 'PostPolicy.view') 569 | assert.deepEqual(event.parameters, []) 570 | assert.instanceOf(event.response, AuthorizationResponse) 571 | done() 572 | }) 573 | 574 | const canView = await bouncer.with(PostPolicy).execute('view') 575 | assert.isTrue(canView.authorized) 576 | 577 | Bouncer.emitter = undefined 578 | 579 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 580 | assert.isFalse(canViewAll.authorized) 581 | }).waitForDone() 582 | 583 | test('execute policy action on a pre-registered policy', async ({ assert }) => { 584 | class User { 585 | declare id: number 586 | declare email: string 587 | } 588 | 589 | class PostPolicy extends BasePolicy { 590 | resolvePermissions() {} 591 | 592 | view(_: User): AuthorizerResponse { 593 | return true 594 | } 595 | 596 | viewAll(_: User): AuthorizerResponse { 597 | return false 598 | } 599 | } 600 | 601 | const bouncer = new Bouncer(new User(), undefined, { 602 | PostPolicy: async () => { 603 | return { 604 | default: PostPolicy, 605 | } 606 | }, 607 | }) 608 | 609 | const postsPolicy = bouncer.with('PostPolicy') 610 | 611 | const canView = await postsPolicy.execute('view') 612 | assert.isTrue(canView.authorized) 613 | 614 | const canViewAll = await postsPolicy.execute('viewAll') 615 | assert.isFalse(canViewAll.authorized) 616 | }) 617 | 618 | test('cache lazily imported policies', async ({ assert }) => { 619 | let importsCounter: number = 0 620 | 621 | class User { 622 | declare id: number 623 | declare email: string 624 | } 625 | 626 | class PostPolicy extends BasePolicy { 627 | resolvePermissions() {} 628 | 629 | view(_: User): AuthorizerResponse { 630 | return true 631 | } 632 | 633 | viewAll(_: User): AuthorizerResponse { 634 | return false 635 | } 636 | } 637 | 638 | const bouncer = new Bouncer(new User(), undefined, { 639 | PostPolicy: async () => { 640 | importsCounter++ 641 | return { 642 | default: PostPolicy, 643 | } 644 | }, 645 | }) 646 | const canView = await bouncer.with('PostPolicy').execute('view') 647 | assert.isTrue(canView.authorized) 648 | 649 | const canViewAll = await bouncer.with('PostPolicy').execute('viewAll') 650 | assert.isFalse(canViewAll.authorized) 651 | 652 | assert.equal(importsCounter, 1) 653 | }) 654 | 655 | test('cache lazily imported policies across bouncer instances', async ({ assert }) => { 656 | let importsCounter: number = 0 657 | 658 | class User { 659 | declare id: number 660 | declare email: string 661 | } 662 | 663 | class PostPolicy extends BasePolicy { 664 | resolvePermissions() {} 665 | 666 | view(_: User): AuthorizerResponse { 667 | return true 668 | } 669 | 670 | viewAll(_: User): AuthorizerResponse { 671 | return false 672 | } 673 | } 674 | 675 | const policies = { 676 | PostPolicy: async () => { 677 | importsCounter++ 678 | return { 679 | default: PostPolicy, 680 | } 681 | }, 682 | } 683 | 684 | const bouncer = new Bouncer(new User(), undefined, policies) 685 | const bouncer1 = new Bouncer(new User(), undefined, policies) 686 | 687 | const canView = await bouncer.with('PostPolicy').execute('view') 688 | assert.isTrue(canView.authorized) 689 | 690 | const canViewAll = await bouncer1.with('PostPolicy').execute('viewAll') 691 | assert.isFalse(canViewAll.authorized) 692 | 693 | assert.equal(importsCounter, 1) 694 | }) 695 | 696 | test('do not cache lazily imported policies when import function is not shared by reference', async ({ 697 | assert, 698 | }) => { 699 | let importsCounter: number = 0 700 | 701 | class User { 702 | declare id: number 703 | declare email: string 704 | } 705 | 706 | class PostPolicy extends BasePolicy { 707 | resolvePermissions() {} 708 | 709 | view(_: User): AuthorizerResponse { 710 | return true 711 | } 712 | 713 | viewAll(_: User): AuthorizerResponse { 714 | return false 715 | } 716 | } 717 | 718 | const bouncer = new Bouncer(new User(), undefined, { 719 | PostPolicy: async () => { 720 | importsCounter++ 721 | return { 722 | default: PostPolicy, 723 | } 724 | }, 725 | }) 726 | const bouncer1 = new Bouncer(new User(), undefined, { 727 | PostPolicy: async () => { 728 | importsCounter++ 729 | return { 730 | default: PostPolicy, 731 | } 732 | }, 733 | }) 734 | 735 | const canView = await bouncer.with('PostPolicy').execute('view') 736 | assert.isTrue(canView.authorized) 737 | 738 | const canViewAll = await bouncer1.with('PostPolicy').execute('viewAll') 739 | assert.isFalse(canViewAll.authorized) 740 | 741 | assert.equal(importsCounter, 2) 742 | }) 743 | 744 | test('deny access when authorizing for guests', async ({ assert }) => { 745 | class User { 746 | declare id: number 747 | declare email: string 748 | } 749 | 750 | class PostPolicy extends BasePolicy { 751 | resolvePermissions() {} 752 | 753 | view(_: User): AuthorizerResponse { 754 | return true 755 | } 756 | 757 | viewAll(_: User): AuthorizerResponse { 758 | return false 759 | } 760 | } 761 | 762 | const bouncer = new Bouncer(null) 763 | const canView = await bouncer.with(PostPolicy).execute('view') 764 | assert.isFalse(canView.authorized) 765 | 766 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 767 | assert.isFalse(canViewAll.authorized) 768 | }) 769 | 770 | test('invoke action that allows guest users', async ({ assert }) => { 771 | class User { 772 | declare id: number 773 | declare email: string 774 | } 775 | 776 | class PostPolicy extends BasePolicy { 777 | resolvePermissions() {} 778 | 779 | @allowGuest() 780 | view(_: User | null): AuthorizerResponse { 781 | return true 782 | } 783 | 784 | viewAll(_: User): AuthorizerResponse { 785 | return false 786 | } 787 | } 788 | 789 | const bouncer = new Bouncer(null) 790 | const canView = await bouncer.with(PostPolicy).execute('view') 791 | assert.isTrue(canView.authorized) 792 | 793 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 794 | assert.isFalse(canViewAll.authorized) 795 | }) 796 | 797 | test('throw error when policy method is not defined', async () => { 798 | class User { 799 | declare id: number 800 | declare email: string 801 | } 802 | 803 | class PostPolicy extends BasePolicy { 804 | resolvePermissions() {} 805 | 806 | view(_: User): AuthorizerResponse { 807 | return true 808 | } 809 | 810 | viewAll(_: User): AuthorizerResponse { 811 | return false 812 | } 813 | } 814 | 815 | const bouncer = new Bouncer(new User(), undefined, { 816 | PostPolicy: async () => { 817 | return { 818 | default: PostPolicy, 819 | } 820 | }, 821 | }) 822 | 823 | const postsPolicy = bouncer.with('PostPolicy') 824 | 825 | // @ts-expect-error 826 | await postsPolicy.execute('foo') 827 | }).throws('Cannot find method "foo" on "[class PostPolicy]"') 828 | 829 | test('construct policy using the container', async ({ assert }) => { 830 | class User { 831 | declare id: number 832 | declare email: string 833 | } 834 | 835 | class PermissionsResolver { 836 | resolve() { 837 | return ['can-view'] 838 | } 839 | } 840 | 841 | @inject() 842 | class PostPolicy extends BasePolicy { 843 | constructor(protected permissionsResolver: PermissionsResolver) { 844 | super() 845 | } 846 | 847 | view(_: User): AuthorizerResponse { 848 | return this.permissionsResolver.resolve().includes('can-view') 849 | } 850 | 851 | viewAll(_: User): AuthorizerResponse { 852 | return this.permissionsResolver.resolve().includes('can-view-all') 853 | } 854 | } 855 | 856 | const bouncer = new Bouncer(new User()) 857 | bouncer.setContainerResolver(new Container().createResolver()) 858 | 859 | const canView = await bouncer.with(PostPolicy).execute('view') 860 | assert.isTrue(canView.authorized) 861 | 862 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 863 | assert.isFalse(canViewAll.authorized) 864 | }) 865 | 866 | test('construct pre-registered policy using the container', async ({ assert }) => { 867 | class User { 868 | declare id: number 869 | declare email: string 870 | } 871 | 872 | class PermissionsResolver { 873 | resolve() { 874 | return ['can-view'] 875 | } 876 | } 877 | 878 | @inject() 879 | class PostPolicy extends BasePolicy { 880 | constructor(protected permissionsResolver: PermissionsResolver) { 881 | super() 882 | } 883 | 884 | view(_: User): AuthorizerResponse { 885 | return this.permissionsResolver.resolve().includes('can-view') 886 | } 887 | 888 | viewAll(_: User): AuthorizerResponse { 889 | return this.permissionsResolver.resolve().includes('can-view-all') 890 | } 891 | } 892 | 893 | const bouncer = new Bouncer(new User(), undefined, { 894 | PostPolicy: async () => { 895 | return { 896 | default: PostPolicy, 897 | } 898 | }, 899 | }) 900 | bouncer.setContainerResolver(new Container().createResolver()) 901 | 902 | const canView = await bouncer.with('PostPolicy').execute('view') 903 | assert.isTrue(canView.authorized) 904 | 905 | const canViewAll = await bouncer.with('PostPolicy').execute('viewAll') 906 | assert.isFalse(canViewAll.authorized) 907 | }) 908 | 909 | test('check if a user is allowed or denied access', async ({ assert }) => { 910 | class User { 911 | declare id: number 912 | declare email: string 913 | } 914 | 915 | class PostPolicy extends BasePolicy { 916 | resolvePermissions() {} 917 | 918 | async view(_: User): Promise { 919 | return true 920 | } 921 | 922 | viewAll(_: User): AuthorizerResponse { 923 | return false 924 | } 925 | } 926 | 927 | const bouncer = new Bouncer(new User(), undefined, { 928 | PostPolicy: async () => { 929 | return { 930 | default: PostPolicy, 931 | } 932 | }, 933 | }) 934 | 935 | const postsPolicy = bouncer.with('PostPolicy') 936 | 937 | assert.isTrue(await postsPolicy.allows('view')) 938 | assert.isFalse(await postsPolicy.allows('viewAll')) 939 | 940 | assert.isFalse(await postsPolicy.denies('view')) 941 | assert.isTrue(await postsPolicy.denies('viewAll')) 942 | }) 943 | 944 | test('authorize for an action', async ({ assert }) => { 945 | class User { 946 | declare id: number 947 | declare email: string 948 | } 949 | 950 | class PostPolicy extends BasePolicy { 951 | resolvePermissions() {} 952 | 953 | view(_: User): AuthorizerResponse { 954 | return true 955 | } 956 | 957 | viewAll(_: User): AuthorizerResponse { 958 | return false 959 | } 960 | } 961 | 962 | const bouncer = new Bouncer(new User(), undefined, { 963 | PostPolicy: async () => { 964 | return { 965 | default: PostPolicy, 966 | } 967 | }, 968 | }) 969 | 970 | const postsPolicy = bouncer.with('PostPolicy') 971 | await assert.doesNotRejects(() => postsPolicy.authorize('view')) 972 | await assert.rejects(() => postsPolicy.authorize('viewAll'), 'Access denied') 973 | }) 974 | }) 975 | 976 | test.group('Bouncer | policies | before hook', () => { 977 | test('execute action when hook returns undefined or null', async ({ assert }) => { 978 | let actionsCounter = 0 979 | 980 | class User { 981 | declare id: number 982 | declare email: string 983 | } 984 | 985 | class PostPolicy extends BasePolicy { 986 | before() { 987 | return 988 | } 989 | 990 | view(_: User): AuthorizerResponse { 991 | actionsCounter++ 992 | return true 993 | } 994 | 995 | viewAll(_: User): AuthorizerResponse { 996 | actionsCounter++ 997 | return false 998 | } 999 | } 1000 | 1001 | const bouncer = new Bouncer(new User()) 1002 | const canView = await bouncer.with(PostPolicy).execute('view') 1003 | assert.isTrue(canView.authorized) 1004 | 1005 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1006 | assert.isFalse(canViewAll.authorized) 1007 | 1008 | assert.equal(actionsCounter, 2) 1009 | }) 1010 | 1011 | test('deny access when before hook returns false', async ({ assert }) => { 1012 | let actionsCounter = 0 1013 | 1014 | class User { 1015 | declare id: number 1016 | declare email: string 1017 | } 1018 | 1019 | class PostPolicy extends BasePolicy { 1020 | before() { 1021 | return false 1022 | } 1023 | 1024 | view(_: User): AuthorizerResponse { 1025 | actionsCounter++ 1026 | return true 1027 | } 1028 | 1029 | viewAll(_: User): AuthorizerResponse { 1030 | actionsCounter++ 1031 | return false 1032 | } 1033 | } 1034 | 1035 | const bouncer = new Bouncer(new User()) 1036 | const canView = await bouncer.with(PostPolicy).execute('view') 1037 | assert.isFalse(canView.authorized) 1038 | 1039 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1040 | assert.isFalse(canViewAll.authorized) 1041 | 1042 | assert.equal(actionsCounter, 0) 1043 | }) 1044 | 1045 | test('return custom response from before hook', async ({ assert }) => { 1046 | let actionsCounter = 0 1047 | 1048 | class User { 1049 | declare id: number 1050 | declare email: string 1051 | } 1052 | 1053 | class PostPolicy extends BasePolicy { 1054 | before() { 1055 | return AuthorizationResponse.deny('Post not found', 404) 1056 | } 1057 | 1058 | view(_: User): AuthorizerResponse { 1059 | actionsCounter++ 1060 | return true 1061 | } 1062 | 1063 | viewAll(_: User): AuthorizerResponse { 1064 | actionsCounter++ 1065 | return false 1066 | } 1067 | } 1068 | 1069 | const bouncer = new Bouncer(new User()) 1070 | const canView = await bouncer.with(PostPolicy).execute('view') 1071 | assert.isFalse(canView.authorized) 1072 | assert.equal(canView.message, 'Post not found') 1073 | assert.equal(canView.status, 404) 1074 | 1075 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1076 | assert.isFalse(canViewAll.authorized) 1077 | assert.equal(canViewAll.message, 'Post not found') 1078 | assert.equal(canViewAll.status, 404) 1079 | 1080 | assert.equal(actionsCounter, 0) 1081 | }) 1082 | }) 1083 | 1084 | test.group('Bouncer | policies | after hook', () => { 1085 | test('passthrough action response when hook returns undefined', async ({ assert }) => { 1086 | let actionsCounter = 0 1087 | 1088 | class User { 1089 | declare id: number 1090 | declare email: string 1091 | } 1092 | 1093 | class PostPolicy extends BasePolicy { 1094 | after() { 1095 | return 1096 | } 1097 | 1098 | view(_: User): AuthorizerResponse { 1099 | actionsCounter++ 1100 | return true 1101 | } 1102 | 1103 | viewAll(_: User): AuthorizerResponse { 1104 | actionsCounter++ 1105 | return false 1106 | } 1107 | } 1108 | 1109 | const bouncer = new Bouncer(new User()) 1110 | const canView = await bouncer.with(PostPolicy).execute('view') 1111 | assert.isTrue(canView.authorized) 1112 | 1113 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1114 | assert.isFalse(canViewAll.authorized) 1115 | 1116 | assert.equal(actionsCounter, 2) 1117 | }) 1118 | 1119 | test('overwrite action response from after hook', async ({ assert }) => { 1120 | let actionsCounter = 0 1121 | 1122 | class User { 1123 | declare id: number 1124 | declare email: string 1125 | } 1126 | 1127 | class PostPolicy extends BasePolicy { 1128 | after() { 1129 | return false 1130 | } 1131 | 1132 | view(_: User): AuthorizerResponse { 1133 | actionsCounter++ 1134 | return true 1135 | } 1136 | 1137 | viewAll(_: User): AuthorizerResponse { 1138 | actionsCounter++ 1139 | return false 1140 | } 1141 | } 1142 | 1143 | const bouncer = new Bouncer(new User()) 1144 | const canView = await bouncer.with(PostPolicy).execute('view') 1145 | assert.isFalse(canView.authorized) 1146 | 1147 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1148 | assert.isFalse(canViewAll.authorized) 1149 | 1150 | assert.equal(actionsCounter, 2) 1151 | }) 1152 | 1153 | test('overwrite before hook response from after response', async ({ assert }) => { 1154 | let actionsCounter = 0 1155 | 1156 | class User { 1157 | declare id: number 1158 | declare email: string 1159 | } 1160 | 1161 | class PostPolicy extends BasePolicy { 1162 | before() { 1163 | return AuthorizationResponse.deny('Post not found', 404) 1164 | } 1165 | 1166 | after() { 1167 | return AuthorizationResponse.allow() 1168 | } 1169 | 1170 | view(_: User): AuthorizerResponse { 1171 | actionsCounter++ 1172 | return true 1173 | } 1174 | 1175 | viewAll(_: User): AuthorizerResponse { 1176 | actionsCounter++ 1177 | return false 1178 | } 1179 | } 1180 | 1181 | const bouncer = new Bouncer(new User()) 1182 | const canView = await bouncer.with(PostPolicy).execute('view') 1183 | assert.isTrue(canView.authorized) 1184 | 1185 | const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') 1186 | assert.isTrue(canViewAll.authorized) 1187 | 1188 | assert.equal(actionsCounter, 0) 1189 | }) 1190 | }) 1191 | -------------------------------------------------------------------------------- /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.js' 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 | command.prompt 25 | .trap('Do you want to register the policy inside the app/policies/main.ts file?') 26 | .accept() 27 | 28 | await command.exec() 29 | command.assertSucceeded() 30 | 31 | command.assertLog('green(DONE:) create app/policies/post_policy.ts') 32 | 33 | await assert.fileContains('app/policies/post_policy.ts', [ 34 | `import Post from '#models/post'`, 35 | `import User from '#models/user'`, 36 | `import { BasePolicy } from '@adonisjs/bouncer'`, 37 | `import type { AuthorizerResponse } from '@adonisjs/bouncer/types'`, 38 | `export default class PostPolicy extends BasePolicy`, 39 | ]) 40 | 41 | await assert.fileContains( 42 | 'app/policies/main.ts', 43 | `PostPolicy: () => import('#policies/post_policy')` 44 | ) 45 | }) 46 | 47 | test('do not display prompt when --register flag is used', async ({ assert, fs }) => { 48 | await fs.createJson('tsconfig.json', {}) 49 | await fs.create('app/policies/main.ts', `export const policies = {}`) 50 | 51 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 52 | await ace.app.init() 53 | ace.ui.switchMode('raw') 54 | 55 | const command = await ace.create(MakePolicy, ['post', '--register']) 56 | await command.exec() 57 | command.assertSucceeded() 58 | 59 | command.assertLog('green(DONE:) create app/policies/post_policy.ts') 60 | await assert.fileContains( 61 | 'app/policies/main.ts', 62 | `PostPolicy: () => import('#policies/post_policy')` 63 | ) 64 | }) 65 | 66 | test('do not register policy when --no-register flag is used', async ({ assert, fs }) => { 67 | await fs.createJson('tsconfig.json', {}) 68 | await fs.create('app/policies/main.ts', `export const policies = {}`) 69 | 70 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 71 | await ace.app.init() 72 | ace.ui.switchMode('raw') 73 | 74 | const command = await ace.create(MakePolicy, ['post', '--no-register']) 75 | await command.exec() 76 | command.assertSucceeded() 77 | 78 | command.assertLog('green(DONE:) create app/policies/post_policy.ts') 79 | await assert.fileEquals('app/policies/main.ts', `export const policies = {}`) 80 | }) 81 | 82 | test('make policy class inside nested directories', async ({ assert, fs }) => { 83 | await fs.createJson('tsconfig.json', {}) 84 | await fs.create('app/policies/main.ts', `export const policies = {}`) 85 | 86 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 87 | await ace.app.init() 88 | ace.ui.switchMode('raw') 89 | 90 | const command = await ace.create(MakePolicy, ['post/published', '--model=post']) 91 | command.prompt 92 | .trap('Do you want to register the policy inside the app/policies/main.ts file?') 93 | .accept() 94 | 95 | await command.exec() 96 | command.assertSucceeded() 97 | 98 | command.assertLog('green(DONE:) create app/policies/post/published_policy.ts') 99 | await assert.fileContains('app/policies/post/published_policy.ts', [ 100 | `import Post from '#models/post'`, 101 | `import User from '#models/user'`, 102 | `import { BasePolicy } from '@adonisjs/bouncer'`, 103 | `import type { AuthorizerResponse } from '@adonisjs/bouncer/types'`, 104 | `export default class PublishedPolicy extends BasePolicy`, 105 | ]) 106 | 107 | await assert.fileContains( 108 | 'app/policies/main.ts', 109 | `PostPublishedPolicy: () => import('#policies/post/published_policy')` 110 | ) 111 | }) 112 | 113 | test('define policy with actions', async ({ assert, fs }) => { 114 | await fs.createJson('tsconfig.json', {}) 115 | await fs.create('app/policies/main.ts', `export const policies = {}`) 116 | 117 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 118 | await ace.app.init() 119 | ace.ui.switchMode('raw') 120 | 121 | const command = await ace.create(MakePolicy, ['post', 'view', 'edit', 'delete']) 122 | command.prompt 123 | .trap('Do you want to register the policy inside the app/policies/main.ts file?') 124 | .accept() 125 | 126 | await command.exec() 127 | command.assertSucceeded() 128 | 129 | command.assertLog('green(DONE:) create app/policies/post_policy.ts') 130 | await assert.fileContains('app/policies/post_policy.ts', [ 131 | `import Post from '#models/post'`, 132 | `import User from '#models/user'`, 133 | `view(user: User): AuthorizerResponse`, 134 | `edit(user: User): AuthorizerResponse`, 135 | `delete(user: User): AuthorizerResponse`, 136 | ]) 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /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.js' 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 | const policiesStub = await stubsManager 56 | .build('policies.stub', { source: stubsRoot }) 57 | .then((stub) => stub.prepare({})) 58 | 59 | await assert.fileContains('adonisrc.ts', '@adonisjs/bouncer/commands') 60 | await assert.fileContains('adonisrc.ts', '@adonisjs/bouncer/bouncer_provider') 61 | await assert.fileContains('app/abilities/main.ts', abilitiesStub.contents) 62 | await assert.fileContains('app/policies/main.ts', policiesStub.contents) 63 | await assert.fileContains( 64 | 'app/middleware/initialize_bouncer_middleware.ts', 65 | `export default class InitializeBouncerMiddleware {` 66 | ) 67 | }).disableTimeout() 68 | }) 69 | -------------------------------------------------------------------------------- /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.js' 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 | -------------------------------------------------------------------------------- /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.js' 13 | import { edgePluginBouncer } from '../../src/plugins/edge.js' 14 | import { BasePolicy } from '../../src/base_policy.js' 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 | -------------------------------------------------------------------------------- /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.js' 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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------