├── .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 |
--------------------------------------------------------------------------------