From 2567f6f8209c955d77a062d727d0434e844cbad4 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Sat, 2 Dec 2023 13:21:08 +0530 Subject: [PATCH 01/27] feat: implement bouncer actions --- .eslintignore | 1 - .eslintrc.json | 17 - .github/COMMIT_CONVENTION.md | 70 -- .github/ISSUE_TEMPLATE/bug_report.md | 29 - .github/ISSUE_TEMPLATE/feature_request.md | 28 - .github/PULL_REQUEST_TEMPLATE.md | 28 - .github/labels.json | 170 +++ .github/workflows/checks.yml | 61 ++ .github/workflows/test.yml | 22 - .gitignore | 1 + .husky/.gitignore | 1 - .husky/commit-msg | 7 +- .npmrc | 2 +- .prettierrc | 10 - CONTRIBUTING.md | 48 - LICENSE.md | 2 +- README.md | 53 - adonis-typings/bouncer.ts | 364 ------- adonis-typings/container.ts | 16 - adonis-typings/context.ts | 16 - adonis-typings/index.ts | 12 - bin/japaTypes.ts | 7 - bin/test.ts | 37 +- commands/MakePolicy.ts | 193 ---- config.json | 13 - examples/UserPolicy.ts | 9 - examples/bouncer.ts | 12 - examples/contract.ts | 13 - examples/index.ts | 5 - examples/user.ts | 7 - japaFile.js | 6 - package.json | 194 ++-- providers/BouncerProvider.ts | 64 -- src/ActionsAuthorizer/index.ts | 309 ------ src/BasePolicy/index.ts | 55 - src/Bindings/View.ts | 113 -- src/Bouncer/index.ts | 168 --- src/Decorators/index.ts | 18 - src/Exceptions/AuthorizationException.ts | 16 - src/PoliciesAuthorizer/index.ts | 230 ----- src/Profiler/index.ts | 55 - src/action.ts | 41 + src/base_policy.ts | 33 + src/bouncer.ts | 327 ++++++ commands/index.ts => src/response.ts | 6 +- src/types.ts | 69 ++ src/utils/index.ts | 98 -- templates/bouncer.txt | 57 - templates/contract.txt | 16 - templates/policy.txt | 10 - test-helpers/index.ts | 41 - test/actions-authorizer.spec.ts | 861 --------------- test/make-policy.spec.ts | 215 ---- test/policy-authorizer.spec.ts | 1149 --------------------- test/provider.spec.ts | 40 - test/view.spec.ts | 213 ---- tests/bouncer.spec.ts | 763 ++++++++++++++ tsconfig.json | 8 +- 58 files changed, 1572 insertions(+), 4857 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.json delete mode 100644 .github/COMMIT_CONVENTION.md delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/labels.json create mode 100644 .github/workflows/checks.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 .husky/.gitignore delete mode 100644 .prettierrc delete mode 100644 CONTRIBUTING.md delete mode 100644 README.md delete mode 100644 adonis-typings/bouncer.ts delete mode 100644 adonis-typings/container.ts delete mode 100644 adonis-typings/context.ts delete mode 100644 adonis-typings/index.ts delete mode 100644 bin/japaTypes.ts delete mode 100644 commands/MakePolicy.ts delete mode 100644 config.json delete mode 100644 examples/UserPolicy.ts delete mode 100644 examples/bouncer.ts delete mode 100644 examples/contract.ts delete mode 100644 examples/index.ts delete mode 100644 examples/user.ts delete mode 100644 japaFile.js delete mode 100644 providers/BouncerProvider.ts delete mode 100644 src/ActionsAuthorizer/index.ts delete mode 100644 src/BasePolicy/index.ts delete mode 100644 src/Bindings/View.ts delete mode 100644 src/Bouncer/index.ts delete mode 100644 src/Decorators/index.ts delete mode 100644 src/Exceptions/AuthorizationException.ts delete mode 100644 src/PoliciesAuthorizer/index.ts delete mode 100644 src/Profiler/index.ts create mode 100644 src/action.ts create mode 100644 src/base_policy.ts create mode 100644 src/bouncer.ts rename commands/index.ts => src/response.ts (60%) create mode 100644 src/types.ts delete mode 100644 src/utils/index.ts delete mode 100644 templates/bouncer.txt delete mode 100644 templates/contract.txt delete mode 100644 templates/policy.txt delete mode 100644 test-helpers/index.ts delete mode 100644 test/actions-authorizer.spec.ts delete mode 100644 test/make-policy.spec.ts delete mode 100644 test/policy-authorizer.spec.ts delete mode 100644 test/provider.spec.ts delete mode 100644 test/view.spec.ts create mode 100644 tests/bouncer.spec.ts diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 378eac2..0000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -build diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 52c858a..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": [ - "plugin:adonis/typescriptPackage", - "prettier" - ], - "plugins": [ - "prettier" - ], - "rules": { - "prettier/prettier": [ - "error", - { - "endOfLine": "auto" - } - ] - } -} diff --git a/.github/COMMIT_CONVENTION.md b/.github/COMMIT_CONVENTION.md deleted file mode 100644 index fc852af..0000000 --- a/.github/COMMIT_CONVENTION.md +++ /dev/null @@ -1,70 +0,0 @@ -## Git Commit Message Convention - -> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). - -Using conventional commit messages, we can automate the process of generating the CHANGELOG file. All commits messages will automatically be validated against the following regex. - -``` js -/^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build|improvement)((.+))?: .{1,50}/ -``` - -## Commit Message Format -A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: - -> The **scope** is optional - -``` -feat(router): add support for prefix - -Prefix makes it easier to append a path to a group of routes -``` - -1. `feat` is type. -2. `router` is scope and is optional -3. `add support for prefix` is the subject -4. The **body** is followed by a blank line. -5. The optional **footer** can be added after the body, followed by a blank line. - -## Types -Only one type can be used at a time and only following types are allowed. - -- feat -- fix -- docs -- style -- refactor -- perf -- test -- workflow -- ci -- chore -- types -- build - -If a type is `feat`, `fix` or `perf`, then the commit will appear in the CHANGELOG.md file. However if there is any BREAKING CHANGE, the commit will always appear in the changelog. - -### Revert -If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit `., where the hash is the SHA of the commit being reverted. - -## Scope -The scope could be anything specifying place of the commit change. For example: `router`, `view`, `querybuilder`, `database`, `model` and so on. - -## Subject -The subject contains succinct description of the change: - -- use the imperative, present tense: "change" not "changed" nor "changes". -- don't capitalize first letter -- no dot (.) at the end - -## Body - -Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". -The body should include the motivation for the change and contrast this with previous behavior. - -## Footer - -The footer should contain any information about **Breaking Changes** and is also the place to -reference GitHub issues that this commit **Closes**. - -**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. - diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 4bcd407..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Bug report -about: Report identified bugs ---- - - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -- Lots of raised issues are directly not bugs but instead are design decisions taken by us. -- Make use of our [forum](https://forum.adonisjs.com/), or [discord server](https://discord.me/adonisjs), if you are not sure that you are reporting a bug. -- Ensure the issue isn't already reported. -- Ensure you are reporting the bug in the correct repo. - -*Delete the above section and the instructions in the sections below before submitting* - -## Package version - - -## Node.js and npm version - - -## Sample Code (to reproduce the issue) - - -## BONUS (a sample repo to reproduce the issue) - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index abd44a5..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Feature request -about: Propose changes for adding a new feature ---- - - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -## Consider an RFC - -Please create an [RFC](https://github.com/adonisjs/rfcs) instead, if - -- Feature introduces a breaking change -- Demands lots of time and changes in the current code base. - -*Delete the above section and the instructions in the sections below before submitting* - -## Why this feature is required (specific use-cases will be appreciated)? - - -## Have you tried any other work arounds? - - -## Are you willing to work on it with little guidance? - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 536d5bb..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ - - -## Proposed changes - -Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. - -## Types of changes - -What types of changes does your code introduce? - -_Put an `x` in the boxes that apply_ - -- [ ] Bugfix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - -## Checklist - -_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ - -- [ ] I have read the [CONTRIBUTING](https://github.com/adonisjs/bouncer/blob/master/CONTRIBUTING.md) doc -- [ ] Lint and unit tests pass locally with my changes -- [ ] I have added tests that prove my fix is effective or that my feature works. -- [ ] I have added necessary documentation (if appropriate) - -## Further comments - -If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..ba001c6 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,170 @@ +[ + { + "name": "Priority: Critical", + "color": "ea0056", + "description": "The issue needs urgent attention", + "aliases": [] + }, + { + "name": "Priority: High", + "color": "5666ed", + "description": "Look into this issue before picking up any new work", + "aliases": [] + }, + { + "name": "Priority: Medium", + "color": "f4ff61", + "description": "Try to fix the issue for the next patch/minor release", + "aliases": [] + }, + { + "name": "Priority: Low", + "color": "87dfd6", + "description": "Something worth considering, but not a top priority for the team", + "aliases": [] + }, + { + "name": "Semver: Alpha", + "color": "008480", + "description": "Will make it's way to the next alpha version of the package", + "aliases": [] + }, + { + "name": "Semver: Major", + "color": "ea0056", + "description": "Has breaking changes", + "aliases": [] + }, + { + "name": "Semver: Minor", + "color": "fbe555", + "description": "Mainly new features and improvements", + "aliases": [] + }, + { + "name": "Semver: Next", + "color": "5666ed", + "description": "Will make it's way to the bleeding edge version of the package", + "aliases": [] + }, + { + "name": "Semver: Patch", + "color": "87dfd6", + "description": "A bug fix", + "aliases": [] + }, + { + "name": "Status: Abandoned", + "color": "ffffff", + "description": "Dropped and not into consideration", + "aliases": ["wontfix"] + }, + { + "name": "Status: Accepted", + "color": "e5fbf2", + "description": "The proposal or the feature has been accepted for the future versions", + "aliases": [] + }, + { + "name": "Status: Blocked", + "color": "ea0056", + "description": "The work on the issue or the PR is blocked. Check comments for reasoning", + "aliases": [] + }, + { + "name": "Status: Completed", + "color": "008672", + "description": "The work has been completed, but not released yet", + "aliases": [] + }, + { + "name": "Status: In Progress", + "color": "73dbc4", + "description": "Still banging the keyboard", + "aliases": ["in progress"] + }, + { + "name": "Status: On Hold", + "color": "f4ff61", + "description": "The work was started earlier, but is on hold now. Check comments for reasoning", + "aliases": ["On Hold"] + }, + { + "name": "Status: Review Needed", + "color": "fbe555", + "description": "Review from the core team is required before moving forward", + "aliases": [] + }, + { + "name": "Status: Awaiting More Information", + "color": "89f8ce", + "description": "Waiting on the issue reporter or PR author to provide more information", + "aliases": [] + }, + { + "name": "Status: Need Contributors", + "color": "7057ff", + "description": "Looking for contributors to help us move forward with this issue or PR", + "aliases": [] + }, + { + "name": "Type: Bug", + "color": "ea0056", + "description": "The issue has indentified a bug", + "aliases": ["bug"] + }, + { + "name": "Type: Security", + "color": "ea0056", + "description": "Spotted security vulnerability and is a top priority for the core team", + "aliases": [] + }, + { + "name": "Type: Duplicate", + "color": "00837e", + "description": "Already answered or fixed previously", + "aliases": ["duplicate"] + }, + { + "name": "Type: Enhancement", + "color": "89f8ce", + "description": "Improving an existing feature", + "aliases": ["enhancement"] + }, + { + "name": "Type: Feature Request", + "color": "483add", + "description": "Request to add a new feature to the package", + "aliases": [] + }, + { + "name": "Type: Invalid", + "color": "dbdbdb", + "description": "Doesn't really belong here. Maybe use discussion threads?", + "aliases": ["invalid"] + }, + { + "name": "Type: Question", + "color": "eceafc", + "description": "Needs clarification", + "aliases": ["help wanted", "question"] + }, + { + "name": "Type: Documentation Change", + "color": "7057ff", + "description": "Documentation needs some improvements", + "aliases": ["documentation"] + }, + { + "name": "Type: Dependencies Update", + "color": "00837e", + "description": "Bump dependencies", + "aliases": ["dependencies"] + }, + { + "name": "Good First Issue", + "color": "008480", + "description": "Want to contribute? Just filter by this label", + "aliases": ["good first issue"] + } +] diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..8c26a40 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,61 @@ +name: test +on: + - push + - pull_request +jobs: + lint: + uses: adonisjs/.github/.github/workflows/lint.yml@main + + typecheck: + uses: adonisjs/.github/.github/workflows/typecheck.yml@main + + test_linux: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.10.0, 21.x] + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: npm install + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run tests + run: npm test + env: + REDIS_HOST: 0.0.0.0 + REDIS_PORT: 6379 + + test_windows: + runs-on: windows-latest + strategy: + matrix: + node-version: [20.10.0, 21.x] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: npm install + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run tests + run: npm test + env: + NO_REDIS: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index b551bcb..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: test -on: - - push - - pull_request -jobs: - linux: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: - - 14.15.4 - - 16.x - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Install - run: npm install - - name: Run tests - run: npm test diff --git a/.gitignore b/.gitignore index bc92e3c..324fb8c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ coverage *.log build dist +yarn.lock shrinkwrap.yaml package-lock.json test/__app diff --git a/.husky/.gitignore b/.husky/.gitignore deleted file mode 100644 index 31354ec..0000000 --- a/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/.husky/commit-msg b/.husky/commit-msg index 4654c12..4002db7 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,3 +1,4 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" -HUSKY_GIT_PARAMS=$1 node ./node_modules/@adonisjs/mrm-preset/validate-commit/conventional/validate.js +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit diff --git a/.npmrc b/.npmrc index a54c771..43c97e7 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -message="chore(release): %s" +package-lock=false diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 07634f7..0000000 --- a/.prettierrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "trailingComma": "es5", - "semi": false, - "singleQuote": true, - "useTabs": false, - "quoteProps": "consistent", - "bracketSpacing": true, - "arrowParens": "always", - "printWidth": 100 -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 992acdb..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,48 +0,0 @@ -# Contributing - -AdonisJs is a community driven project. You are free to contribute in any of the following ways. - -- [Coding style](coding-style) -- [Fix bugs by creating PR's](fix-bugs-by-creating-prs) -- [Share an RFC for new features or big changes](share-an-rfc-for-new-features-or-big-changes) -- [Report security issues](report-security-issues) -- [Be a part of the community](be-a-part-of-community) - -## Coding style - -Majority of AdonisJs core packages are written in Typescript. Having a brief knowledge of Typescript is required to contribute to the core. [Learn more](https://adonisjs.com/coding-style) about the same. - -## Fix bugs by creating PR's - -We appreciate every time you report a bug in the framework or related libraries. However, taking time to submit a PR can help us in fixing bugs quickly and ensure a healthy and stable eco-system. - -Go through the following points, before creating a new PR. - -1. Create an issue discussing the bug or short-coming in the framework. -2. Once approved, go ahead and fork the REPO. -3. Make sure to start from the `develop`, since this is the upto date branch. -4. Make sure to keep commits small and relevant. -5. We follow [conventional-commits](https://github.com/conventional-changelog/conventional-changelog) to structure our commit messages. Instead of running `git commit`, you must run `npm commit`, which will show you prompts to create a valid commit message. -6. Once done with all the changes, create a PR against the `develop` branch. - -## Share an RFC for new features or big changes - -Sharing PR's for small changes works great. However, when contributing big features to the framework, it is required to go through the RFC process. - -### What is an RFC? - -RFC stands for **Request for Commits**, a standard process followed by many other frameworks including [Ember](https://github.com/emberjs/rfcs), [yarn](https://github.com/yarnpkg/rfcs) and [rust](https://github.com/rust-lang/rfcs). - -In brief, RFC process allows you to talk about the changes with everyone in the community and get a view of the core team before dedicating your time to work on the feature. - -The RFC proposals are created as issues on [adonisjs/rfcs](https://github.com/adonisjs/rfcs) repo. Make sure to read the README to learn about the process in depth. - -## Report security issues - -All of the security issues, must be reported via [email](mailto:virk@adonisjs.com) and not using any of the public channels. [Learn more](https://adonisjs.com/security) about the security policy - -## Be a part of community - -We welcome you to participate in the [forum](https://forum.adonisjs.com/) and the AdonisJs [discord server](https://discord.me/adonisjs). You are free to ask your questions and share your work or contributions made to AdonisJs eco-system. - -We follow a strict [Code of Conduct](https://adonisjs.com/community-guidelines) to make sure everyone is respectful to each other. diff --git a/LICENSE.md b/LICENSE.md index 47ca6df..1c19428 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # The MIT License -Copyright 2021 Harminder Virk, contributors +Copyright 2022 Harminder Virk, contributors 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: diff --git a/README.md b/README.md deleted file mode 100644 index 922daf8..0000000 --- a/README.md +++ /dev/null @@ -1,53 +0,0 @@ -
- -
- -
- -
-

Bouncer

-

Authorization package to authorize user actions using policies

-
- -
- -
- -[![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] [![synk-image]][synk-url] - -
- -
-

- - Website - - | - - Guides - - | - - Contributing - -

-
- -
- Built with ❤︎ by Harminder Virk -
- -[gh-workflow-image]: https://img.shields.io/github/workflow/status/adonisjs/bouncer/test?style=for-the-badge -[gh-workflow-url]: https://github.com/adonisjs/bouncer/actions/workflows/test.yml "Github action" - -[typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript -[typescript-url]: "typescript" - -[npm-image]: https://img.shields.io/npm/v/@adonisjs/bouncer.svg?style=for-the-badge&logo=npm -[npm-url]: https://npmjs.org/package/@adonisjs/bouncer "npm" - -[license-image]: https://img.shields.io/npm/l/@adonisjs/bouncer?color=blueviolet&style=for-the-badge -[license-url]: LICENSE.md "license" - -[synk-image]: https://img.shields.io/snyk/vulnerabilities/github/adonisjs/bouncer?label=Synk%20Vulnerabilities&style=for-the-badge -[synk-url]: https://snyk.io/test/github/adonisjs/bouncer?targetFile=package.json "synk" diff --git a/adonis-typings/bouncer.ts b/adonis-typings/bouncer.ts deleted file mode 100644 index 2c02dee..0000000 --- a/adonis-typings/bouncer.ts +++ /dev/null @@ -1,364 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Addons/Bouncer' { - import { ProfilerRowContract, ProfilerContract } from '@ioc:Adonis/Core/Profiler' - - /* - |-------------------------------------------------------------------------- - | Helpers - |-------------------------------------------------------------------------- - */ - - /** - * Unwrap promise - */ - export type UnwrapPromise = T extends PromiseLike ? PT : never - - /** - * Shape of default export - */ - export type DefaultExport = Promise<{ default: T }> - - /** - * Shape of the function that imports a policy class - */ - export type LazyPolicy = () => DefaultExport - - /** - * Filters the available actions to the one that depends upon the given user - */ - export type ExtractActionsForUser = { - [Action in keyof Actions]: Actions[Action] extends (user: User, ...args: any[]) => any - ? Action - : never - }[keyof Actions] - - /** - * Returns an array of arguments accepted by a given action, except the user - * argument - */ - export type GetActionRemainingArgs< - Actions extends any, - Action extends keyof Actions - > = Actions[Action] extends (user: any, ...args: infer A) => any ? A : never - - /** - * Extracts types from the registered actions - */ - export type ExtractActionsTypes = { - [K in keyof Actions]: Actions[K]['handler'] - } - - /** - * Extracts types from the registered policies - */ - export type ExtractPoliciesTypes = { - [K in keyof Policies]: InstanceType>['default']> - } - - /** - * Use this type to define a custom type only action - */ - export type Action = ( - ...args: Args - ) => Promise | ActionResponse - - /** - * Shape of the action handler - */ - export type ActionHandler = (...args: any[]) => Promise | ActionResponse - - /** - * Shape of the before hook handler - */ - export type BeforeHookHandler = ( - user: any, - action: string, - ...args: any[] - ) => Promise | ActionResponse | void - - /** - * Shape of the after hook handler - */ - export type AfterHookHandler = ( - user: any, - action: string, - result: AuthorizationResult, - ...args: any[] - ) => Promise | ActionResponse | void - - /** - * The expected response from the action handler - */ - export type ActionResponse = boolean | [string, number?] - - /** - * Shape of the authorization result - */ - export type AuthorizationResult = { - authorized: boolean - errorResponse: null | [string, number] - } - - /** - * Available options for a given actions - */ - export type ActionOptions = { - allowGuest?: boolean - } - - /** - * Shape of the base policy. All policies must extend it - */ - export interface BasePolicyContract {} - - /** - * Shape of the policy constructor - */ - export interface BasePolicyConstructorContract extends BasePolicyContract { - new (...args: any[]): BasePolicyContract - - /** - * Meta data for the actions - */ - actionsOptions: { [key: string]: ActionOptions } - - /** - * A boolean to know if the class has been booted - */ - booted: boolean - - /** - * Boot the policy - */ - boot(): void - - /** - * Store options for a given policy action - */ - storeActionOptions( - this: T, - propertyName: keyof InstanceType, - options?: ActionOptions - ): this - } - - /** - * Bouncer allows defining actions and resources for authorization - */ - export interface BouncerContract< - Actions extends { - [key: string]: { - handler: ActionHandler - options?: ActionOptions - } - }, - Policies extends { [key: string]: LazyPolicy } - > { - /** - * Registered actions - */ - actions: Actions - - /** - * Registered policies - */ - policies: Policies - - /** - * Registered hooks - */ - hooks: { - before: BeforeHookHandler[] - after: AfterHookHandler[] - } - - /** - * Register a before hook - */ - before(callback: BeforeHookHandler): this - - /** - * Register an after hook - */ - after(callback: AfterHookHandler): this - - /** - * Define an action and its handler - */ - define( - action: ActionName, - handler: Handler, - options?: ActionOptions - ): BouncerContract< - Actions & Record, - Policies - > - - /** - * Register policies - */ - registerPolicies( - policies: BouncerPolicies - ): BouncerContract - - /** - * Returns the authorizer instance for a given user - */ - forUser(user: User): ActionsAuthorizerContract - - /** - * Deny authorization check using a custom message and an optional status - */ - deny(message: string, status?: number): [string, number] - } - - /** - * Authorizer allows authorizing actions for a given user - */ - export interface ActionsAuthorizerContract { - user: ActionsUser - - /** - * Set the profiler to be used for profiling the function calls - */ - setProfiler(profiler?: ProfilerRowContract | ProfilerContract): this - - /** - * Returns the authorizer instance for a given user - */ - forUser(user: User): ActionsAuthorizerContract - - /** - * Find if user is allowed to perform the action on a given resource - */ - allows>( - action: ActionName, - ...args: GetActionRemainingArgs - ): Promise - - /** - * Find if user is not allowed to perform the action on a given resource - */ - denies>( - action: ActionName, - ...args: GetActionRemainingArgs - ): Promise - - /** - * Authorize user for a given resource + action - */ - authorize>( - action: ActionName, - ...args: GetActionRemainingArgs - ): Promise - - /** - * Use a policy for authorization - */ - with( - policy: Policy - ): PoliciesAuthorizerContract - - /** - * The untyped version of [[this.allows]] and support references a policy.action - * via string. Added mainly to be used inside the templates. - * - * For example: - * ``` - * bouncer.can('PostPolicy.update', post) - * ``` - */ - can(action: string, ...args: any[]): Promise - - /** - * The untyped version of [[this.denies]] and support references a policy.action - * via string. Added mainly to be used inside the templates. - * - * For example: - * ``` - * bouncer.cannot('PostPolicy.update', post) - * ``` - */ - cannot(action: string, ...args: any[]): Promise - } - - /** - * Authorizer allows authorizing actions for a given user - */ - export interface PoliciesAuthorizerContract< - PolicyUser extends any, - Policy extends keyof PoliciesList - > { - user: PolicyUser - - /** - * Set the profiler to be used for profiling function calls - */ - setProfiler(profiler?: ProfilerRowContract | ProfilerContract): this - - /** - * Returns the authorizer instance for a given user - */ - forUser(user: User): PoliciesAuthorizerContract - - /** - * Find if user is allowed to perform the action on a given resource - */ - allows>( - action: PolicyAction, - ...args: GetActionRemainingArgs - ): Promise - - /** - * Find if user is not allowed to perform the action on a given resource - */ - denies>( - action: PolicyAction, - ...args: GetActionRemainingArgs - ): Promise - - /** - * Authorize user for a given resource + action - */ - authorize>( - action: PolicyAction, - ...args: GetActionRemainingArgs - ): Promise - } - - /** - * The following interfaces are re-defined inside the user land to - * have application wide - */ - export interface ActionsList {} - export interface PoliciesList {} - - /** - * Typed decorator - */ - export type TypedDecorator = < - TKey extends string, - TTarget extends { [K in TKey]: PropType } - >( - target: TTarget, - property: TKey - ) => void - - /** - * Shape of the action decorator - */ - export type ActionDecorator = (options: Partial) => TypedDecorator> - - const Bouncer: BouncerContract<{}, {}> - export const BasePolicy: BasePolicyConstructorContract - export const action: ActionDecorator - export default Bouncer -} diff --git a/adonis-typings/container.ts b/adonis-typings/container.ts deleted file mode 100644 index 86bc050..0000000 --- a/adonis-typings/container.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Core/Application' { - import Bouncer from '@ioc:Adonis/Addons/Bouncer' - - export interface ContainerBindings { - 'Adonis/Addons/Bouncer': typeof Bouncer - } -} diff --git a/adonis-typings/context.ts b/adonis-typings/context.ts deleted file mode 100644 index 19db50b..0000000 --- a/adonis-typings/context.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Core/HttpContext' { - import { ActionsAuthorizerContract } from '@ioc:Adonis/Addons/Bouncer' - - interface HttpContextContract { - bouncer: ActionsAuthorizerContract> - } -} diff --git a/adonis-typings/index.ts b/adonis-typings/index.ts deleted file mode 100644 index 1b8290b..0000000 --- a/adonis-typings/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// -/// -/// diff --git a/bin/japaTypes.ts b/bin/japaTypes.ts deleted file mode 100644 index d42cac6..0000000 --- a/bin/japaTypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Assert } from '@japa/assert' - -declare module '@japa/runner' { - interface TestContext { - assert: Assert - } -} diff --git a/bin/test.ts b/bin/test.ts index 5aba7ce..1f3394e 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -1,37 +1,12 @@ +import 'reflect-metadata' import { assert } from '@japa/assert' -import { specReporter } from '@japa/spec-reporter' -import { runFailedTests } from '@japa/run-failed-tests' -import { processCliArgs, configure, run } from '@japa/runner' +import { expectTypeOf } from '@japa/expect-type' +import { configure, processCLIArgs, run } from '@japa/runner' -/* -|-------------------------------------------------------------------------- -| Configure tests -|-------------------------------------------------------------------------- -| -| The configure method accepts the configuration to configure the Japa -| tests runner. -| -| The first method call "processCliArgs" process the command line arguments -| and turns them into a config object. Using this method is not mandatory. -| -| Please consult japa.dev/runner-config for the config docs. -*/ +processCLIArgs(process.argv.splice(2)) configure({ - ...processCliArgs(process.argv.slice(2)), - ...{ - files: ['test/**/*.spec.ts'], - plugins: [assert(), runFailedTests()], - reporters: [specReporter()], - importer: (filePath: string) => import(filePath), - }, + files: ['tests/**/*.spec.ts'], + plugins: [assert(), expectTypeOf()], }) -/* -|-------------------------------------------------------------------------- -| Run tests -|-------------------------------------------------------------------------- -| -| The following "run" method is required to execute all the tests. -| -*/ run() diff --git a/commands/MakePolicy.ts b/commands/MakePolicy.ts deleted file mode 100644 index 336bb49..0000000 --- a/commands/MakePolicy.ts +++ /dev/null @@ -1,193 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { join } from 'path' -import { string } from '@poppinss/utils/build/helpers' -import { BaseCommand, flags, args } from '@adonisjs/core/build/standalone' - -/** - * Stubs for the policy actions - */ -const ACTIONS_STUBS = ( - userVariable: string, - userModel: string, - resourceVariable: string, - resourceModel: string -) => { - return { - viewList: `public async viewList(${userVariable}: ${userModel}) {}`, - view: `public async view(${userVariable}: ${userModel}, ${resourceVariable}: ${resourceModel}) {}`, - create: `public async create(${userVariable}: ${userModel}) {}`, - update: `public async update(${userVariable}: ${userModel}, ${resourceVariable}: ${resourceModel}) {}`, - delete: `public async delete(${userVariable}: ${userModel}, ${resourceVariable}: ${resourceModel}) {}`, - } -} - -/** - * Command to create a new policy - */ -export default class MakePolicyCommand extends BaseCommand { - public static commandName = 'make:policy' - public static description = 'Make a new bouncer policy' - - /** - * Name of the policy - */ - @args.string({ description: 'Name of the policy to create' }) - public name: string - - /** - * Make of the resource model for authorization - */ - @flags.string({ - description: 'Name of the resource model to authorize', - }) - public resourceModel: string - - /** - * Make of the user model to be authorized - */ - @flags.string({ - description: 'Name of the user model to be authorized', - }) - public userModel: string - - /** - * An optional set of actions to write inside the policy class - */ - @flags.array({ - description: 'Actions to implement', - }) - public actions: string[] - - /** - * Makes the namespace for a given model - */ - private makeModelNamespace(model: string) { - const modelsNamespace = this.application.rcFile.namespaces.models - return `${modelsNamespace}/${model.replace(new RegExp(`^${modelsNamespace}/`), '')}` - } - - /** - * Makes the model variable name - */ - private makeModelVariable(model: string) { - return string.camelCase(model) - } - - /** - * Prompt for models when not explicitly defined - */ - public async prepare() { - if (!this.resourceModel) { - this.resourceModel = await this.prompt.ask( - 'Enter the name of the resource model to authorize', - { - hint: 'optional', - } - ) - } - - if (!this.userModel) { - this.userModel = await this.prompt.ask('Enter the name of the user model to be authorized', { - hint: 'optional', - default: 'User', - }) - } - } - - /** - * Run the command - */ - public async run() { - /** - * Prompt for actions when actions are not defined, but resourceModel is - * defined - */ - if (this.resourceModel && (!this.actions || !this.actions.length)) { - this.actions = await this.prompt.multiple('Select the actions you want to authorize', [ - 'None', - 'viewList', - 'view', - 'create', - 'update', - 'delete', - ]) - } - - /** - * Create actions when one or more actions are selected and "None" is not - * selected - */ - const createActions = this.actions && this.actions.length && !this.actions.includes('None') - - /** - * Actions stubs - */ - const actionsStubs = createActions - ? ACTIONS_STUBS( - this.makeModelVariable(this.userModel), - this.userModel, - this.makeModelVariable(this.resourceModel), - this.resourceModel - ) - : {} - - /** - * Policy import - */ - const imports = createActions - ? [ - `import ${this.userModel} from '${this.makeModelNamespace(this.userModel)}'`, - `import ${this.resourceModel} from '${this.makeModelNamespace(this.resourceModel)}'`, - ] - : [] - - const stub = join(__dirname, '..', 'templates', 'policy.txt') - const path = this.application.resolveNamespaceDirectory('policies') - const policiesNamespace = this.application.rcFile.namespaces.policies || 'App/Policies' - - const file = this.generator - .addFile(this.name, { pattern: 'pascalcase', suffix: 'Policy' }) - .stub(stub) - .destinationDir(path || 'app/Policies') - .useMustache() - .apply({ - actions: createActions - ? this.actions.reduce((result, action) => { - if (actionsStubs[action]) { - result = result.concat(actionsStubs[action]) - } - return result - }, []) - : [], - imports, - }) - .appRoot(this.application.cliCwd || this.application.appRoot) - - await this.generator.run() - const fileJSON = file.toJSON() - - if (fileJSON.state === 'persisted') { - this.ui - .instructions() - .heading('Register Policy') - .add(`Open ${this.colors.cyan('start/bouncer.ts')} file`) - .add(`Navigate to ${this.colors.cyan('bouncer.registerPolicies')} function call`) - .add( - `Add ${this.colors - .cyan() - .underline( - `${fileJSON.filename}: () => import('${policiesNamespace}/${fileJSON.filename}')` - )} to the object` - ) - .render() - } - } -} diff --git a/config.json b/config.json deleted file mode 100644 index aeccf6b..0000000 --- a/config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "core": true, - "license": "MIT", - "services": [ - "github-actions" - ], - "minNodeVersion": "14.15.4", - "probotApps": [ - "stale", - "lock" - ], - "runGhActionsOnWindows": false -} \ No newline at end of file diff --git a/examples/UserPolicy.ts b/examples/UserPolicy.ts deleted file mode 100644 index d9ec6aa..0000000 --- a/examples/UserPolicy.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BasePolicy, action } from '@ioc:Adonis/Addons/Bouncer' -import { User } from './user' - -export default class UserPolicy extends BasePolicy { - @action({ allowGuest: true }) - public async update(_user: User, _username: string) { - return true - } -} diff --git a/examples/bouncer.ts b/examples/bouncer.ts deleted file mode 100644 index 6b41610..0000000 --- a/examples/bouncer.ts +++ /dev/null @@ -1,12 +0,0 @@ -import Bouncer from '@ioc:Adonis/Addons/Bouncer' -import { User, Manager } from './user' - -export const { actions } = Bouncer.define('update_user', async (user: User | Manager) => { - return !!user -}).define('view_user', async (user: User | null, isAdmin: boolean) => { - return isAdmin || !!user?.id -}) - -export const { policies } = Bouncer.registerPolicies({ - UserPolicy: () => import('./UserPolicy'), -}) diff --git a/examples/contract.ts b/examples/contract.ts deleted file mode 100644 index 98e7b90..0000000 --- a/examples/contract.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { actions, policies } from './bouncer' -import { User } from './user' - -declare module '@ioc:Adonis/Addons/Bouncer' { - type ApplicationActions = ExtractActionsTypes - type ApplicationPolicies = ExtractPoliciesTypes - - interface ActionsList extends ApplicationActions { - mark_as_stale: Action<[user: User | null, isAdmin: boolean]> - } - - interface PoliciesList extends ApplicationPolicies {} -} diff --git a/examples/index.ts b/examples/index.ts deleted file mode 100644 index 9c9a8a7..0000000 --- a/examples/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Bouncer from '@ioc:Adonis/Addons/Bouncer' -import { User } from './user' - -Bouncer.forUser(new User()).allows('mark_as_stale', true) -Bouncer.forUser(new User()).with('UserPolicy').allows('update', '') diff --git a/examples/user.ts b/examples/user.ts deleted file mode 100644 index 1de328e..0000000 --- a/examples/user.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class User { - public id = 1 -} - -export class Manager { - public name = 'manager' -} diff --git a/japaFile.js b/japaFile.js deleted file mode 100644 index e87aae4..0000000 --- a/japaFile.js +++ /dev/null @@ -1,6 +0,0 @@ -require('@adonisjs/require-ts/build/register') - -const { configure } = require('japa') -configure({ - files: ['test/**/*.spec.ts'], -}) diff --git a/package.json b/package.json index 3890a57..58ddf06 100644 --- a/package.json +++ b/package.json @@ -2,137 +2,117 @@ "name": "@adonisjs/bouncer", "version": "2.3.0", "description": "Authorization layer for AdonisJS", - "main": "build/providers/BouncerProvider.js", + "engines": { + "node": ">=18.16.0" + }, + "main": "build/index.js", + "type": "module", "files": [ - "build/adonis-typings", - "build/providers", - "build/src", - "build/commands", - "build/templates" + "build", + "!build/bin", + "!build/tests", + "!build/tests_helpers" ], - "typings": "./build/adonis-typings/index.d.ts", + "exports": { + ".": "./build/index.js", + "./types": "./build/src/types.js" + }, "scripts": { - "mrm": "mrm --preset=@adonisjs/mrm-preset", "pretest": "npm run lint", - "test": "node -r ts-node/register/transpile-only bin/test.ts", + "test": "cross-env NODE_DEBUG=adonisjs:bouncer npm run quick:test", "clean": "del-cli build", - "copyfiles": "copyfiles \"templates/**/*.txt\" build", - "compile": "npm run lint && npm run clean && tsc", - "build": "npm run compile && npm run copyfiles", + "typecheck": "tsc --noEmit", + "copy:templates": "copyfiles \"stubs/**/*.stub\" --up=\"1\" build", + "precompile": "npm run lint && npm run clean", + "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", + "postcompile": "npm run copy:templates", + "build": "npm run compile", "prepublishOnly": "npm run build", "lint": "eslint . --ext=.ts", "format": "prettier --write .", - "commit": "git-cz", "release": "np", "version": "npm run build", - "sync-labels": "github-label-sync --labels ./node_modules/@adonisjs/mrm-preset/gh-labels.json adonisjs/bouncer" + "sync-labels": "github-label-sync --labels .github/labels.json adonisjs/bouncer", + "quick:test": "c8 node --enable-source-maps --loader=ts-node/esm bin/test.ts" }, "author": "virk,adonisjs", "license": "MIT", + "homepage": "https://github.com/adonisjs/bouncer#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/adonisjs/bouncer.git" + }, + "bugs": { + "url": "https://github.com/adonisjs/bouncer/issues" + }, + "keywords": [ + "authorization", + "adonisjs" + ], "devDependencies": { - "@adonisjs/auth": "^8.2.1", - "@adonisjs/core": "^5.8.2", - "@adonisjs/mrm-preset": "^5.0.3", - "@adonisjs/require-ts": "^2.0.11", - "@adonisjs/view": "^6.1.5", - "@japa/assert": "^1.3.4", - "@japa/run-failed-tests": "^1.0.7", - "@japa/runner": "^2.0.8", - "@japa/spec-reporter": "^1.1.12", - "@poppinss/dev-utils": "^2.0.3", - "@types/node": "^17.0.35", + "@adonisjs/core": "^6.1.5-33", + "@adonisjs/eslint-config": "^1.2.0", + "@adonisjs/prettier-config": "^1.2.0", + "@adonisjs/tsconfig": "^1.2.0", + "@commitlint/cli": "^18.4.3", + "@commitlint/config-conventional": "^18.4.3", + "@japa/assert": "^2.0.0-2", + "@japa/expect-type": "^2.0.0-1", + "@japa/runner": "^3.0.0-9", + "@swc/core": "^1.3.100", + "c8": "^8.0.1", "copyfiles": "^2.4.1", - "del-cli": "^4.0.1", - "eslint": "^8.16.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-adonis": "^2.1.0", - "eslint-plugin-prettier": "^4.0.0", - "github-label-sync": "^2.2.0", - "husky": "^8.0.1", - "mrm": "^4.0.0", - "np": "^7.6.1", - "prettier": "^2.6.2", - "ts-dedent": "^2.2.0", - "ts-node": "^10.8.0", - "typescript": "^4.6.4" + "cross-env": "^7.0.3", + "del-cli": "^5.1.0", + "github-label-sync": "^2.3.1", + "husky": "^8.0.3", + "np": "^9.0.0", + "reflect-metadata": "^0.1.13", + "ts-node": "^10.9.1", + "tsup": "^8.0.1", + "typescript": "5.2.2" }, - "nyc": { - "exclude": [ - "test" + "eslintConfig": { + "extends": "@adonisjs/eslint-config/package" + }, + "prettier": "@adonisjs/prettier-config", + "c8": { + "reporter": [ + "text", + "html" ], - "extension": [ - ".ts" + "exclude": [ + "tests/**" ] }, - "config": { - "commitizen": { - "path": "cz-conventional-changelog" - } - }, - "np": { - "contents": ".", - "anyBranch": false - }, - "dependencies": { - "@poppinss/utils": "^4.0.4" - }, - "peerDependencies": { - "@adonisjs/auth": "^8.0.0", - "@adonisjs/core": "^5.1.0", - "@adonisjs/view": "^6.0.0" - }, - "peerDependenciesMeta": { - "@adonisjs/auth": { - "optional": true - }, - "@adonisjs/view": { - "optional": true - } - }, - "adonisjs": { - "templates": { - "start": [ - { - "src": "bouncer.txt", - "dest": "bouncer" - } - ], - "contracts": [ - { - "src": "contract.txt", - "dest": "bouncer" - } - ] - }, - "preloads": [ - "./start/bouncer" - ], - "commands": [ - "@adonisjs/bouncer/build/commands" - ], - "types": "@adonisjs/bouncer", - "providers": [ - "@adonisjs/bouncer" + "commitlint": { + "extends": [ + "@commitlint/config-conventional" ] }, "publishConfig": { "access": "public", - "tag": "latest" - }, - "directories": { - "example": "examples", - "test": "test" + "tag": "next" }, - "repository": { - "type": "git", - "url": "git+https://github.com/adonisjs/bouncer.git" + "np": { + "message": "chore(release): %s", + "tag": "next", + "branch": "main", + "anyBranch": false }, - "keywords": [ - "authorization", - "adonisjs" - ], - "bugs": { - "url": "https://github.com/adonisjs/bouncer/issues" + "tsup": { + "entry": [ + "./index.ts" + ], + "outDir": "./build", + "clean": true, + "format": "esm", + "dts": false, + "sourcemap": true, + "target": "esnext" }, - "homepage": "https://github.com/adonisjs/bouncer#readme" + "dependencies": { + "@poppinss/hooks": "^7.2.1", + "@poppinss/utils": "^6.6.0" + } } diff --git a/providers/BouncerProvider.ts b/providers/BouncerProvider.ts deleted file mode 100644 index 50306c0..0000000 --- a/providers/BouncerProvider.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -/** - * Lazily resolves the user from the auth module. Coz the bouncer - * property may get access before the auth middleware is - * executed. - */ -class AuthUserResolver { - public getUser = () => this.auth.user - constructor(private auth: any) {} -} - -export default class BouncerServiceProvider { - constructor(protected app: ApplicationContract) {} - - /** - * Register bouncer to the container - */ - public register() { - this.app.container.singleton('Adonis/Addons/Bouncer', () => { - const { Bouncer } = require('../src/Bouncer') - return new Bouncer(this.app) - }) - } - - /** - * Hook into the http context to provide an authorizer instance - */ - public boot() { - this.app.container.withBindings( - ['Adonis/Core/HttpContext', 'Adonis/Addons/Bouncer'], - (HttpContext, Bouncer) => { - HttpContext.getter( - 'bouncer', - function bouncer() { - return Bouncer.forUser( - this.auth ? new AuthUserResolver(this.auth).getUser : null - ) as any - }, - true - ) - } - ) - - this.app.container.withBindings(['Adonis/Core/Server', 'Adonis/Core/View'], (Server, View) => { - const { CanTag, CannotTag } = require('../src/Bindings/View') - View.registerTag(CanTag) - View.registerTag(CannotTag) - - Server.hooks.before(async (ctx) => { - ctx.view.share({ bouncer: ctx.bouncer }) - }) - }) - } -} diff --git a/src/ActionsAuthorizer/index.ts b/src/ActionsAuthorizer/index.ts deleted file mode 100644 index 90572c1..0000000 --- a/src/ActionsAuthorizer/index.ts +++ /dev/null @@ -1,309 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { ProfilerContract, ProfilerRowContract } from '@ioc:Adonis/Core/Profiler' -import { ActionsAuthorizerContract, AuthorizationResult } from '@ioc:Adonis/Addons/Bouncer' - -import { Bouncer } from '../Bouncer' -import { Exception } from '@poppinss/utils' -import { AuthorizationProfiler } from '../Profiler' -import { PoliciesAuthorizer } from '../PoliciesAuthorizer' -import { AuthorizationException } from '../Exceptions/AuthorizationException' -import { hookHasHandledTheRequest, normalizeActionResponse, normalizeHookResponse } from '../utils' - -/** - * Exposes the API to authorize actions - */ -export class ActionsAuthorizer implements ActionsAuthorizerContract { - /** - * An optional profiler instance to profile the actions. Usually it will - * be an instance of the current HTTP request profiler - */ - private profiler?: ProfilerRowContract | ProfilerContract - - /** - * We lookup the user lazily using the "userOrResolver" property. This - * allows the class consumer to provide the user after creating - * the authorizer instance. - * - * We stop calling the resolver, once we receive the user instance. - */ - public user: any - constructor(private userOrResolver: any, private bouncer: Bouncer) {} - - /** - * Resolve the user from the userOrResolver - * property - */ - private resolveUser() { - if (this.user) { - return - } - - if (typeof this.userOrResolver === 'function') { - this.user = this.userOrResolver() - } else { - this.user = this.userOrResolver - } - } - - /** - * Run all hooks for a given lifecycle phase - */ - private async runHooks( - lifecycle: 'before' | 'after', - action: string, - result: null | AuthorizationResult, - args: any[], - profiler: AuthorizationProfiler - ): Promise<{ status: 'skipped' | 'authorized' | 'unauthorized' }> { - let response: any - - for (let hook of this.bouncer.hooks[lifecycle]) { - /** - * Execute hook - */ - response = await profiler.profileFunction( - 'bouncer:hook', - { lifecycle, action, handler: hook.name || 'anonymous' }, - hook, - lifecycle === 'before' - ? [this.user, action, ...args] - : [this.user, action, result!, ...args] - ) - - /** - * Short circuit when response is not undefined or null. Meaning the - * hook has decided to take over the request - */ - if (hookHasHandledTheRequest(response)) { - break - } - } - - return normalizeHookResponse(response) - } - - /** - * Run the action - */ - private async runAction(action: string, args: any[], profiler: AuthorizationProfiler) { - /** - * We should explicitly raise an exception when the action is not defined. - */ - if (!this.bouncer.actions[action]) { - throw new Error( - `Cannot run "${action}" action. Make sure it is defined inside the "start/bouncer" file` - ) - } - - const { handler, options } = this.bouncer.actions[action] - const allowGuest = options && options.allowGuest - - /** - * Disallow when user is missing and guest is not allowed - */ - if (!this.user && !allowGuest) { - return normalizeActionResponse(false) - } - - /** - * Execute action and profile it - */ - const response = await profiler.profileFunction( - 'bouncer:action', - { action, handler: handler.name || 'anonymous' }, - handler, - [this.user, ...args] - ) - - return normalizeActionResponse(response) - } - - /** - * Run the authorization action - */ - private async authorizeAction(action: string, args: any[]) { - const profiler = new AuthorizationProfiler('bouncer:authorize', this.profiler, { action }) - - try { - /** - * Run before hooks and return the result if status is not "skipped" - */ - const { status: beforeStatus } = await this.runHooks('before', action, null, args, profiler) - if (beforeStatus !== 'skipped') { - return profiler.end(normalizeActionResponse(beforeStatus === 'authorized' ? true : false)) - } - - /** - * Run action handler - */ - const result = await this.runAction(action, args, profiler) - - /** - * Run after hooks and return the result if status is not "skipped" - */ - const { status: afterStatus } = await this.runHooks('after', action, result, args, profiler) - if (afterStatus !== 'skipped') { - return profiler.end(normalizeActionResponse(afterStatus === 'authorized' ? true : false)) - } - - return profiler.end(result) - } catch (error) { - profiler.end({ - authorized: false, - errorMessage: null, - error, - }) - - throw error - } - } - - /** - * Set profiler instance to be used for profiling calls - */ - public setProfiler(profiler?: ProfilerRowContract | ProfilerContract) { - this.profiler = profiler - return this - } - - /** - * Create a new authorizer instance for a given user - */ - public forUser(userOrResolver: any) { - return new ActionsAuthorizer(userOrResolver, this.bouncer).setProfiler(this.profiler) - } - - /** - * Returns an instance of the policies authorizer - */ - public with(policy: string): any { - return new PoliciesAuthorizer(this.userOrResolver, this.bouncer, policy).setProfiler( - this.profiler - ) - } - - /** - * Find if a user is allowed to perform the action - */ - public async allows(action: string, ...args: any[]) { - this.resolveUser() - const { authorized } = await this.authorizeAction(action, args) - return authorized === true - } - - /** - * Find if a user is not allowed to perform the action - */ - public async denies(action: string, ...args: any[]) { - this.resolveUser() - const { authorized } = await this.authorizeAction(action, args) - return authorized === false - } - - /** - * Authorize user against the given action - */ - public async authorize(action: string, ...args: any[]) { - this.resolveUser() - const { authorized, errorResponse } = await this.authorizeAction(action, args) - - if (authorized) { - return - } - - throw AuthorizationException.raise(errorResponse![0], errorResponse![1]) - } - - /** - * Parses the ability arguments passed to "can" and "cannot" methods - */ - private parseAbilityArguments(policyOrAction: string, args: any[]) { - const tokens = policyOrAction.split('.') - const usingCustomAuthorizer = args.length && args[0] instanceof ActionsAuthorizer - - let output: { - action: string - args: any[] - policy?: null | string - authorizer: ActionsAuthorizer - } = { - action: policyOrAction, - authorizer: this, - args: args, - } - - if (usingCustomAuthorizer) { - output.authorizer = args.shift() - output.args = args - } - - if (tokens.length > 1) { - output.policy = tokens.shift() - output.action = tokens.join('.') - } - - return output - } - - /** - * The untyped version of [[this.allows]] and support references a policy.action - * via string. Added mainly to be used inside the templates. - * - * For example: - * ``` - * bouncer.can('PostPolicy.update', post) - * ``` - */ - public async can(policyOrAction: string, ...args: any[]) { - if (!policyOrAction) { - throw new Exception('The "can" method expects action name as the first argument') - } - - const { - action, - policy, - authorizer, - args: parsedArgs, - } = this.parseAbilityArguments(policyOrAction, args) - - return policy - ? authorizer.with(policy).allows(action, ...parsedArgs) - : authorizer.allows(action, ...parsedArgs) - } - - /** - * The untyped version of [[this.denies]] and support references a policy.action - * via string. Added mainly to be used inside the templates. - * - * For example: - * ``` - * bouncer.cannot('PostPolicy.update', post) - * ``` - */ - public async cannot(policyOrAction: string, ...args: any[]) { - if (!policyOrAction) { - throw new Exception('The "cannot" method expects action name as the first argument') - } - - const { - action, - policy, - authorizer, - args: parsedArgs, - } = this.parseAbilityArguments(policyOrAction, args) - - return policy - ? authorizer.with(policy).denies(action, ...parsedArgs) - : authorizer.denies(action, ...parsedArgs) - } -} diff --git a/src/BasePolicy/index.ts b/src/BasePolicy/index.ts deleted file mode 100644 index 6f27bf3..0000000 --- a/src/BasePolicy/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { defineStaticProperty } from '@poppinss/utils' -import { ActionOptions, BasePolicyContract } from '@ioc:Adonis/Addons/Bouncer' - -/** - * Every policy must extend the base policy - */ -export class BasePolicy implements BasePolicyContract { - public static booted: boolean - public static actionsOptions: { [key: string]: ActionOptions } - - /** - * Boot the policy - */ - public static boot() { - /** - * Define the property when not defined on self - */ - if (!this.hasOwnProperty('booted')) { - this.booted = false - } - - /** - * Return when already booted - */ - if (this.booted === true) { - return - } - - this.booted = true - defineStaticProperty(this, BasePolicy, { - propertyName: 'actionsOptions', - defaultValue: {}, - strategy: 'inherit', - }) - } - - /** - * Store action actions. This is usually invoked via a decarator - */ - public static storeActionOptions(propertyName: any, options: ActionOptions) { - this.actionsOptions[propertyName] = options - return this - } -} diff --git a/src/Bindings/View.ts b/src/Bindings/View.ts deleted file mode 100644 index 58f15f0..0000000 --- a/src/Bindings/View.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { EdgeError } from 'edge-error' -import { TagContract } from '@ioc:Adonis/Core/View' - -/** - * Can tag - */ -export const CanTag: TagContract = { - tagName: 'can', - block: true, - seekable: true, - compile(parser, buffer, token) { - if (!token.properties.jsArg.trim()) { - throw new EdgeError('Define the action name to authorize', 'E_RUNTIME_EXCEPTION', { - filename: token.filename, - line: token.loc.start.line, - col: token.loc.start.col, - }) - } - - const parsed = parser.utils.transformAst( - parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), - token.filename, - parser - ) - - /** - * For non sequence expression we have to wrap the args inside parenthesis - */ - const openingBrace = parsed.type !== 'SequenceExpression' ? '(' : '' - const closingBrace = parsed.type !== 'SequenceExpression' ? ')' : '' - - /** - * Write the if statement - */ - buffer.writeStatement( - `if (await state.bouncer.can${openingBrace}${parser.utils.stringify( - parsed - )}${closingBrace}) {`, - token.filename, - token.loc.start.line - ) - - /** - * Process all children - */ - token.children.forEach((child) => parser.processToken(child, buffer)) - - /** - * Close if statement - */ - buffer.writeStatement('}', token.filename, -1) - }, -} - -/** - * Cannot tag - */ -export const CannotTag: TagContract = { - tagName: 'cannot', - block: true, - seekable: true, - compile(parser, buffer, token) { - if (!token.properties.jsArg.trim()) { - throw new EdgeError('Define the action name to authorize', 'E_RUNTIME_EXCEPTION', { - filename: token.filename, - line: token.loc.start.line, - col: token.loc.start.col, - }) - } - - const parsed = parser.utils.transformAst( - parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), - token.filename, - parser - ) - - /** - * For non sequence expression we have to wrap the args inside parenthesis - */ - const openingBrace = parsed.type !== 'SequenceExpression' ? '(' : '' - const closingBrace = parsed.type !== 'SequenceExpression' ? ')' : '' - - /** - * Write the if statement - */ - buffer.writeStatement( - `if (await state.bouncer.cannot${openingBrace}${parser.utils.stringify( - parsed - )}${closingBrace}) {`, - token.filename, - token.loc.start.line - ) - - /** - * Process all children - */ - token.children.forEach((child) => parser.processToken(child, buffer)) - - /** - * Close if statement - */ - buffer.writeStatement('}', token.filename, -1) - }, -} diff --git a/src/Bouncer/index.ts b/src/Bouncer/index.ts deleted file mode 100644 index 2a3f9b2..0000000 --- a/src/Bouncer/index.ts +++ /dev/null @@ -1,168 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { BasePolicyContract } from '@ioc:Adonis/Addons/Bouncer' - -import { - LazyPolicy, - ActionOptions, - ActionHandler, - BouncerContract, - AfterHookHandler, - BeforeHookHandler, - BasePolicyConstructorContract, -} from '@ioc:Adonis/Addons/Bouncer' - -import { action } from '../Decorators' -import { BasePolicy } from '../BasePolicy' -import { ActionsAuthorizer } from '../ActionsAuthorizer' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -/** - * Bouncer exposes the API for registering authorization actions and policies - */ -export class Bouncer implements BouncerContract { - /** - * A set of resolved policies. This is to avoid re-importing the same - * policies again and again - */ - private resolvedPolicies: { - [key: string]: BasePolicyConstructorContract - } = {} - - /** - * Set of registered actions - */ - public actions: { - [key: string]: { - handler: ActionHandler - options?: ActionOptions - } - } = {} - - /** - * Set of registered policies - */ - public policies: { [key: string]: LazyPolicy } = {} - - /** - * Set of registered hooks - */ - public hooks: BouncerContract['hooks'] = { - before: [], - after: [], - } - - /** - * Reference to the base policy - */ - public BasePolicy = BasePolicy - public action = action - - constructor(private application: ApplicationContract) {} - - /** - * Register a before hook - */ - public before(callback: BeforeHookHandler): this { - this.hooks.before.push(callback) - return this - } - - /** - * Register an after hook - */ - public after(callback: AfterHookHandler): this { - this.hooks.after.push(callback) - return this - } - - /** - * Define an authorization action - */ - public define( - actionName: Action, - handler: ActionHandler, - options?: ActionOptions - ): any { - if (typeof handler !== 'function') { - throw new Error(`Invalid handler for "${actionName}" action. Must be a function`) - } - - this.actions[actionName] = { handler, options } - return this - } - - /** - * Register policies - */ - public registerPolicies(policies: { [key: string]: LazyPolicy }): any { - Object.keys(policies).forEach((policy) => { - if (typeof policies[policy] !== 'function') { - throw new Error( - `Invalid value for "${policy}" policy. Must be a function importing the policy class` - ) - } - }) - - this.policies = policies - return this - } - - /** - * Returns the authorizer for a given user - */ - public forUser(user: any) { - return new ActionsAuthorizer(user, this) - } - - /** - * Deny authorization check using a custom message and status - */ - public deny(message: string, status?: number): [string, number] { - return [message, status || 403] - } - - /** - * Resolve policy from the set of pre-registered policies - */ - public async resolvePolicy(policy: string): Promise { - /** - * Return pre-resolved policy - */ - if (this.resolvedPolicies[policy]) { - return this.application.container.makeAsync(this.resolvedPolicies[policy]) - } - - /** - * Ensure policy is registered - */ - if (typeof this.policies[policy] !== 'function') { - throw new Error( - `Cannot use "${policy}" policy. Make sure it is defined as a function inside "start/bouncer" file` - ) - } - - const policyExport = await this.policies[policy]() - - /** - * Ensure policy has a default export - */ - if (!policyExport || !policyExport.default) { - throw new Error( - `Invalid "${policy}" policy. Make sure to export default the policy implementation` - ) - } - - policyExport.default.boot() - return this.application.container.makeAsync(policyExport.default) - } -} diff --git a/src/Decorators/index.ts b/src/Decorators/index.ts deleted file mode 100644 index f5b2782..0000000 --- a/src/Decorators/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { ActionDecorator, BasePolicyConstructorContract } from '@ioc:Adonis/Addons/Bouncer' - -export const action: ActionDecorator = (options) => { - return function decorateAsColumn(target, property) { - const Policy = target.constructor as BasePolicyConstructorContract - Policy.boot() - Policy.storeActionOptions(property, options) - } -} diff --git a/src/Exceptions/AuthorizationException.ts b/src/Exceptions/AuthorizationException.ts deleted file mode 100644 index 76301c5..0000000 --- a/src/Exceptions/AuthorizationException.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' - -export class AuthorizationException extends Exception { - public static raise(message: string, status: number) { - return new this(message, status, 'E_AUTHORIZATION_FAILURE') - } -} diff --git a/src/PoliciesAuthorizer/index.ts b/src/PoliciesAuthorizer/index.ts deleted file mode 100644 index 08279ff..0000000 --- a/src/PoliciesAuthorizer/index.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { ProfilerContract, ProfilerRowContract } from '@ioc:Adonis/Core/Profiler' -import { - PoliciesList, - BasePolicyContract, - AuthorizationResult, - PoliciesAuthorizerContract, - BasePolicyConstructorContract, -} from '@ioc:Adonis/Addons/Bouncer' - -import { Bouncer } from '../Bouncer' -import { AuthorizationProfiler } from '../Profiler' -import { normalizeActionResponse, normalizeHookResponse } from '../utils' -import { AuthorizationException } from '../Exceptions/AuthorizationException' - -/** - * Exposes the API to authorize actions using a policy class - */ -export class PoliciesAuthorizer implements PoliciesAuthorizerContract { - /** - * An optional profiler instance to profile the actions. Usually it will - * be an instance of the current HTTP request profiler - */ - private profiler?: ProfilerRowContract | ProfilerContract - - /** - * The instance of the policy class. We need to resolve it only - * once per authorizer instance - */ - private policyInstance: BasePolicyContract - - /** - * We lookup the user lazily using the "userOrResolver" property. This - * allows the class consumer to provide the user after creating - * the authorizer instance. - * - * We stop calling the resolver, once we receive the user instance. - */ - public user: any - constructor(private userOrResolver: any, private bouncer: Bouncer, private policy: string) {} - - /** - * Resolve policy - */ - private async resolvePolicy() { - if (this.policyInstance) { - return - } - this.policyInstance = await this.bouncer.resolvePolicy(this.policy) - } - - /** - * Resolve the user from the userOrResolver - * property - */ - private resolveUser() { - if (this.user) { - return - } - - if (typeof this.userOrResolver === 'function') { - this.user = this.userOrResolver() - } else { - this.user = this.userOrResolver - } - } - - /** - * Run before/after hooks for a given lifecycle phase - */ - private async runHooks( - lifecycle: 'before' | 'after', - action: string, - result: null | AuthorizationResult, - args: any[], - profiler: AuthorizationProfiler - ): Promise<{ status: 'skipped' | 'authorized' | 'unauthorized' }> { - if (typeof this.policyInstance[lifecycle] !== 'function') { - return { status: 'skipped' as const } - } - - const response = await profiler.profileFunction( - 'bouncer:hook', - { lifecycle, action, handler: lifecycle }, - this.policyInstance[lifecycle].bind(this.policyInstance), - lifecycle === 'before' ? [this.user, action, ...args] : [this.user, action, result!, ...args] - ) - - return normalizeHookResponse(response) - } - - /** - * Run the action - */ - private async runAction(action: string, args: any[], profiler: AuthorizationProfiler) { - if (typeof this.policyInstance[action] !== 'function') { - throw new Error( - `Cannot run "${action}" action. Make sure it is defined on the "${this.policy}" class` - ) - } - - const Policy = this.policyInstance.constructor as BasePolicyConstructorContract - const options = Policy.actionsOptions[action] - const allowGuest = options && options.allowGuest - - /** - * Disallow when user is missing and guest is not allowed - */ - if (!this.user && !allowGuest) { - return normalizeActionResponse(false) - } - - /** - * Execute action and profile it - */ - const response = await profiler.profileFunction( - 'bouncer:action', - { action, handler: action }, - this.policyInstance[action].bind(this.policyInstance), - [this.user, ...args] - ) - - return normalizeActionResponse(response) - } - - /** - * Run the authorization action - */ - private async authorizeAction(action: string, args: any[]) { - const profiler = new AuthorizationProfiler('bouncer:authorize', this.profiler, { - action, - policy: this.policy, - }) - - try { - /** - * Run before hooks and return the result if status is not "skipped" - */ - const { status: beforeStatus } = await this.runHooks('before', action, null, args, profiler) - if (beforeStatus !== 'skipped') { - return profiler.end(normalizeActionResponse(beforeStatus === 'authorized' ? true : false)) - } - - /** - * Run action handler - */ - const result = await this.runAction(action, args, profiler) - - /** - * Run after hooks and return the result if status is not "skipped" - */ - const { status: afterStatus } = await this.runHooks('after', action, result, args, profiler) - if (afterStatus !== 'skipped') { - return profiler.end(normalizeActionResponse(afterStatus === 'authorized' ? true : false)) - } - - return profiler.end(result) - } catch (error) { - profiler.end({ - authorized: false, - errorMessage: null, - error, - }) - - throw error - } - } - - /** - * Set profiler instance to be used for profiling calls - */ - public setProfiler(profiler?: ProfilerRowContract | ProfilerContract) { - this.profiler = profiler - return this - } - - /** - * Find if a user is allowed to perform the action - */ - public async allows(action: string, ...args: any[]) { - await this.resolvePolicy() - this.resolveUser() - const { authorized } = await this.authorizeAction(action, args) - return authorized === true - } - - /** - * Find if a user is not allowed to perform the action - */ - public async denies(action: string, ...args: any[]) { - await this.resolvePolicy() - this.resolveUser() - const { authorized } = await this.authorizeAction(action, args) - return authorized === false - } - - /** - * Authorize user against the given action - */ - public async authorize(action: string, ...args: any[]) { - await this.resolvePolicy() - this.resolveUser() - - const { authorized, errorResponse } = await this.authorizeAction(action, args) - if (authorized) { - return - } - - throw AuthorizationException.raise(errorResponse![0], errorResponse![1]) - } - - /** - * Create a new authorizer instance for a given user - */ - public forUser(userOrResolver: any) { - return new PoliciesAuthorizer(userOrResolver, this.bouncer, this.policy).setProfiler( - this.profiler - ) - } -} diff --git a/src/Profiler/index.ts b/src/Profiler/index.ts deleted file mode 100644 index 27806b1..0000000 --- a/src/Profiler/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { ProfilerRowContract, ProfilerContract } from '@ioc:Adonis/Core/Profiler' - -export class AuthorizationProfiler { - private profiler?: ProfilerRowContract - - constructor( - label: string, - profiler?: ProfilerRowContract | ProfilerContract, - profilerPayload?: any - ) { - if (profiler) { - this.profiler = profiler.create(label, profilerPayload) - } - } - - /** - * Profiles a function - */ - public async profileFunction any>( - actionName: string, - data: any, - fn: Fn, - args: any[] - ): Promise> { - if (!this.profiler) { - return fn(...args) - } - - const action = this.profiler.create(actionName, data) - try { - const response = await fn(...args) - action.end() - return response - } catch (error) { - action.end({ error }) - throw error - } - } - - public end(payload: Payload): Payload { - if (this.profiler) { - this.profiler.end(payload) - } - return payload - } -} diff --git a/src/action.ts b/src/action.ts new file mode 100644 index 0000000..87a82ca --- /dev/null +++ b/src/action.ts @@ -0,0 +1,41 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { AuthorizationResponse } from './response.js' +import { AuthorizerResponse, BouncerAction, BouncerAuthorizer } from './types.js' + +/** + * Helper to convert a user defined authorizer function to a bouncer action + */ +export function action>( + authorizer: Authorizer, + options?: { allowGuest: boolean } +) { + return { + allowGuest: options?.allowGuest || false, + original: authorizer, + async execute(user, ...args) { + if (user === null && !this.allowGuest) { + return new AuthorizationResponse(false) + } + + const response = await this.original(user, ...args) + return typeof response === 'boolean' ? new AuthorizationResponse(response) : response + }, + } satisfies BouncerAction as Authorizer extends ( + user: infer User, + ...args: infer Args + ) => AuthorizerResponse + ? { + allowGuest: false + original: Authorizer + execute(user: User | null, ...args: Args): Promise + } + : never +} diff --git a/src/base_policy.ts b/src/base_policy.ts new file mode 100644 index 0000000..f5c8bbd --- /dev/null +++ b/src/base_policy.ts @@ -0,0 +1,33 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { defineStaticProperty } from '@poppinss/utils' + +export abstract class BasePolicy { + static booted: boolean = false + static actionsMetaData: Record = {} + + static boot() { + if (!this.hasOwnProperty('booted')) { + this.booted = false + } + if (this.booted === false) { + this.booted = true + defineStaticProperty(this, 'actionsMetaData', { initialValue: {}, strategy: 'inherit' }) + } + } + + /** + * Set metadata for a action name + */ + static setActionMetaData(actionName: string, options: { allowGuest: boolean }) { + this.boot() + this.actionsMetaData[actionName] = options + } +} diff --git a/src/bouncer.ts b/src/bouncer.ts new file mode 100644 index 0000000..bbdc7e2 --- /dev/null +++ b/src/bouncer.ts @@ -0,0 +1,327 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { inspect } from 'node:util' +import { RuntimeException } from '@poppinss/utils' +import { type ContainerResolver } from '@adonisjs/core/container' + +import { action as createAction } from './action.js' +import { AuthorizationResponse } from './response.js' +import { + Constructor, + BouncerAction, + GetPolicyMethods, + AuthorizerResponse, + NarrowActionsForAUser, + LazyImport, +} from './types.js' +import { BasePolicy } from './base_policy.js' + +/** + * Bouncer exposes the API to evaluate bouncer actions and policies to + * verify if a user is authorized to perform the given action + */ +export class Bouncer< + User extends Record, + Actions extends Record> | undefined = undefined, + Policies extends Record> | undefined = undefined, +> { + /** + * Define a bouncer action from a callback + */ + static action = createAction + + /** + * User resolver to lazily resolve the user + */ + #userOrResolver: User | (() => User | null) | null + + /** + * Reference to the resolved user + */ + #user?: User | null + + /** + * Pre-defined actions + */ + #actions?: Actions + + /** + * A set of policies we already know are classes. Just + * to avoid the class check + */ + #knownPolicies: Set> = new Set() + + /** + * Reference to the IoC container resolver. It is needed + * to optionally construct policy class instances + */ + #containerResolver?: ContainerResolver + + constructor(userOrResolver: User | (() => User | null) | null, actions?: Actions) { + this.#userOrResolver = userOrResolver + this.#actions = actions + } + + /** + * Set a container resolver to use for resolving policies + */ + setContainerResolver(containerResolver: ContainerResolver) { + this.#containerResolver = containerResolver + } + + /** + * Returns reference to the user object + */ + #getUser() { + if (this.#user === undefined) { + if (typeof this.#userOrResolver === 'function') { + this.#user = this.#userOrResolver() + } else { + this.#user = this.#userOrResolver + } + } + + return this.#user + } + + /** + * Check if the policy reference is a class constructor + */ + #isAClass(Policy: unknown): Policy is Constructor { + if (this.#knownPolicies.has(Policy as any)) { + return true + } + return typeof Policy === 'function' && Policy.toString().startsWith('class ') + } + + /** + * Check if a policy method allows guest users + */ + #policyAllowsGuests(Policy: Constructor, action: string): boolean { + if (!('actionsMetaData' in Policy)) { + return false + } + + const methodMetaData = (Policy.actionsMetaData as (typeof BasePolicy)['actionsMetaData'])[ + action + ] + if (!methodMetaData) { + return false + } + + return methodMetaData.allowGuest + } + + /** + * Executes a policy action from the policy class constructor and a + * method on the class. + */ + async #executePolicyAction(Policy: unknown, action: string, ...args: any[]) { + /** + * Ensure policy is a class constructor + */ + if (!this.#isAClass(Policy)) { + throw new RuntimeException('Invalid policy reference. It must be a class constructor') + } + + /** + * Create an instance of the class either using the container + * resolver or manually. + */ + const policyInstance = this.#containerResolver + ? await this.#containerResolver.make(Policy) + : new Policy() + + /** + * Ensure the method exists on the policy class instead + */ + if (typeof policyInstance[action] !== 'function') { + throw new RuntimeException(`Cannot find method "${action}" on "[class ${Policy.name}]"`) + } + + const user = this.#getUser() + + /** + * Disallow action for guest users + */ + if (user === null && !this.#policyAllowsGuests(Policy, action)) { + return new AuthorizationResponse(false) + } + + /** + * Invoke action manually and normalize its response + */ + const response = await policyInstance[action](user, ...args) + return typeof response === 'boolean' ? new AuthorizationResponse(response) : response + } + + /** + * Execute an action from a policy class. The policy will be + * constructed using the AdonisJS IoC container + */ + execute< + Policy extends Constructor, + Method extends GetPolicyMethods>, + >( + action: [Policy, Method], + ...args: InstanceType[Method] extends ( + user: User, + ...args: infer Args + ) => AuthorizerResponse + ? Args + : never + ): Promise + + /** + * Execute an action by reference + */ + execute>( + action: Action, + ...args: Action extends { + original: (user: User, ...args: infer Args) => AuthorizerResponse + } + ? Args + : never + ): Promise + + /** + * Execute an action from the list of pre-defined actions + */ + execute>( + action: Action, + ...args: Actions[Action] extends { + original: (user: User, ...args: infer Args) => AuthorizerResponse + } + ? Args + : never + ): Promise + + async execute(action: any, ...args: any[]): Promise { + /** + * Executing action from a pre-defined list of actions + */ + if (this.#actions && this.#actions[action]) { + return this.#actions[action].execute(this.#getUser(), ...args) + } + + /** + * Executing policy action + */ + if (Array.isArray(action)) { + return this.#executePolicyAction(action[0], action[1], ...args) + } + + /** + * Ensure value is an action reference or throw error + */ + if (!action || typeof action !== 'object' || 'execute' in action === false) { + throw new RuntimeException(`Invalid bouncer action ${inspect(action)}`) + } + + /** + * Executing action by reference + */ + return await (action as BouncerAction).execute(this.#getUser(), ...args) + } + + /** + * Check if a user is allowed to perform a policy action. The policy will be + * constructed using the AdonisJS IoC container + */ + allows< + Policy extends Constructor, + Method extends GetPolicyMethods>, + >( + action: [Policy, Method], + ...args: InstanceType[Method] extends ( + user: User, + ...args: infer Args + ) => AuthorizerResponse + ? Args + : never + ): Promise + + /** + * Check if a user is allowed to perform an action provided + * as a reference + */ + allows>( + action: Action, + ...args: Action extends { + original: (user: User, ...args: infer Args) => AuthorizerResponse + } + ? Args + : never + ): Promise + + /** + * Check if a user is allowed to perform an action + * from the list of pre-defined actions + */ + allows>( + action: Action, + ...args: Actions[Action] extends { + original: (user: User, ...args: infer Args) => AuthorizerResponse + } + ? Args + : never + ): Promise + async allows(action: any, ...args: any[]): Promise { + const response = await this.execute(action, ...args) + return response.authorized + } + + /** + * Check if a user is denied from performing a policy action. The policy will be + * constructed using the AdonisJS IoC container + */ + denies< + Policy extends Constructor, + Method extends GetPolicyMethods>, + >( + action: [Policy, Method], + ...args: InstanceType[Method] extends ( + user: User, + ...args: infer Args + ) => AuthorizerResponse + ? Args + : never + ): Promise + + /** + * Check if a user is denied from performing an action provided + * as a reference + */ + denies>( + action: Action, + ...args: Action extends { + original: (user: User, ...args: infer Args) => AuthorizerResponse + } + ? Args + : never + ): Promise + + /** + * Check if a user is denied from performing an action + * from the list of pre-defined actions + */ + denies>( + action: Action, + ...args: Actions[Action] extends { + original: (user: User, ...args: infer Args) => AuthorizerResponse + } + ? Args + : never + ): Promise + async denies(action: any, ...args: any[]): Promise { + const response = await this.execute(action, ...args) + return !response.authorized + } +} diff --git a/commands/index.ts b/src/response.ts similarity index 60% rename from commands/index.ts rename to src/response.ts index e7887c1..babd611 100644 --- a/commands/index.ts +++ b/src/response.ts @@ -1,10 +1,12 @@ /* * @adonisjs/bouncer * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -export default ['@adonisjs/bouncer/build/commands/MakePolicy'] +export class AuthorizationResponse { + constructor(public authorized: boolean) {} +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..886f52c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,69 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { AuthorizationResponse } from './response.js' + +/** + * Representation of a constructor + */ +export type Constructor = new (...args: any[]) => T + +/** + * Representation of a lazy default import + */ +export type LazyImport = () => Promise<{ default: DefaultExport }> + +/** + * Helper to unwrap lazy import + */ +export type UnWrapLazyImport> = Awaited>['default'] + +/** + * Returns a list of actions from a policy class that could be + * used with a specific bouncer instance for a given user + */ +export type GetPolicyMethods = { + [K in keyof Policy]: Policy[K] extends BouncerAuthorizer ? K : never +}[keyof Policy] + +/** + * Narrowing the list of actions that can be used for + * a specific bouncer instance for a given user + */ +export type NarrowActionsForAUser< + User, + Actions extends Record> | undefined, +> = { + [K in keyof Actions]: Actions[K] extends BouncerAction ? K : never +}[keyof Actions] + +/** + * A response that can be returned by an authorizer + */ +export type AuthorizerResponse = + | boolean + | Promise + | AuthorizationResponse + | Promise + +/** + * The callback function that authorizes an action. It should always + * accept the user as the first argument, followed by additional + * arguments. + */ +export type BouncerAuthorizer = (user: User, ...args: any[]) => AuthorizerResponse + +/** + * Representation of a known bouncer action + */ +export type BouncerAction = { + allowGuest: boolean + original: BouncerAuthorizer + execute(user: User | null, ...args: any[]): Promise +} diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index 966ec40..0000000 --- a/src/utils/index.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { AuthorizationResult } from '@ioc:Adonis/Addons/Bouncer' - -const ERROR_MESSAGE = 'Not authorized to perform this action' -const ERROR_STATUS = 403 - -/** - * Normalizes the authorization hook response - */ -export function normalizeHookResponse(response: any): { - status: 'skipped' | 'authorized' | 'unauthorized' -} { - return { - status: - response === null || response === undefined - ? ('skipped' as const) - : response === true - ? ('authorized' as const) - : ('unauthorized' as const), - } -} - -/** - * Normalizes the authorization action response - */ -export function normalizeActionResponse(response: any): AuthorizationResult { - /** - * Explicit true is considered a pass - */ - if (response === true) { - return { - authorized: true, - errorResponse: null, - } - } - - /** - * Handle "Bouncer.deny" calls - */ - if (Array.isArray(response) && response.length) { - const [message, status] = response - - return { - authorized: false, - errorResponse: [message || 'Unauthorized Access', status || 403] as [string, number], - } - } - - /** - * Everything else is marked as a failure - */ - return { - authorized: false, - errorResponse: [ERROR_MESSAGE, ERROR_STATUS] as [string, number], - } -} - -/** - * Profile a function call - */ -export async function profileFunction any>( - actionName: string, - data: any, - fn: Fn, - args: any[] -): Promise> { - if (!this.actionProfiler) { - return fn(...args) - } - - const action = this.actionProfiler.create(actionName, data) - try { - const response = await fn(...args) - action.end() - return response - } catch (error) { - action.end({ error }) - throw error - } -} - -/** - * Inspect response to check if hook has hanlded the request - * already or not - */ -export function hookHasHandledTheRequest(response: any) { - return response !== null && response !== undefined -} diff --git a/templates/bouncer.txt b/templates/bouncer.txt deleted file mode 100644 index 03adf28..0000000 --- a/templates/bouncer.txt +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Contract source: https://git.io/Jte3T - * - * Feel free to let us know via PR, if you find something broken in this config - * file. - */ - -import Bouncer from '@ioc:Adonis/Addons/Bouncer' - -/* -|-------------------------------------------------------------------------- -| Bouncer Actions -|-------------------------------------------------------------------------- -| -| Actions allows you to separate your application business logic from the -| authorization logic. Feel free to make use of policies when you find -| yourself creating too many actions -| -| You can define an action using the `.define` method on the Bouncer object -| as shown in the following example -| -| ``` -| Bouncer.define('deletePost', (user: User, post: Post) => { -| return post.user_id === user.id -| }) -| ``` -| -|**************************************************************** -| NOTE: Always export the "actions" const from this file -|**************************************************************** -*/ -export const { actions } = Bouncer - -/* -|-------------------------------------------------------------------------- -| Bouncer Policies -|-------------------------------------------------------------------------- -| -| Policies are self contained actions for a given resource. For example: You -| can create a policy for a "User" resource, one policy for a "Post" resource -| and so on. -| -| The "registerPolicies" accepts a unique policy name and a function to lazy -| import the policy -| -| ``` -| Bouncer.registerPolicies({ -| UserPolicy: () => import('App/Policies/User'), -| PostPolicy: () => import('App/Policies/Post') -| }) -| ``` -| -|**************************************************************** -| NOTE: Always export the "policies" const from this file -|**************************************************************** -*/ -export const { policies } = Bouncer.registerPolicies({}) diff --git a/templates/contract.txt b/templates/contract.txt deleted file mode 100644 index 57c5ec2..0000000 --- a/templates/contract.txt +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Contract source: https://git.io/Jte3v - * - * Feel free to let us know via PR, if you find something broken in this config - * file. - */ - -import { actions, policies } from '../start/bouncer' - -declare module '@ioc:Adonis/Addons/Bouncer' { - type ApplicationActions = ExtractActionsTypes - type ApplicationPolicies = ExtractPoliciesTypes - - interface ActionsList extends ApplicationActions {} - interface PoliciesList extends ApplicationPolicies {} -} diff --git a/templates/policy.txt b/templates/policy.txt deleted file mode 100644 index 99b5ebe..0000000 --- a/templates/policy.txt +++ /dev/null @@ -1,10 +0,0 @@ -import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer' -{{#imports}} -{{{.}}} -{{/imports}} - -export default class {{ filename }} extends BasePolicy { - {{#actions}} - {{{.}}} - {{/actions}} -} diff --git a/test-helpers/index.ts b/test-helpers/index.ts deleted file mode 100644 index 2f69564..0000000 --- a/test-helpers/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { join } from 'path' -import { Filesystem } from '@poppinss/dev-utils' -import { Application } from '@adonisjs/core/build/standalone' - -export const fs = new Filesystem(join(__dirname, '__app')) - -export async function setup(setupProviders?: boolean) { - const application = new Application(fs.basePath, 'web', { - providers: ['@adonisjs/core', '@adonisjs/view', '../../providers/BouncerProvider'], - }) - - await fs.add( - 'config/app.ts', - ` - export const profiler = { enabled: true } - export const appKey = 'averylongrandomsecretkey' - export const http = { - trustProxy: () => {}, - cookie: {} - } - ` - ) - - await application.setup() - - if (setupProviders) { - await application.registerProviders() - await application.bootProviders() - } - - return application -} diff --git a/test/actions-authorizer.spec.ts b/test/actions-authorizer.spec.ts deleted file mode 100644 index ef4b78b..0000000 --- a/test/actions-authorizer.spec.ts +++ /dev/null @@ -1,861 +0,0 @@ -/* @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' - -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { Bouncer } from '../src/Bouncer' -import { setup, fs } from '../test-helpers' - -let app: ApplicationContract - -test.group('Actions Authorizer', (group) => { - group.each.setup(async () => { - app = await setup(false) - }) - - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('return true if a user is allowed to perform an action', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isTrue(await authorizer.allows('viewPost', new Post(1))) - }) - - test('return false if a user is not allowed to perform an action', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isFalse(await authorizer.allows('viewPost', new Post(2))) - }) - - test('return true if a user is denied to perform an action', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isTrue(await authorizer.denies('viewPost', new Post(2))) - }) - - test('return false if a user is not denied to perform an action', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isFalse(await authorizer.denies('viewPost', new Post(1))) - }) - - test('raise exception when a user is not allowed to perform an action', async ({ assert }) => { - assert.plan(2) - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - - try { - await authorizer.authorize('viewPost', new Post(2)) - } catch (error) { - assert.equal(error.message, 'E_AUTHORIZATION_FAILURE: Not authorized to perform this action') - assert.equal(error.status, 403) - } - }) - - test('allow custom denial message', async ({ assert }) => { - assert.plan(2) - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.define('viewPost', (user: User, post: Post) => { - if (user.id === post.userId) { - return true - } - return ['Cannot access post'] - }) - - const authorizer = bouncer.forUser(new User(1)) - - try { - await authorizer.authorize('viewPost', new Post(2)) - } catch (error) { - assert.equal(error.message, 'E_AUTHORIZATION_FAILURE: Cannot access post') - assert.equal(error.status, 403) - } - }) - - test('allow custom denial message with custom status code', async ({ assert }) => { - assert.plan(2) - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.define('viewPost', (user: User, post: Post) => { - if (user.id === post.userId) { - return true - } - return ['Post not found', 404] - }) - - const authorizer = bouncer.forUser(new User(1)) - - try { - await authorizer.authorize('viewPost', new Post(2)) - } catch (error) { - assert.equal(error.message, 'E_AUTHORIZATION_FAILURE: Post not found') - assert.equal(error.status, 404) - } - }) - - test('allow switching user at runtime', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - - assert.isFalse(await authorizer.allows('viewPost', new Post(2))) - assert.isTrue(await authorizer.forUser(new User(2)).allows('viewPost', new Post(2))) - }) - - test('authorize action from a before hook', async ({ assert }) => { - let actionInvocationCounts = 0 - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.before((user: User) => { - if (user.isSuperAdmin) { - return true - } - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - actionInvocationCounts++ - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isTrue(await authorizer.allows('viewPost', new Post(2))) - assert.isTrue(await authorizer.forUser(new User(2)).allows('viewPost', new Post(2))) - assert.equal(actionInvocationCounts, 1) - }) - - test('allow before hook to authorize non-existing actions', async ({ assert }) => { - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.before(() => { - return true - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isTrue(await authorizer.allows('viewPost', new Post(2))) - assert.isTrue(await authorizer.forUser(new User(2)).allows('viewPost', new Post(2))) - }) - - test('allow before hook to deny non-existing actions', async ({ assert }) => { - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.before(() => { - return false - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isFalse(await authorizer.allows('viewPost', new Post(2))) - assert.isFalse(await authorizer.forUser(new User(2)).allows('viewPost', new Post(2))) - }) - - test('raise exception when action is not defined', async ({ assert }) => { - assert.plan(1) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.before(() => { - return - }) - - const authorizer = bouncer.forUser(new User(1, true)) - try { - await authorizer.allows('viewPost', new Post(2)) - } catch (error) { - assert.equal( - error.message, - 'Cannot run "viewPost" action. Make sure it is defined inside the "start/bouncer" file' - ) - } - }) - - test('authorize action from an after hook', async ({ assert }) => { - let actionInvocationCounts = 0 - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.after((user: User, _, result) => { - if (user.isSuperAdmin) { - assert.deepEqual(result.errorResponse, ['Not authorized to perform this action', 403]) - return true - } - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - actionInvocationCounts++ - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isTrue(await authorizer.allows('viewPost', new Post(2))) - assert.isTrue(await authorizer.forUser(new User(2)).allows('viewPost', new Post(2))) - assert.equal(actionInvocationCounts, 2) - }) - - test('deny action from an after hook', async ({ assert }) => { - let actionInvocationCounts = 0 - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.after(() => { - return false - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - actionInvocationCounts++ - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isFalse(await authorizer.allows('viewPost', new Post(2))) - assert.isFalse(await authorizer.forUser(new User(2)).allows('viewPost', new Post(2))) - assert.equal(actionInvocationCounts, 2) - }) - - test('forwaded action response as it is', async ({ assert }) => { - let actionInvocationCounts = 0 - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.after(() => { - return - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - actionInvocationCounts++ - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isFalse(await authorizer.allows('viewPost', new Post(2))) - assert.isTrue(await authorizer.forUser(new User(2)).allows('viewPost', new Post(2))) - assert.equal(actionInvocationCounts, 2) - }) - - test('run the action callback when hooks skips the authorization', async ({ assert }) => { - let hooksInvocationCounts = 0 - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.before(() => { - hooksInvocationCounts++ - }) - bouncer.before(() => { - hooksInvocationCounts++ - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isFalse(await authorizer.allows('viewPost', new Post(2))) - assert.isTrue(await authorizer.forUser(new User(2)).allows('viewPost', new Post(2))) - assert.equal(hooksInvocationCounts, 4) - }) - - test('do not run the next hook when first one authorizes the action', async ({ assert }) => { - let hooksInvocationCounts = 0 - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.before((user: User) => { - hooksInvocationCounts++ - if (user.isSuperAdmin) { - return true - } - }) - - bouncer.before(() => { - hooksInvocationCounts++ - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isTrue(await authorizer.allows('viewPost', new Post(2))) - assert.isTrue(await authorizer.forUser(new User(2)).allows('viewPost', new Post(2))) - assert.equal(hooksInvocationCounts, 3) - }) - - test('do not attempt authorization when user is missing', async ({ assert }) => { - let actionInvocationCounts = 0 - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.define('viewPost', (user: User, post: Post) => { - actionInvocationCounts++ - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(null) - - assert.isFalse(await authorizer.allows('viewPost', new Post(1))) - assert.equal(actionInvocationCounts, 0) - }) - - test('do invoke before callback when user is missing', async ({ assert }) => { - let actionInvocationCounts = 0 - assert.plan(3) - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.before((user) => { - assert.isNull(user) - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - actionInvocationCounts++ - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(null) - - assert.isFalse(await authorizer.allows('viewPost', new Post(1))) - assert.equal(actionInvocationCounts, 0) - }) - - test('do attempt authorization when user is missing and guest is allowed', async ({ assert }) => { - let actionInvocationCounts = 0 - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.define( - 'viewPost', - (user: User | null, post: Post) => { - actionInvocationCounts++ - - if (!user) { - return true - } - - return user.id === post.userId - }, - { allowGuest: true } - ) - - const authorizer = bouncer.forUser(null) - - assert.isTrue(await authorizer.allows('viewPost', new Post(1))) - assert.equal(actionInvocationCounts, 1) - }) - - test('authorize action using the can/cannot method', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isTrue(await authorizer.can('viewPost', new Post(1))) - assert.isFalse(await authorizer.cannot('viewPost', new Post(1))) - }) - - test('authorize action using the can/cannot for a custom user', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(2)) - assert.isTrue(await authorizer.can('viewPost', authorizer.forUser(new User(1)), new Post(1))) - assert.isFalse( - await authorizer.cannot('viewPost', authorizer.forUser(new User(1)), new Post(1)) - ) - }) - - test('resolve user using the resolver function', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(() => new User(1)) - assert.isTrue(await authorizer.allows('viewPost', new Post(1))) - }) -}) - -test.group('Actions Authorizer | Profile', (group) => { - group.each.setup(async () => { - app = await setup(false) - }) - - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('profile authorization calls', async ({ assert }) => { - const profilePackets: any[] = [] - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - assert.isTrue(await authorizer.allows('viewPost', new Post(1))) - - assert.lengthOf(profilePackets, 2) - assert.equal(profilePackets[0].label, 'bouncer:action') - assert.deepEqual(profilePackets[0].data, { action: 'viewPost', handler: 'anonymous' }) - assert.deepEqual(profilePackets[0].parent_id, profilePackets[1].id) - - assert.equal(profilePackets[1].label, 'bouncer:authorize') - assert.deepEqual(profilePackets[1].data, { - action: 'viewPost', - authorized: true, - errorResponse: null, - }) - }) - - test('profile when action raises an exception', async ({ assert }) => { - assert.plan(9) - const profilePackets: any[] = [] - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', () => { - throw new Error('bad request') - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - try { - await authorizer.allows('viewPost', new Post(1)) - } catch (error) { - assert.lengthOf(profilePackets, 2) - assert.equal(profilePackets[0].label, 'bouncer:action') - assert.equal(profilePackets[0].data.action, 'viewPost') - assert.equal(profilePackets[0].data.error.message, 'bad request') - assert.deepEqual(profilePackets[0].parent_id, profilePackets[1].id) - - assert.equal(profilePackets[1].label, 'bouncer:authorize') - assert.equal(profilePackets[1].data.action, 'viewPost') - assert.isFalse(profilePackets[1].data.authorized) - assert.equal(profilePackets[1].data.error.message, 'bad request') - } - }) - - test('profile hooks', async ({ assert }) => { - const profilePackets: any[] = [] - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.before(() => { - return - }) - - bouncer.after(() => { - return - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - assert.isTrue(await authorizer.allows('viewPost', new Post(1))) - - assert.lengthOf(profilePackets, 4) - assert.equal(profilePackets[0].label, 'bouncer:hook') - assert.deepEqual(profilePackets[0].data, { - action: 'viewPost', - handler: 'anonymous', - lifecycle: 'before', - }) - assert.equal(profilePackets[0].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[1].label, 'bouncer:action') - assert.deepEqual(profilePackets[1].data, { - action: 'viewPost', - handler: 'anonymous', - }) - assert.equal(profilePackets[1].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[2].label, 'bouncer:hook') - assert.deepEqual(profilePackets[2].data, { - action: 'viewPost', - handler: 'anonymous', - lifecycle: 'after', - }) - assert.equal(profilePackets[2].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[3].label, 'bouncer:authorize') - assert.deepEqual(profilePackets[3].data, { - action: 'viewPost', - authorized: true, - errorResponse: null, - }) - }) - - test('profile when before hook raises an exception', async ({ assert }) => { - assert.plan(9) - - const profilePackets: any[] = [] - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.before(() => { - throw new Error('bad request') - }) - - bouncer.after(() => { - return - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - try { - await authorizer.allows('viewPost', new Post(1)) - } catch (error) { - assert.lengthOf(profilePackets, 2) - assert.equal(profilePackets[0].label, 'bouncer:hook') - assert.equal(profilePackets[0].data.action, 'viewPost') - assert.equal(profilePackets[0].data.error.message, 'bad request') - assert.equal(profilePackets[0].parent_id, profilePackets[1].id) - - assert.equal(profilePackets[1].label, 'bouncer:authorize') - assert.equal(profilePackets[1].data.action, 'viewPost') - assert.isFalse(profilePackets[1].data.authorized) - assert.equal(profilePackets[1].data.error.message, 'bad request') - } - }) - - test('profile when after hook raises an exception', async ({ assert }) => { - assert.plan(15) - - const profilePackets: any[] = [] - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.before(() => {}) - - bouncer.after(() => { - throw new Error('bad request') - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - try { - await authorizer.allows('viewPost', new Post(1)) - } catch (error) { - assert.lengthOf(profilePackets, 4) - assert.equal(profilePackets[0].label, 'bouncer:hook') - assert.deepEqual(profilePackets[0].data, { - action: 'viewPost', - handler: 'anonymous', - lifecycle: 'before', - }) - assert.equal(profilePackets[0].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[1].label, 'bouncer:action') - assert.deepEqual(profilePackets[1].data, { - action: 'viewPost', - handler: 'anonymous', - }) - assert.equal(profilePackets[1].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[2].label, 'bouncer:hook') - assert.equal(profilePackets[2].data.action, 'viewPost') - assert.equal(profilePackets[2].data.error.message, 'bad request') - assert.equal(profilePackets[2].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[3].label, 'bouncer:authorize') - assert.equal(profilePackets[3].data.action, 'viewPost') - assert.isFalse(profilePackets[3].data.authorized) - assert.equal(profilePackets[3].data.error.message, 'bad request') - } - }) -}) diff --git a/test/make-policy.spec.ts b/test/make-policy.spec.ts deleted file mode 100644 index d609f70..0000000 --- a/test/make-policy.spec.ts +++ /dev/null @@ -1,215 +0,0 @@ -/* - * @adonisjs/assembler - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { join } from 'path' -import importFresh from 'import-fresh' -import { Kernel } from '@adonisjs/ace' -import { Filesystem } from '@poppinss/dev-utils' -import { Application } from '@adonisjs/application' - -import MakePolicy from '../commands/MakePolicy' - -const fs = new Filesystem(join(__dirname, '__app')) - -test.group('Make Policy', (group) => { - group.setup(() => { - process.env.ADONIS_ACE_CWD = fs.basePath - }) - - group.teardown(() => { - delete process.env.ADONIS_ACE_CWD - }) - - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('make a policy inside the default directory', async ({ assert }) => { - await fs.add('.adonisrc.json', JSON.stringify({})) - - const rcContents = importFresh(join(fs.basePath, '.adonisrc.json')) as any - const app = new Application(fs.basePath, 'test', rcContents) - - const policy = new MakePolicy(app, new Kernel(app)) - policy.name = 'post' - await policy.run() - - const PostPolicy = await fs.get('app/Policies/PostPolicy.ts') - - assert.deepEqual(PostPolicy.split('\n'), [ - `import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'`, - '', - 'export default class PostPolicy extends BasePolicy {', - '}', - '', - ]) - }) - - test('make a policy with actions', async ({ assert }) => { - await fs.add('.adonisrc.json', JSON.stringify({})) - - const rcContents = importFresh(join(fs.basePath, '.adonisrc.json')) as any - const app = new Application(fs.basePath, 'test', rcContents) - - const policy = new MakePolicy(app, new Kernel(app)) - policy.name = 'post' - policy.resourceModel = 'Post' - policy.userModel = 'User' - policy.actions = ['create', 'update'] - - await policy.run() - - const PostPolicy = await fs.get('app/Policies/PostPolicy.ts') - - assert.deepEqual(PostPolicy.split('\n'), [ - `import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'`, - `import User from 'App/Models/User'`, - `import Post from 'App/Models/Post'`, - '', - 'export default class PostPolicy extends BasePolicy {', - ' public async create(user: User) {}', - ' public async update(user: User, post: Post) {}', - '}', - '', - ]) - }) - - test('do not add actions when none option is selected', async ({ assert }) => { - await fs.add('.adonisrc.json', JSON.stringify({})) - - const rcContents = importFresh(join(fs.basePath, '.adonisrc.json')) as any - const app = new Application(fs.basePath, 'test', rcContents) - - const policy = new MakePolicy(app, new Kernel(app)) - policy.name = 'post' - policy.resourceModel = 'Post' - policy.userModel = 'User' - policy.actions = ['create', 'update', 'None'] - - await policy.run() - - const PostPolicy = await fs.get('app/Policies/PostPolicy.ts') - - assert.deepEqual(PostPolicy.split('\n'), [ - `import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'`, - '', - 'export default class PostPolicy extends BasePolicy {', - '}', - '', - ]) - }) - - test('ignore non-whitelisted actions', async ({ assert }) => { - await fs.add('.adonisrc.json', JSON.stringify({})) - - const rcContents = importFresh(join(fs.basePath, '.adonisrc.json')) as any - const app = new Application(fs.basePath, 'test', rcContents) - - const policy = new MakePolicy(app, new Kernel(app)) - policy.name = 'post' - policy.resourceModel = 'Post' - policy.userModel = 'User' - policy.actions = ['create', 'update', 'foo', 'bar'] - - await policy.run() - - const PostPolicy = await fs.get('app/Policies/PostPolicy.ts') - - assert.deepEqual(PostPolicy.split('\n'), [ - `import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'`, - `import User from 'App/Models/User'`, - `import Post from 'App/Models/Post'`, - '', - 'export default class PostPolicy extends BasePolicy {', - ' public async create(user: User) {}', - ' public async update(user: User, post: Post) {}', - '}', - '', - ]) - }) - - test('make policy inside a custom directory', async ({ assert }) => { - await fs.add( - '.adonisrc.json', - JSON.stringify({ - namespaces: { - policies: 'App/Domains/Cart/Policies', - }, - aliases: { - App: './app', - }, - }) - ) - - const rcContents = importFresh(join(fs.basePath, '.adonisrc.json')) as any - const app = new Application(fs.basePath, 'test', rcContents) - - const policy = new MakePolicy(app, new Kernel(app)) - policy.name = 'post' - policy.resourceModel = 'Post' - policy.userModel = 'User' - policy.actions = ['create', 'update', 'foo', 'bar'] - - await policy.run() - - const PostPolicy = await fs.get('app/Domains/Cart/Policies/PostPolicy.ts') - - assert.deepEqual(PostPolicy.split('\n'), [ - `import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'`, - `import User from 'App/Models/User'`, - `import Post from 'App/Models/Post'`, - '', - 'export default class PostPolicy extends BasePolicy {', - ' public async create(user: User) {}', - ' public async update(user: User, post: Post) {}', - '}', - '', - ]) - }) - - test('make correct path to custom models namespace', async ({ assert }) => { - await fs.add( - '.adonisrc.json', - JSON.stringify({ - namespaces: { - models: 'App/Domains/Models', - }, - aliases: { - App: './app', - }, - }) - ) - - const rcContents = importFresh(join(fs.basePath, '.adonisrc.json')) as any - const app = new Application(fs.basePath, 'test', rcContents) - - const policy = new MakePolicy(app, new Kernel(app)) - policy.name = 'post' - policy.resourceModel = 'Post' - policy.userModel = 'User' - policy.actions = ['create', 'update', 'foo', 'bar'] - - await policy.run() - - const PostPolicy = await fs.get('app/Policies/PostPolicy.ts') - - assert.deepEqual(PostPolicy.split('\n'), [ - `import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'`, - `import User from 'App/Domains/Models/User'`, - `import Post from 'App/Domains/Models/Post'`, - '', - 'export default class PostPolicy extends BasePolicy {', - ' public async create(user: User) {}', - ' public async update(user: User, post: Post) {}', - '}', - '', - ]) - }) -}) diff --git a/test/policy-authorizer.spec.ts b/test/policy-authorizer.spec.ts deleted file mode 100644 index 40eb59a..0000000 --- a/test/policy-authorizer.spec.ts +++ /dev/null @@ -1,1149 +0,0 @@ -/* @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' - -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { Bouncer } from '../src/Bouncer' -import { setup, fs } from '../test-helpers' -import { AuthorizationResult } from '@ioc:Adonis/Addons/Bouncer' - -let app: ApplicationContract - -test.group('Policy Authorizer', (group) => { - group.each.setup(async () => { - app = await setup(false) - }) - - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('return true if a user is allowed to perform an action', async ({ assert }) => { - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isTrue(await authorizer.with('UserPolicy').allows('viewPost', new Post(1))) - }) - - test('return false if a user is not allowed to perform an action', async ({ assert }) => { - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isFalse(await authorizer.with('UserPolicy').allows('viewPost', new Post(2))) - }) - - test('return true if a user is denied to perform an action', async ({ assert }) => { - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isTrue(await authorizer.with('UserPolicy').denies('viewPost', new Post(2))) - }) - - test('return false if a user is not denied to perform an action', async ({ assert }) => { - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isFalse(await authorizer.with('UserPolicy').denies('viewPost', new Post(1))) - }) - - test('raise exception when a user is not allowed to perform an action', async ({ assert }) => { - assert.plan(2) - - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - - try { - await authorizer.with('UserPolicy').authorize('viewPost', new Post(2)) - } catch (error) { - assert.equal(error.message, 'E_AUTHORIZATION_FAILURE: Not authorized to perform this action') - assert.equal(error.status, 403) - } - }) - - test('allow custom denial message', async ({ assert }) => { - assert.plan(2) - - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - if (user.id === post.userId) { - return true - } - return ['Cannot access post'] - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - - try { - await authorizer.with('UserPolicy').authorize('viewPost', new Post(2)) - } catch (error) { - assert.equal(error.message, 'E_AUTHORIZATION_FAILURE: Cannot access post') - assert.equal(error.status, 403) - } - }) - - test('allow custom denial message with custom status code', async ({ assert }) => { - assert.plan(2) - - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - if (user.id === post.userId) { - return true - } - return ['Post not found', 404] - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - - try { - await authorizer.with('UserPolicy').authorize('viewPost', new Post(2)) - } catch (error) { - assert.equal(error.message, 'E_AUTHORIZATION_FAILURE: Post not found') - assert.equal(error.status, 404) - } - }) - - test('allow switching user at runtime', async ({ assert }) => { - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - - assert.isFalse(await authorizer.with('UserPolicy').allows('viewPost', new Post(2))) - assert.isTrue( - await authorizer.forUser(new User(2)).with('UserPolicy').allows('viewPost', new Post(2)) - ) - assert.isTrue( - await authorizer.with('UserPolicy').forUser(new User(2)).allows('viewPost', new Post(2)) - ) - }) - - test('authorize action from a before hook', async ({ assert }) => { - let actionInvocationCounts = 0 - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public before(user: User) { - if (user.isSuperAdmin) { - return true - } - } - - public viewPost(user: User, post: Post) { - actionInvocationCounts++ - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isTrue(await authorizer.with('UserPolicy').allows('viewPost', new Post(2))) - assert.isTrue( - await authorizer.with('UserPolicy').forUser(new User(2)).allows('viewPost', new Post(2)) - ) - assert.equal(actionInvocationCounts, 1) - }) - - test('allow before hook to authorize non-existing actions', async ({ assert }) => { - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public before() { - return true - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isTrue(await authorizer.with('UserPolicy').allows('viewPost', new Post(2))) - assert.isTrue( - await authorizer.with('UserPolicy').forUser(new User(2)).allows('viewPost', new Post(2)) - ) - }) - - test('allow before hook to deny non-existing actions', async ({ assert }) => { - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public before() { - return false - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isFalse(await authorizer.with('UserPolicy').allows('viewPost', new Post(2))) - assert.isFalse( - await authorizer.with('UserPolicy').forUser(new User(2)).allows('viewPost', new Post(2)) - ) - }) - - test('raise exception when action is not defined', async ({ assert }) => { - assert.plan(1) - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public before() {} - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1, true)) - try { - await authorizer.with('UserPolicy').allows('viewPost', new Post(2)) - } catch (error) { - assert.equal( - error.message, - 'Cannot run "viewPost" action. Make sure it is defined on the "UserPolicy" class' - ) - } - }) - - test('authorize action from an after hook', async ({ assert }) => { - let actionInvocationCounts = 0 - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public after(user: User, _: string, result: AuthorizationResult) { - if (user.isSuperAdmin) { - assert.deepEqual(result.errorResponse, ['Not authorized to perform this action', 403]) - return true - } - } - - public viewPost(user: User, post: Post) { - actionInvocationCounts++ - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isTrue(await authorizer.with('UserPolicy').allows('viewPost', new Post(2))) - assert.isTrue( - await authorizer.forUser(new User(2)).with('UserPolicy').allows('viewPost', new Post(2)) - ) - assert.equal(actionInvocationCounts, 2) - }) - - test('deny action from an after hook', async ({ assert }) => { - let actionInvocationCounts = 0 - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public after() { - return false - } - - public viewPost(user: User, post: Post) { - actionInvocationCounts++ - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isFalse(await authorizer.with('UserPolicy').allows('viewPost', new Post(2))) - assert.isFalse( - await authorizer.forUser(new User(2)).with('UserPolicy').allows('viewPost', new Post(2)) - ) - assert.equal(actionInvocationCounts, 2) - }) - - test('forwaded action response from after hook as it is', async ({ assert }) => { - let actionInvocationCounts = 0 - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public after() {} - - public viewPost(user: User, post: Post) { - actionInvocationCounts++ - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1, true)) - assert.isFalse(await authorizer.with('UserPolicy').allows('viewPost', new Post(2))) - assert.isTrue( - await authorizer.forUser(new User(2)).with('UserPolicy').allows('viewPost', new Post(2)) - ) - assert.equal(actionInvocationCounts, 2) - }) - - test('do not attempt authorization when user is missing', async ({ assert }) => { - let actionInvocationCounts = 0 - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public after() {} - - public viewPost(user: User, post: Post) { - actionInvocationCounts++ - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(null) - - assert.isFalse(await authorizer.with('UserPolicy').allows('viewPost', new Post(1))) - assert.equal(actionInvocationCounts, 0) - }) - - test('do invoke "before" hook when user is missing', async ({ assert }) => { - let actionInvocationCounts = 0 - assert.plan(3) - - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public before(user: User) { - assert.isNull(user) - } - - public viewPost(user: User, post: Post) { - actionInvocationCounts++ - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(null) - - assert.isFalse(await authorizer.with('UserPolicy').allows('viewPost', new Post(1))) - assert.equal(actionInvocationCounts, 0) - }) - - test('do attempt authorization when user is missing and guest is allowed', async ({ assert }) => { - let actionInvocationCounts = 0 - - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public before(user: User) { - assert.isNull(user) - } - - public viewPost(user: User, post: Post) { - actionInvocationCounts++ - - if (!user) { - return true - } - - return user.id === post.userId - } - } - - UserPolicy.boot() - UserPolicy.storeActionOptions('viewPost', { allowGuest: true }) - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(null) - - assert.isTrue(await authorizer.with('UserPolicy').allows('viewPost', new Post(1))) - assert.equal(actionInvocationCounts, 1) - }) - - test('allow guest via decorator', async ({ assert }) => { - let actionInvocationCounts = 0 - - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number, public isSuperAdmin: boolean = false) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public before(user: User) { - assert.isNull(user) - } - - @bouncer.action({ allowGuest: true }) - public viewPost(user: User, post: Post) { - actionInvocationCounts++ - - if (!user) { - return true - } - - return user.id === post.userId - } - } - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(null) - - assert.isTrue(await authorizer.with('UserPolicy').allows('viewPost', new Post(1))) - assert.equal(actionInvocationCounts, 1) - }) - - test('authorize using the can/cannot method', async ({ assert }) => { - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - assert.isTrue(await authorizer.can('UserPolicy.viewPost', new Post(1))) - assert.isFalse(await authorizer.cannot('UserPolicy.viewPost', new Post(1))) - }) - - test('authorize using the can/cannot method for a custom user', async ({ assert }) => { - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(2)) - assert.isTrue( - await authorizer.can('UserPolicy.viewPost', bouncer.forUser(new User(1)), new Post(1)) - ) - assert.isFalse( - await authorizer.cannot('UserPolicy.viewPost', bouncer.forUser(new User(1)), new Post(1)) - ) - }) - - test('raise exception when using invalid policy name via can/cannot method', async ({ - assert, - }) => { - assert.plan(1) - - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - try { - await authorizer.can('UPolicy.viewPost', new Post(1)) - } catch ({ message }) { - assert.equal( - message, - 'Cannot use "UPolicy" policy. Make sure it is defined as a function inside "start/bouncer" file' - ) - } - }) - - test('pass resolver to the policy when using with method', async ({ assert }) => { - const bouncer = new Bouncer(app) - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - let i = 0 - const authorizer = bouncer.forUser(() => { - return new User(++i) - }) - - assert.isTrue(await authorizer.allows('viewPost', new Post(1))) - assert.isTrue(await authorizer.with('UserPolicy').allows('viewPost', new Post(2))) - }) -}) - -test.group('Actions Authorizer | Profile', (group) => { - group.each.setup(async () => { - app = await setup(false) - }) - - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('profile authorization calls', async ({ assert }) => { - const profilePackets: any[] = [] - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - assert.isTrue(await authorizer.with('UserPolicy').allows('viewPost', new Post(1))) - - assert.lengthOf(profilePackets, 2) - assert.equal(profilePackets[0].label, 'bouncer:action') - assert.deepEqual(profilePackets[0].data, { action: 'viewPost', handler: 'viewPost' }) - assert.deepEqual(profilePackets[0].parent_id, profilePackets[1].id) - - assert.equal(profilePackets[1].label, 'bouncer:authorize') - assert.deepEqual(profilePackets[1].data, { - action: 'viewPost', - authorized: true, - policy: 'UserPolicy', - errorResponse: null, - }) - }) - - test('profile when action raises an exception', async ({ assert }) => { - assert.plan(9) - const profilePackets: any[] = [] - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public viewPost() { - throw new Error('bad request') - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - try { - await authorizer.with('UserPolicy').allows('viewPost', new Post(1)) - } catch (error) { - assert.lengthOf(profilePackets, 2) - assert.equal(profilePackets[0].label, 'bouncer:action') - assert.equal(profilePackets[0].data.action, 'viewPost') - assert.equal(profilePackets[0].data.error.message, 'bad request') - assert.deepEqual(profilePackets[0].parent_id, profilePackets[1].id) - - assert.equal(profilePackets[1].label, 'bouncer:authorize') - assert.equal(profilePackets[1].data.action, 'viewPost') - assert.isFalse(profilePackets[1].data.authorized) - assert.equal(profilePackets[1].data.error.message, 'bad request') - } - }) - - test('profile hooks', async ({ assert }) => { - const profilePackets: any[] = [] - const bouncer = new Bouncer(app) - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public before() {} - public after() {} - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - assert.isTrue(await authorizer.with('UserPolicy').allows('viewPost', new Post(1))) - - assert.lengthOf(profilePackets, 4) - assert.equal(profilePackets[0].label, 'bouncer:hook') - assert.deepEqual(profilePackets[0].data, { - action: 'viewPost', - handler: 'before', - lifecycle: 'before', - }) - assert.equal(profilePackets[0].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[1].label, 'bouncer:action') - assert.deepEqual(profilePackets[1].data, { - action: 'viewPost', - handler: 'viewPost', - }) - assert.equal(profilePackets[1].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[2].label, 'bouncer:hook') - assert.deepEqual(profilePackets[2].data, { - action: 'viewPost', - handler: 'after', - lifecycle: 'after', - }) - assert.equal(profilePackets[2].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[3].label, 'bouncer:authorize') - assert.deepEqual(profilePackets[3].data, { - action: 'viewPost', - policy: 'UserPolicy', - authorized: true, - errorResponse: null, - }) - }) - - test('profile when before hook raises an exception', async ({ assert }) => { - assert.plan(9) - const bouncer = new Bouncer(app) - - const profilePackets: any[] = [] - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - class UserPolicy extends bouncer.BasePolicy { - public before() { - throw new Error('bad request') - } - - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - try { - await authorizer.with('UserPolicy').allows('viewPost', new Post(1)) - } catch (error) { - assert.lengthOf(profilePackets, 2) - assert.equal(profilePackets[0].label, 'bouncer:hook') - assert.equal(profilePackets[0].data.action, 'viewPost') - assert.equal(profilePackets[0].data.error.message, 'bad request') - assert.equal(profilePackets[0].parent_id, profilePackets[1].id) - - assert.equal(profilePackets[1].label, 'bouncer:authorize') - assert.equal(profilePackets[1].data.action, 'viewPost') - assert.isFalse(profilePackets[1].data.authorized) - assert.equal(profilePackets[1].data.error.message, 'bad request') - } - }) - - test('profile when after hook raises an exception', async ({ assert }) => { - assert.plan(15) - - const profilePackets: any[] = [] - - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - class UserPolicy extends bouncer.BasePolicy { - public before() {} - - public after() { - throw new Error('bad request') - } - - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - } - - UserPolicy.boot() - - bouncer.registerPolicies({ - UserPolicy: async () => { - return { default: UserPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - authorizer.setProfiler(app.profiler) - - app.profiler.processor = function (packet) { - profilePackets.push(packet) - } - - try { - await authorizer.with('UserPolicy').allows('viewPost', new Post(1)) - } catch (error) { - assert.lengthOf(profilePackets, 4) - assert.equal(profilePackets[0].label, 'bouncer:hook') - assert.deepEqual(profilePackets[0].data, { - action: 'viewPost', - handler: 'before', - lifecycle: 'before', - }) - assert.equal(profilePackets[0].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[1].label, 'bouncer:action') - assert.deepEqual(profilePackets[1].data, { - action: 'viewPost', - handler: 'viewPost', - }) - assert.equal(profilePackets[1].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[2].label, 'bouncer:hook') - assert.equal(profilePackets[2].data.action, 'viewPost') - assert.equal(profilePackets[2].data.error.message, 'bad request') - assert.equal(profilePackets[2].parent_id, profilePackets[3].id) - - assert.equal(profilePackets[3].label, 'bouncer:authorize') - assert.equal(profilePackets[3].data.action, 'viewPost') - assert.isFalse(profilePackets[3].data.authorized) - assert.equal(profilePackets[3].data.error.message, 'bad request') - } - }) -}) diff --git a/test/provider.spec.ts b/test/provider.spec.ts deleted file mode 100644 index dbe2a0e..0000000 --- a/test/provider.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { Bouncer } from '../src/Bouncer' -import { setup, fs } from '../test-helpers' -import { ActionsAuthorizer } from '../src/ActionsAuthorizer' - -test.group('Setup provider', (group) => { - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('register provider', async ({ assert }) => { - const app = await setup(true) - assert.isTrue(app.container.hasBinding('Adonis/Addons/Bouncer')) - assert.instanceOf(app.container.use('Adonis/Addons/Bouncer'), Bouncer) - }) - - test('get authorizer instance for a given request', async ({ assert }) => { - const app = await setup(true) - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - - assert.instanceOf(ctx.bouncer, ActionsAuthorizer) - assert.strictEqual(ctx.bouncer, ctx.bouncer) - }) - - test('share authorizer with view', async ({ assert }) => { - const app = await setup(true) - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - assert.instanceOf(ctx.bouncer, ActionsAuthorizer) - assert.strictEqual(ctx.bouncer, ctx.bouncer) - }) -}) diff --git a/test/view.spec.ts b/test/view.spec.ts deleted file mode 100644 index d49cde6..0000000 --- a/test/view.spec.ts +++ /dev/null @@ -1,213 +0,0 @@ -/* - * @adonisjs/bouncer - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import dedent from 'ts-dedent' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { Bouncer } from '../src/Bouncer' -import { setup, fs } from '../test-helpers' -import { CanTag } from '../src/Bindings/View' - -let app: ApplicationContract - -test.group('Can Tag', (group) => { - group.each.setup(async () => { - app = await setup(true) - app.container.resolveBinding('Adonis/Core/View').registerTag(CanTag) - }) - - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('use "can tag" with action name', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - - const authorizer = bouncer.forUser(new User(1)) - app.container.resolveBinding('Adonis/Core/View').registerTemplate('eval', { - template: dedent` - @can('viewPost', post) - The post user id is {{ post.userId }} - @end - `, - }) - - const view = app.container.resolveBinding('Adonis/Core/View') - const output = await view.render('eval', { bouncer: authorizer, post: new Post(1) }) - assert.equal(output.trim(), 'The post user id is 1') - }) - - test('use "can tag" without action arguments', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - const bouncer = new Bouncer(app) - bouncer.define('createPost', (_) => { - return true - }) - - const authorizer = bouncer.forUser(new User(1)) - app.container.resolveBinding('Adonis/Core/View').registerTemplate('eval', { - template: dedent` - @can('createPost') - Create post - @end - `, - }) - - const view = app.container.resolveBinding('Adonis/Core/View') - const output = await view.render('eval', { bouncer: authorizer }) - assert.equal(output.trim(), ' Create post ') - }) - - test('use "can tag" with a literal value', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - bouncer.define('createPost', (_) => { - return true - }) - - const authorizer = bouncer.forUser(new User(1)) - app.container.resolveBinding('Adonis/Core/View').registerTemplate('eval', { - template: dedent` - @set('action', 'createPost') - @set('action2', 'viewPost') - @can(action) - Create post - @end - - @can(action2, post) - View post - @end - `, - }) - - const view = app.container.resolveBinding('Adonis/Core/View') - - const output = await view.render('eval', { bouncer: authorizer, post: new Post(1) }) - assert.deepEqual( - output.split('\n').map((line) => line.trim()), - [' Create post ', '', ' View post '] - ) - }) - - test('use "can tag" as a member expression', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - - bouncer.define('viewPost', (user: User, post: Post) => { - return user.id === post.userId - }) - bouncer.define('createPost', (_) => { - return true - }) - - const authorizer = bouncer.forUser(new User(1)) - app.container.resolveBinding('Adonis/Core/View').registerTemplate('eval', { - template: dedent` - @set('actions', { view: 'viewPost', create: 'createPost' }) - @can(actions.create) - Create post - @end - - @can(actions.view, post) - View post - @end - `, - }) - - const view = app.container.resolveBinding('Adonis/Core/View') - - const output = await view.render('eval', { bouncer: authorizer, post: new Post(1) }) - assert.deepEqual( - output.split('\n').map((line) => line.trim()), - [' Create post ', '', ' View post '] - ) - }) - - test('use "can tag" as template literal', async ({ assert }) => { - class User { - constructor(public id: number) {} - } - - class Post { - constructor(public userId: number) {} - } - - const bouncer = new Bouncer(app) - class PostPolicy extends bouncer.BasePolicy { - public viewPost(user: User, post: Post) { - return user.id === post.userId - } - - public createPost() { - return true - } - } - - PostPolicy.boot() - bouncer.registerPolicies({ - PostPolicy: async () => { - return { default: PostPolicy } - }, - }) - - const authorizer = bouncer.forUser(new User(1)) - app.container.resolveBinding('Adonis/Core/View').registerTemplate('eval', { - template: [ - `@set('actions', { view: 'viewPost', create: 'createPost' })`, - '@can(`PostPolicy.${actions.view}`, post)', - ' Create post ', - '@end', - '', - '@can(`PostPolicy.${actions.create}`)', - ' View post ', - '@end', - ].join('\n'), - }) - - const view = app.container.resolveBinding('Adonis/Core/View') - const output = await view.render('eval', { bouncer: authorizer, post: new Post(1) }) - assert.deepEqual( - output.split('\n').map((line) => line.trim()), - [' Create post ', '', ' View post '] - ) - }) -}) diff --git a/tests/bouncer.spec.ts b/tests/bouncer.spec.ts new file mode 100644 index 0000000..dc4031b --- /dev/null +++ b/tests/bouncer.spec.ts @@ -0,0 +1,763 @@ +/* + * @adonisjs/boucner + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Container, inject } from '@adonisjs/core/container' + +import { Bouncer } from '../src/bouncer.js' +import { BasePolicy } from '../src/base_policy.js' +import { AuthorizationResponse } from '../src/response.js' + +test.group('Bouncer | actions | types', () => { + test('assert allowed actions by reference', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + const editPost = Bouncer.action((_: User) => false) + const editStaff = Bouncer.action((_: Admin) => false) + const bouncer = new Bouncer(new User()) + await bouncer.execute(editPost) + + /** + * Since, the editStaff action needs an instance of Admin + * class, it cannot used with a bouncer instance created + * for the User class + */ + // @ts-expect-error + await bouncer.execute(editStaff) + + /** + * Since, we have not passed any predefined actions to buncer, we + * cannot call them as string + */ + // @ts-expect-error + await bouncer.execute('editStaff') + }).throws(`Invalid bouncer action 'editStaff'`) + + test('assert allowed pre-defined actions', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + const editPost = Bouncer.action((_: User) => false) + const editStaff = Bouncer.action((_: Admin) => false) + const bouncer = new Bouncer(new User(), { editPost, editStaff }) + + await bouncer.execute('editPost') + + /** + * Since, the editStaff action needs an instance of Admin + * class, it cannot used with a bouncer instance created + * for the User class + */ + // @ts-expect-error + await bouncer.execute('editStaff') + // @ts-expect-error + await bouncer.execute(editStaff) + }) + + test('assert allowed actions from policies', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + class PostPolicy extends BasePolicy { + viewAll(_: User) { + return true + } + } + class StaffPolicy { + viewAll(_: Admin) { + return true + } + } + + const editPost = Bouncer.action((_: User) => false) + const editStaff = Bouncer.action((_: Admin) => false) + + const bouncer = new Bouncer(new User()) + await bouncer.execute(editPost) + await bouncer.execute([PostPolicy, 'viewAll']) + + /** + * Since, the editStaff action needs an instance of Admin + * class, it cannot used with a bouncer instance created + * for the User class + */ + // @ts-expect-error + await bouncer.execute(editStaff) + + /** + * Since, we have not passed any predefined actions to buncer, we + * cannot call them as string + */ + // @ts-expect-error + await bouncer.execute('editStaff') + + /** + * Since, the "StaffPolicy" needs Admin class, it cannot be + * used with the bouncer instance created for the User + * class. + */ + // @ts-expect-error + await bouncer.execute([StaffPolicy, 'viewAll']) + }).throws(`Invalid bouncer action 'editStaff'`) + + test('infer arguments accepted by an action', async () => { + class User { + declare id: number + declare email: string + } + class Post { + declare userId: null + declare title: string + } + + const editPost = Bouncer.action((_: User, __: Post) => { + return false + }) + const bouncer = new Bouncer(new User(), { editPost }) + + bouncer.allows('editPost', new Post()) + bouncer.allows(editPost, new Post()) + + /** + * Since, the editPost action needs post instance, it gives + * type error if do not pass the parameter + */ + // @ts-expect-error + bouncer.allows('editPost') + + /** + * Since, the editPost action needs post instance, it gives + * type error if do not pass the parameter + */ + // @ts-expect-error + bouncer.allows(editPost) + }) + + test('infer arguments accepted by a policy', async () => { + class User { + declare id: number + declare email: string + } + class Post { + declare userId: null + declare title: string + } + + class PostPolicy extends BasePolicy { + viewAll(_: User, __: Post) { + return true + } + } + + const bouncer = new Bouncer(new User()) + await bouncer.execute([PostPolicy, 'viewAll'], new Post()) + + /** + * Since, the "viewAll" action needs a post instance, it returns + * an error when post instance is not provided + */ + // @ts-expect-error + await bouncer.execute([PostPolicy, 'viewAll']) + }) + + test('assert allowed actions by reference with union of users', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + const editPost = Bouncer.action((_: User | Admin) => false) + const editStaff = Bouncer.action((_: User | Admin) => false) + + const bouncer = new Bouncer(new User()) + const bouncer1 = new Bouncer(new User()) + + await bouncer.allows(editPost) + await bouncer.allows(editStaff) + + await bouncer1.allows(editPost) + await bouncer1.allows(editStaff) + + /** + * Since, we have not passed any predefined actions to buncer, we + * cannot call them as string + */ + // @ts-expect-error + await bouncer.allows('editStaff') + /** + * Since, we have not passed any predefined actions to buncer, we + * cannot call them as string + */ + // @ts-expect-error + await bouncer1.allows('editStaff') + }).throws(`Invalid bouncer action 'editStaff'`) + + test('assert allowed pre-defined actions with union of users', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + const editPost = Bouncer.action((_: User | Admin) => false) + const editStaff = Bouncer.action((_: User | Admin) => false) + const actions = { editPost, editStaff } + + const bouncer = new Bouncer(new User(), actions) + const bouncer1 = new Bouncer(new User(), actions) + + await bouncer.allows('editPost') + await bouncer.allows('editStaff') + await bouncer.allows(editStaff) + + await bouncer1.allows('editPost') + await bouncer1.allows('editStaff') + await bouncer1.allows(editStaff) + }) + + test('assert allowed actions from policies with union of users', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + class PostPolicy extends BasePolicy { + viewAll(_: User | Admin) { + return true + } + } + class StaffPolicy extends BasePolicy { + viewAll(_: Admin | User) { + return true + } + } + + const bouncer = new Bouncer(new User()) + await bouncer.execute([PostPolicy, 'viewAll']) + await bouncer.execute([StaffPolicy, 'viewAll']) + + const bouncer1 = new Bouncer(new User()) + await bouncer1.execute([PostPolicy, 'viewAll']) + await bouncer1.execute([StaffPolicy, 'viewAll']) + }) +}) + +test.group('Bouncer | actions', () => { + test('execute action by reference', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action((_: User) => false) + const bouncer = new Bouncer(new User()) + + const response = await bouncer.execute(editPost) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('execute action from pre-defined list', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action((_: User) => false) + const bouncer = new Bouncer(new User(), { editPost }) + + const response = await bouncer.execute('editPost') + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('execute policy action', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + viewAll(_: User) { + return false + } + } + + const bouncer = new Bouncer(new User()) + const response = await bouncer.execute([PostPolicy, 'viewAll']) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('pass arguments to the action', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + class Post { + constructor(public userId: number) {} + } + + const editPost = Bouncer.action((user: User, post: Post) => { + return post.userId === user.id + }) + + const bouncer = new Bouncer(new User(1), { editPost }) + + const response = await bouncer.execute('editPost', new Post(1)) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, true) + + const referenceResponse = await bouncer.execute(editPost, new Post(2)) + assert.instanceOf(referenceResponse, AuthorizationResponse) + assert.equal(referenceResponse.authorized, false) + }) + + test('pass arguments to policy action', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + class Post { + constructor(public userId: number) {} + } + + class PostPolicy extends BasePolicy { + viewAll(user: User, post: Post) { + return post.userId === user.id + } + } + + const bouncer = new Bouncer(new User(1)) + const response = await bouncer.execute([PostPolicy, 'viewAll'], new Post(1)) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, true) + + const failingResponse = await bouncer.execute([PostPolicy, 'viewAll'], new Post(2)) + assert.instanceOf(failingResponse, AuthorizationResponse) + assert.equal(failingResponse.authorized, false) + }) + + test('check if user is allowed or denied to perform an action', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action((_: User) => false) + const bouncer = new Bouncer(new User(), { editPost }) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + }) + + test('check if user is allowed or denied to perform a policy action', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class PostPolicy extends BasePolicy { + viewAll(_: User) { + return false + } + } + + const editPost = Bouncer.action((_: User) => false) + const bouncer = new Bouncer(new User(1), { editPost }) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + + assert.isFalse(await bouncer.allows([PostPolicy, 'viewAll'])) + assert.isTrue(await bouncer.denies([PostPolicy, 'viewAll'])) + }) + + test('deny access for guest users', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action((_: User) => { + throw new Error('Never executed to be invoked for guest users') + }) + const actions = { editPost } + + const bouncer = new Bouncer(null, actions) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + }) + + test('deny access for guest users on policy actions', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class PostPolicy extends BasePolicy { + viewAll(_: User): boolean { + throw new Error('Never expected to called for guest users') + } + } + + const bouncer = new Bouncer(null) + + assert.isFalse(await bouncer.allows([PostPolicy, 'viewAll'])) + assert.isTrue(await bouncer.denies([PostPolicy, 'viewAll'])) + }) + + test('execute action when guest users are allowed', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action( + (_: User | null) => { + return true + }, + { allowGuest: true } + ) + const actions = { editPost } + + const bouncer = new Bouncer(null, actions) + + assert.isTrue(await bouncer.allows(editPost)) + assert.isFalse(await bouncer.denies(editPost)) + + assert.isTrue(await bouncer.allows('editPost')) + assert.isFalse(await bouncer.denies('editPost')) + }) + + test('execute action for guest users on policy actions when guests are allowed', async ({ + assert, + }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class PostPolicy extends BasePolicy { + viewAll(_: User): boolean { + return true + } + } + PostPolicy.setActionMetaData('viewAll', { allowGuest: true }) + + const bouncer = new Bouncer(null) + + assert.isTrue(await bouncer.allows([PostPolicy, 'viewAll'])) + assert.isFalse(await bouncer.denies([PostPolicy, 'viewAll'])) + }) +}) + +test.group('Bouncer | actions | userResolver', () => { + test('execute action by reference', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action((_: User) => false) + const bouncer = new Bouncer(() => new User()) + + const response = await bouncer.execute(editPost) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('execute action from pre-defined list', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action((_: User) => false) + const bouncer = new Bouncer(() => new User(), { editPost }) + + const response = await bouncer.execute('editPost') + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('execute policy action', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + viewAll(_: User) { + return false + } + } + + const bouncer = new Bouncer(() => new User()) + const response = await bouncer.execute([PostPolicy, 'viewAll']) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('pass arguments to the action', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + class Post { + constructor(public userId: number) {} + } + + const editPost = Bouncer.action((user: User, post: Post) => { + return post.userId === user.id + }) + + const bouncer = new Bouncer(() => new User(1), { editPost }) + + const response = await bouncer.execute('editPost', new Post(1)) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, true) + + const referenceResponse = await bouncer.execute(editPost, new Post(2)) + assert.instanceOf(referenceResponse, AuthorizationResponse) + assert.equal(referenceResponse.authorized, false) + }) + + test('pass arguments to policy action', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + class Post { + constructor(public userId: number) {} + } + + class PostPolicy extends BasePolicy { + viewAll(user: User, post: Post) { + return post.userId === user.id + } + } + + const bouncer = new Bouncer(() => new User(1)) + const response = await bouncer.execute([PostPolicy, 'viewAll'], new Post(1)) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, true) + + const failingResponse = await bouncer.execute([PostPolicy, 'viewAll'], new Post(2)) + assert.instanceOf(failingResponse, AuthorizationResponse) + assert.equal(failingResponse.authorized, false) + }) + + test('check if user is allowed or denied to perform an action', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action((_: User) => false) + const bouncer = new Bouncer(() => new User(), { editPost }) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + }) + + test('check if user is allowed or denied to perform a policy action', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class PostPolicy extends BasePolicy { + viewAll(_: User) { + return false + } + } + + const editPost = Bouncer.action((_: User) => false) + const bouncer = new Bouncer(() => new User(1), { editPost }) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + + assert.isFalse(await bouncer.allows([PostPolicy, 'viewAll'])) + assert.isTrue(await bouncer.denies([PostPolicy, 'viewAll'])) + }) + + test('deny access for guest users', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action((_: User) => { + throw new Error('Never executed to be invoked for guest users') + }) + const actions = { editPost } + + const bouncer = new Bouncer(() => null, actions) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + }) + + test('deny access for guest users on policy actions', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class PostPolicy extends BasePolicy { + viewAll(_: User): boolean { + throw new Error('Never expected to called for guest users') + } + } + + const bouncer = new Bouncer(() => null) + + assert.isFalse(await bouncer.allows([PostPolicy, 'viewAll'])) + assert.isTrue(await bouncer.denies([PostPolicy, 'viewAll'])) + }) + + test('execute action when guest users are allowed', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.action( + (_: User | null) => { + return true + }, + { allowGuest: true } + ) + const actions = { editPost } + + const bouncer = new Bouncer(() => null, actions) + + assert.isTrue(await bouncer.allows(editPost)) + assert.isFalse(await bouncer.denies(editPost)) + + assert.isTrue(await bouncer.allows('editPost')) + assert.isFalse(await bouncer.denies('editPost')) + }) + + test('execute action for guest users on policy actions when guests are allowed', async ({ + assert, + }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class PostPolicy extends BasePolicy { + viewAll(_: User): boolean { + return true + } + } + PostPolicy.setActionMetaData('viewAll', { allowGuest: true }) + + const bouncer = new Bouncer(() => null) + + assert.isTrue(await bouncer.allows([PostPolicy, 'viewAll'])) + assert.isFalse(await bouncer.denies([PostPolicy, 'viewAll'])) + }) +}) + +test.group('Bouncer | policies | extendedChecks', () => { + test('throw error when policy is not a class', async () => { + class User { + declare id: number + declare email: string + } + + const bouncer = new Bouncer(new User()) + // @ts-expect-error + await bouncer.execute([{}, 'viewAll']) + }).throws('Invalid policy reference. It must be a class constructor') + + test('throw error when policy action is not defined on the class', async () => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy {} + + const bouncer = new Bouncer(new User()) + // @ts-expect-error + await bouncer.execute([PostPolicy, 'viewAll']) + }).throws('Cannot find method "viewAll" on "[class PostPolicy]"') + + test('constructor policy class using container', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class Logger {} + + @inject() + class PostPolicy extends BasePolicy { + constructor(public logger: Logger) { + super() + } + + viewAll(_: User) { + return this.logger instanceof Logger + } + } + + const bouncer = new Bouncer(new User()) + bouncer.setContainerResolver(new Container().createResolver()) + assert.isTrue(await bouncer.allows([PostPolicy, 'viewAll'])) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index f804420..fe93d8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig", + "extends": "@adonisjs/tsconfig/tsconfig.package.json", "compilerOptions": { - "skipLibCheck": true, + "rootDir": "./", + "outDir": "./build", "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "types": ["@adonisjs/auth", "@types/node", "@adonisjs/core", "@adonisjs/view"] + "emitDecoratorMetadata": true } } From b3ffcb100133f7f8812cec2fc5faeb7e96aebbc1 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 11:29:00 +0530 Subject: [PATCH 02/27] feat: finish abilities and policies API --- package.json | 1 + src/{action.ts => ability.ts} | 18 +- src/base_policy.ts | 3 + src/bouncer.ts | 313 ++++------ src/debug.ts | 12 + src/decorators/action.ts | 21 + src/errors.ts | 99 +++ src/policy_authorizer.ts | 236 +++++++ src/response.ts | 26 + src/types.ts | 26 +- tests/authorization_exception.spec.ts | 123 ++++ tests/bouncer.spec.ts | 763 ----------------------- tests/bouncer/abilities.spec.ts | 414 ++++++++++++ tests/bouncer/policies.spec.ts | 864 ++++++++++++++++++++++++++ tests/response.spec.ts | 37 ++ 15 files changed, 1997 insertions(+), 959 deletions(-) rename src/{action.ts => ability.ts} (55%) create mode 100644 src/debug.ts create mode 100644 src/decorators/action.ts create mode 100644 src/errors.ts create mode 100644 src/policy_authorizer.ts create mode 100644 tests/authorization_exception.spec.ts delete mode 100644 tests/bouncer.spec.ts create mode 100644 tests/bouncer/abilities.spec.ts create mode 100644 tests/bouncer/policies.spec.ts create mode 100644 tests/response.spec.ts diff --git a/package.json b/package.json index 58ddf06..f803add 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "devDependencies": { "@adonisjs/core": "^6.1.5-33", "@adonisjs/eslint-config": "^1.2.0", + "@adonisjs/i18n": "^2.0.0-8", "@adonisjs/prettier-config": "^1.2.0", "@adonisjs/tsconfig": "^1.2.0", "@commitlint/cli": "^18.4.3", diff --git a/src/action.ts b/src/ability.ts similarity index 55% rename from src/action.ts rename to src/ability.ts index 87a82ca..ad7d061 100644 --- a/src/action.ts +++ b/src/ability.ts @@ -8,34 +8,32 @@ */ import { AuthorizationResponse } from './response.js' -import { AuthorizerResponse, BouncerAction, BouncerAuthorizer } from './types.js' +import { AuthorizerResponse, BouncerAbility, BouncerAuthorizer } from './types.js' /** - * Helper to convert a user defined authorizer function to a bouncer action + * Helper to convert a user defined authorizer function to a bouncer ability */ -export function action>( +export function ability>( authorizer: Authorizer, options?: { allowGuest: boolean } ) { return { allowGuest: options?.allowGuest || false, original: authorizer, - async execute(user, ...args) { + execute(user, ...args) { if (user === null && !this.allowGuest) { - return new AuthorizationResponse(false) + return AuthorizationResponse.deny() } - - const response = await this.original(user, ...args) - return typeof response === 'boolean' ? new AuthorizationResponse(response) : response + return this.original(user, ...args) }, - } satisfies BouncerAction as Authorizer extends ( + } satisfies BouncerAbility as Authorizer extends ( user: infer User, ...args: infer Args ) => AuthorizerResponse ? { allowGuest: false original: Authorizer - execute(user: User | null, ...args: Args): Promise + execute(user: User | null, ...args: Args): AuthorizerResponse } : never } diff --git a/src/base_policy.ts b/src/base_policy.ts index f5c8bbd..3618b48 100644 --- a/src/base_policy.ts +++ b/src/base_policy.ts @@ -9,6 +9,9 @@ import { defineStaticProperty } from '@poppinss/utils' +/** + * Base policy to define custom bouncer policies + */ export abstract class BasePolicy { static booted: boolean = false static actionsMetaData: Record = {} diff --git a/src/bouncer.ts b/src/bouncer.ts index bbdc7e2..f4e4363 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -11,31 +11,41 @@ import { inspect } from 'node:util' import { RuntimeException } from '@poppinss/utils' import { type ContainerResolver } from '@adonisjs/core/container' -import { action as createAction } from './action.js' +import debug from './debug.js' import { AuthorizationResponse } from './response.js' -import { +import { E_AUTHORIZATION_FAILURE } from './errors.js' +import { ability as createAbility } from './ability.js' +import { PolicyAuthorizer } from './policy_authorizer.js' +import type { + LazyImport, Constructor, - BouncerAction, - GetPolicyMethods, + BouncerAbility, + ResponseBuilder, + UnWrapLazyImport, AuthorizerResponse, - NarrowActionsForAUser, - LazyImport, + NarrowAbilitiesForAUser, } from './types.js' -import { BasePolicy } from './base_policy.js' /** - * Bouncer exposes the API to evaluate bouncer actions and policies to + * Bouncer exposes the API to evaluate bouncer abilities and policies to * verify if a user is authorized to perform the given action */ export class Bouncer< User extends Record, - Actions extends Record> | undefined = undefined, - Policies extends Record> | undefined = undefined, + Abilities extends Record> | undefined = undefined, + Policies extends Record>> | undefined = undefined, > { /** - * Define a bouncer action from a callback + * Response builder is used to normalize bouncer responses + */ + static responseBuilder: ResponseBuilder = (response) => { + return typeof response === 'boolean' ? new AuthorizationResponse(response) : response + } + + /** + * Define a bouncer ability from a callback */ - static action = createAction + static define = createAbility /** * User resolver to lazily resolve the user @@ -48,32 +58,29 @@ export class Bouncer< #user?: User | null /** - * Pre-defined actions + * Pre-defined abilities */ - #actions?: Actions + #abilities?: Abilities /** - * A set of policies we already know are classes. Just - * to avoid the class check + * Pre-defined policies */ - #knownPolicies: Set> = new Set() + #policies?: Policies /** - * Reference to the IoC container resolver. It is needed - * to optionally construct policy class instances + * Reference to the container resolver to construct + * policy classes. */ #containerResolver?: ContainerResolver - constructor(userOrResolver: User | (() => User | null) | null, actions?: Actions) { + constructor( + userOrResolver: User | (() => User | null) | null, + abilities?: Abilities, + policies?: Policies + ) { this.#userOrResolver = userOrResolver - this.#actions = actions - } - - /** - * Set a container resolver to use for resolving policies - */ - setContainerResolver(containerResolver: ContainerResolver) { - this.#containerResolver = containerResolver + this.#abilities = abilities + this.#policies = policies } /** @@ -92,99 +99,52 @@ export class Bouncer< } /** - * Check if the policy reference is a class constructor - */ - #isAClass(Policy: unknown): Policy is Constructor { - if (this.#knownPolicies.has(Policy as any)) { - return true - } - return typeof Policy === 'function' && Policy.toString().startsWith('class ') - } - - /** - * Check if a policy method allows guest users + * Returns an instance of PolicyAuthorizer. PolicyAuthorizer is + * used to authorize user and actions using a given policy */ - #policyAllowsGuests(Policy: Constructor, action: string): boolean { - if (!('actionsMetaData' in Policy)) { - return false - } + with( + policy: Policy + ): Policies extends Record>> + ? PolicyAuthorizer> + : never + with>(policy: Policy): PolicyAuthorizer + with(policy: Policy) { + if (typeof policy !== 'function') { + /** + * Ensure the policy is pre-registered + */ + if (!this.#policies || !this.#policies[policy]) { + throw new RuntimeException(`Invalid bouncer policy "${inspect(policy)}"`) + } - const methodMetaData = (Policy.actionsMetaData as (typeof BasePolicy)['actionsMetaData'])[ - action - ] - if (!methodMetaData) { - return false + return new PolicyAuthorizer( + this.#getUser(), + this.#policies[policy], + Bouncer.responseBuilder + ).setContainerResolver(this.#containerResolver) } - return methodMetaData.allowGuest + return new PolicyAuthorizer( + this.#getUser(), + policy, + Bouncer.responseBuilder + ).setContainerResolver(this.#containerResolver) } /** - * Executes a policy action from the policy class constructor and a - * method on the class. + * Set a container resolver to use for resolving policies */ - async #executePolicyAction(Policy: unknown, action: string, ...args: any[]) { - /** - * Ensure policy is a class constructor - */ - if (!this.#isAClass(Policy)) { - throw new RuntimeException('Invalid policy reference. It must be a class constructor') - } - - /** - * Create an instance of the class either using the container - * resolver or manually. - */ - const policyInstance = this.#containerResolver - ? await this.#containerResolver.make(Policy) - : new Policy() - - /** - * Ensure the method exists on the policy class instead - */ - if (typeof policyInstance[action] !== 'function') { - throw new RuntimeException(`Cannot find method "${action}" on "[class ${Policy.name}]"`) - } - - const user = this.#getUser() - - /** - * Disallow action for guest users - */ - if (user === null && !this.#policyAllowsGuests(Policy, action)) { - return new AuthorizationResponse(false) - } - - /** - * Invoke action manually and normalize its response - */ - const response = await policyInstance[action](user, ...args) - return typeof response === 'boolean' ? new AuthorizationResponse(response) : response + setContainerResolver(containerResolver?: ContainerResolver): this { + this.#containerResolver = containerResolver + return this } /** - * Execute an action from a policy class. The policy will be - * constructed using the AdonisJS IoC container - */ - execute< - Policy extends Constructor, - Method extends GetPolicyMethods>, - >( - action: [Policy, Method], - ...args: InstanceType[Method] extends ( - user: User, - ...args: infer Args - ) => AuthorizerResponse - ? Args - : never - ): Promise - - /** - * Execute an action by reference + * Execute an ability by reference */ - execute>( - action: Action, - ...args: Action extends { + execute>( + ability: Ability, + ...args: Ability extends { original: (user: User, ...args: infer Args) => AuthorizerResponse } ? Args @@ -192,136 +152,137 @@ export class Bouncer< ): Promise /** - * Execute an action from the list of pre-defined actions + * Execute an ability from the list of pre-defined abilities */ - execute>( - action: Action, - ...args: Actions[Action] extends { + execute>( + ability: Ability, + ...args: Abilities[Ability] extends { original: (user: User, ...args: infer Args) => AuthorizerResponse } ? Args : never ): Promise - async execute(action: any, ...args: any[]): Promise { + async execute(ability: any, ...args: any[]): Promise { /** - * Executing action from a pre-defined list of actions + * Executing ability from a pre-defined list of abilities */ - if (this.#actions && this.#actions[action]) { - return this.#actions[action].execute(this.#getUser(), ...args) + if (this.#abilities && this.#abilities[ability]) { + debug('executing pre-registered ability "%s"', ability) + return Bouncer.responseBuilder( + await this.#abilities[ability].execute(this.#getUser(), ...args) + ) } /** - * Executing policy action + * Ensure value is an ability reference or throw error */ - if (Array.isArray(action)) { - return this.#executePolicyAction(action[0], action[1], ...args) + if (!ability || typeof ability !== 'object' || 'execute' in ability === false) { + throw new RuntimeException(`Invalid bouncer ability "${inspect(ability)}"`) } /** - * Ensure value is an action reference or throw error + * Executing ability by reference */ - if (!action || typeof action !== 'object' || 'execute' in action === false) { - throw new RuntimeException(`Invalid bouncer action ${inspect(action)}`) + if (debug.enabled) { + debug('executing ability "%s"', ability.name) } - - /** - * Executing action by reference - */ - return await (action as BouncerAction).execute(this.#getUser(), ...args) + return Bouncer.responseBuilder( + await (ability as BouncerAbility).execute(this.#getUser(), ...args) + ) } /** - * Check if a user is allowed to perform a policy action. The policy will be - * constructed using the AdonisJS IoC container + * Check if a user is allowed to perform an action using + * the ability provided by reference */ - allows< - Policy extends Constructor, - Method extends GetPolicyMethods>, - >( - action: [Policy, Method], - ...args: InstanceType[Method] extends ( - user: User, - ...args: infer Args - ) => AuthorizerResponse + allows>( + ability: Ability, + ...args: Ability extends { + original: (user: User, ...args: infer Args) => AuthorizerResponse + } ? Args : never ): Promise /** - * Check if a user is allowed to perform an action provided - * as a reference + * Check if a user is allowed to perform an action using + * the ability from the pre-defined list of abilities */ - allows>( - action: Action, - ...args: Action extends { + allows>( + ability: Ability, + ...args: Abilities[Ability] extends { original: (user: User, ...args: infer Args) => AuthorizerResponse } ? Args : never ): Promise + async allows(ability: any, ...args: any[]): Promise { + const response = await this.execute(ability, ...args) + return response.authorized + } /** - * Check if a user is allowed to perform an action - * from the list of pre-defined actions + * Check if a user is denied from performing an action using + * the ability provided by reference */ - allows>( + denies>( action: Action, - ...args: Actions[Action] extends { + ...args: Action extends { original: (user: User, ...args: infer Args) => AuthorizerResponse } ? Args : never ): Promise - async allows(action: any, ...args: any[]): Promise { - const response = await this.execute(action, ...args) - return response.authorized - } /** - * Check if a user is denied from performing a policy action. The policy will be - * constructed using the AdonisJS IoC container + * Check if a user is denied from performing an action using + * the ability from the pre-defined list of abilities */ - denies< - Policy extends Constructor, - Method extends GetPolicyMethods>, - >( - action: [Policy, Method], - ...args: InstanceType[Method] extends ( - user: User, - ...args: infer Args - ) => AuthorizerResponse + denies>( + action: Action, + ...args: Abilities[Action] extends { + original: (user: User, ...args: infer Args) => AuthorizerResponse + } ? Args : never ): Promise + async denies(action: any, ...args: any[]): Promise { + const response = await this.execute(action, ...args) + return !response.authorized + } /** - * Check if a user is denied from performing an action provided - * as a reference + * Authorize a user against for a given ability + * + * @throws AuthorizationException */ - denies>( + authorize>( action: Action, ...args: Action extends { original: (user: User, ...args: infer Args) => AuthorizerResponse } ? Args : never - ): Promise + ): Promise /** - * Check if a user is denied from performing an action - * from the list of pre-defined actions + * Authorize a user against a given ability + * + * @throws {E_AUTHORIZATION_FAILURE} */ - denies>( - action: Action, - ...args: Actions[Action] extends { + authorize>( + ability: Ability, + ...args: Abilities[Ability] extends { original: (user: User, ...args: infer Args) => AuthorizerResponse } ? Args : never - ): Promise - async denies(action: any, ...args: any[]): Promise { - const response = await this.execute(action, ...args) - return !response.authorized + ): Promise + async authorize(ability: any, ...args: any[]): Promise { + const response = await this.execute(ability, ...args) + if (!response.authorized) { + throw new E_AUTHORIZATION_FAILURE(response) + } } } diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..2368ae8 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/bouncerq + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { debuglog } from 'node:util' + +export default debuglog('adonisjs:bouncer') diff --git a/src/decorators/action.ts b/src/decorators/action.ts new file mode 100644 index 0000000..3bff0b0 --- /dev/null +++ b/src/decorators/action.ts @@ -0,0 +1,21 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { BasePolicy } from '../base_policy.js' + +/** + * Define bouncer action metadata on a policy class method + */ +export function action(options: { allowGuest: boolean }) { + return function (target: BasePolicy, property: string) { + const Policy = target.constructor as typeof BasePolicy + Policy.boot() + Policy.setActionMetaData(property, options) + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..ee8adf3 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,99 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { I18n } from '@adonisjs/i18n' +import { Exception } from '@poppinss/utils' +import type { HttpContext } from '@adonisjs/core/http' +import type { AuthorizationResponse } from './response.js' + +/** + * AuthorizationException is raised by bouncer when an ability or + * policy denies access to a user for a given resource. + */ +class AuthorizationException extends Exception { + message = 'Access denied' + status = 403 + code = 'E_AUTHORIZATION_FAILURE' + + /** + * Error identifier to lookup translation message + */ + identifier = 'access.denied' + + constructor( + public response: AuthorizationResponse, + options?: ErrorOptions & { + code?: string + status?: number + } + ) { + super(response.message, options) + } + + /** + * Returns the message to be sent in the HTTP response. + * Feel free to override this method and return a custom + * response. + */ + getResponseMessage(ctx: HttpContext) { + /** + * Give preference to response message and then fallback + * to error message + */ + const message = this.response.message || this.message + + /** + * Use translation when using i18n package + */ + if ('i18n' in ctx) { + /** + * Give preference to response translation and fallback to static + * identifier. + */ + const identifier = this.response.translation?.identifier || this.identifier + const data = this.response.translation?.data || {} + return (ctx.i18n as I18n).t(identifier, data, message) + } + + return message + } + + async handle(_: AuthorizationException, ctx: HttpContext) { + const status = this.response.status || this.status + const message = this.getResponseMessage(ctx) + + switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { + case 'html': + case null: + ctx.response.status(status).send(message) + break + case 'json': + ctx.response.status(status).send({ + errors: [ + { + message, + }, + ], + }) + break + case 'application/vnd.api+json': + ctx.response.status(status).send({ + errors: [ + { + code: this.identifier, + title: message, + }, + ], + }) + break + } + } +} + +export const E_AUTHORIZATION_FAILURE = AuthorizationException diff --git a/src/policy_authorizer.ts b/src/policy_authorizer.ts new file mode 100644 index 0000000..285d0e3 --- /dev/null +++ b/src/policy_authorizer.ts @@ -0,0 +1,236 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { RuntimeException } from '@poppinss/utils' +import { ContainerResolver } from '@adonisjs/core/container' + +import debug from './debug.js' +import { BasePolicy } from './base_policy.js' +import { E_AUTHORIZATION_FAILURE } from './errors.js' +import { AuthorizationResponse } from './response.js' +import type { + LazyImport, + Constructor, + ResponseBuilder, + GetPolicyMethods, + AuthorizerResponse, +} from './types.js' + +/** + * Map of known policies, so that we can avoid re-importing them + * for every use + */ +const KNOWN_POLICIES_CACHE: Map> = new Map() + +/** + * Exposes the API to authorize a user using a pre-defined policy + */ +export class PolicyAuthorizer< + User extends Record, + Policy extends Constructor, +> { + #policy?: Policy + #policyImporter: LazyImport | Policy + + /** + * Reference to the resolved user + */ + #user?: User | null + + /** + * Reference to the IoC container resolver. It is needed + * to optionally construct policy class instances + */ + #containerResolver?: ContainerResolver + + /** + * Response builder is used to normalize bouncer responses + */ + #responseBuilder: ResponseBuilder + + constructor( + user: User | null, + policy: LazyImport | Policy, + responseBuilder: ResponseBuilder + ) { + this.#user = user + this.#policyImporter = policy + this.#responseBuilder = responseBuilder + } + + /** + * Check if a policy method allows guest users + */ + #policyAllowsGuests(Policy: Constructor, action: string): boolean { + const actionsMetaData = + 'actionsMetaData' in Policy && + (Policy.actionsMetaData as (typeof BasePolicy)['actionsMetaData']) + + if (!actionsMetaData || !actionsMetaData[action]) { + return false + } + + return !!actionsMetaData[action].allowGuest + } + + /** + * Check to see if policy is defined as a class + */ + #isPolicyAsClass(policy: LazyImport | Policy): policy is Policy { + return typeof policy === 'function' && policy.toString().startsWith('class ') + } + + /** + * Resolves the policy from the importer and caches it for + * repetitive use. + */ + async #resolvePolicy(): Promise> { + /** + * Prefer local reference (if exists) + */ + if (this.#policy) { + return this.#policy + } + + /** + * Read from if exists + */ + if (KNOWN_POLICIES_CACHE.has(this.#policyImporter)) { + debug('reading policy from the imports cache %O', this.#policyImporter) + return KNOWN_POLICIES_CACHE.get(this.#policyImporter)! + } + + /** + * Import policy using the importer if a lazy import function + * is provided, otherwise we consider policy to be a class + */ + const policyOrImport = this.#policyImporter + if (this.#isPolicyAsClass(policyOrImport)) { + this.#policy = policyOrImport + } else { + debug('lazily importing policy %O', this.#policyImporter) + const policyExports = await policyOrImport() + this.#policy = policyExports.default + } + + /** + * Cache the resolved value + */ + KNOWN_POLICIES_CACHE.set(this.#policyImporter, this.#policy) + return this.#policy + } + + /** + * Set a container resolver to use for resolving policies + */ + setContainerResolver(containerResolver?: ContainerResolver): this { + this.#containerResolver = containerResolver + return this + } + + /** + * Execute an action from the list of pre-defined actions + */ + async execute>>( + action: Method, + ...args: InstanceType[Method] extends ( + user: User, + ...args: infer Args + ) => AuthorizerResponse + ? Args + : never + ): Promise { + const Policy = await this.#resolvePolicy() + + /** + * Create an instance of the class either using the container + * resolver or manually. + */ + const policyInstance = this.#containerResolver + ? await this.#containerResolver.make(Policy) + : new Policy() + + /** + * Ensure the method exists on the policy class otherwise + * raise an exception + */ + if (typeof policyInstance[action] !== 'function') { + throw new RuntimeException( + `Cannot find method "${action as string}" on "[class ${Policy.name}]"` + ) + } + + /** + * Disallow action for guest users + */ + if (this.#user === null && !this.#policyAllowsGuests(Policy, action as string)) { + return this.#responseBuilder(AuthorizationResponse.deny()) + } + + /** + * Invoke action manually and normalize its response + */ + const response = await policyInstance[action](this.#user, ...args) + return this.#responseBuilder(response) + } + + /** + * Check if a user is allowed to perform an action using + * one of the known policy methods + */ + async allows>>( + action: Method, + ...args: InstanceType[Method] extends ( + user: User, + ...args: infer Args + ) => AuthorizerResponse + ? Args + : never + ): Promise { + const response = await this.execute(action, ...args) + return response.authorized + } + + /** + * Check if a user is denied from performing an action using + * one of the known policy methods + */ + async denies>>( + action: Method, + ...args: InstanceType[Method] extends ( + user: User, + ...args: infer Args + ) => AuthorizerResponse + ? Args + : never + ): Promise { + const response = await this.execute(action, ...args) + return !response.authorized + } + + /** + * Authorize a user against a given policy action + * + * @throws {E_AUTHORIZATION_FAILURE} + */ + async authorize>>( + action: Method, + ...args: InstanceType[Method] extends ( + user: User, + ...args: infer Args + ) => AuthorizerResponse + ? Args + : never + ): Promise { + const response = await this.execute(action, ...args) + if (!response.authorized) { + throw new E_AUTHORIZATION_FAILURE(response) + } + } +} diff --git a/src/response.ts b/src/response.ts index babd611..cf414bb 100644 --- a/src/response.ts +++ b/src/response.ts @@ -8,5 +8,31 @@ */ export class AuthorizationResponse { + static deny(message?: string, statusCode?: number) { + const response = new AuthorizationResponse(false) + response.message = message + response.status = statusCode + return response + } + + static allow() { + return new AuthorizationResponse(true) + } + + declare status?: number + declare message?: string + declare translation?: { + identifier: string + data?: Record + } + constructor(public authorized: boolean) {} + + /** + * Define the translation identifier for the authorization response + */ + t(identifier: string, data?: Record) { + this.translation = { identifier, data } + return this + } } diff --git a/src/types.ts b/src/types.ts index 886f52c..7c584b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,7 +25,7 @@ export type LazyImport = () => Promise<{ default: DefaultExport } export type UnWrapLazyImport> = Awaited>['default'] /** - * Returns a list of actions from a policy class that could be + * Returns a list of methods from a policy class that could be * used with a specific bouncer instance for a given user */ export type GetPolicyMethods = { @@ -33,15 +33,15 @@ export type GetPolicyMethods = { }[keyof Policy] /** - * Narrowing the list of actions that can be used for + * Narrowing the list of abilities that can be used for * a specific bouncer instance for a given user */ -export type NarrowActionsForAUser< +export type NarrowAbilitiesForAUser< User, - Actions extends Record> | undefined, + Abilities extends Record> | undefined, > = { - [K in keyof Actions]: Actions[K] extends BouncerAction ? K : never -}[keyof Actions] + [K in keyof Abilities]: Abilities[K] extends BouncerAbility ? K : never +}[keyof Abilities] /** * A response that can be returned by an authorizer @@ -53,17 +53,23 @@ export type AuthorizerResponse = | Promise /** - * The callback function that authorizes an action. It should always + * The callback function that authorizes an ability. It should always * accept the user as the first argument, followed by additional * arguments. */ export type BouncerAuthorizer = (user: User, ...args: any[]) => AuthorizerResponse /** - * Representation of a known bouncer action + * Representation of a known bouncer ability */ -export type BouncerAction = { +export type BouncerAbility = { allowGuest: boolean original: BouncerAuthorizer - execute(user: User | null, ...args: any[]): Promise + execute(user: User | null, ...args: any[]): AuthorizerResponse } + +/** + * Response builder is used to normalize response to + * an instanceof AuthorizationResponse + */ +export type ResponseBuilder = (response: boolean | AuthorizationResponse) => AuthorizationResponse diff --git a/tests/authorization_exception.spec.ts b/tests/authorization_exception.spec.ts new file mode 100644 index 0000000..68160c8 --- /dev/null +++ b/tests/authorization_exception.spec.ts @@ -0,0 +1,123 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { E_AUTHORIZATION_FAILURE } from '../src/errors.js' +import { AuthorizationResponse } from '../src/response.js' +import { I18nManagerFactory } from '@adonisjs/i18n/factories' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +test.group('AuthorizationException', () => { + test('make HTTP response with default message and status code', async ({ assert }) => { + const exception = new E_AUTHORIZATION_FAILURE(new AuthorizationResponse(false)) + const ctx = new HttpContextFactory().create() + + await exception.handle(exception, ctx) + assert.equal(ctx.response.getBody(), 'Access denied') + assert.equal(ctx.response.getStatus(), 403) + }) + + test('make HTTP response with response error message and status code', async ({ assert }) => { + const exception = new E_AUTHORIZATION_FAILURE(AuthorizationResponse.deny('Post not found', 404)) + const ctx = new HttpContextFactory().create() + + await exception.handle(exception, ctx) + assert.equal(ctx.response.getBody(), 'Post not found') + assert.equal(ctx.response.getStatus(), 404) + }) + + test('use default translation identifier for message when using i18n', async ({ assert }) => { + const i18nManager = new I18nManagerFactory() + .merge({ + config: { + loaders: [ + () => { + return { + async load() { + return { + en: { + 'access.denied': 'Access denied from translations', + }, + } + }, + } + }, + ], + }, + }) + .create() + + await i18nManager.loadTranslations() + + const exception = new E_AUTHORIZATION_FAILURE(AuthorizationResponse.deny('Post not found', 404)) + const ctx = new HttpContextFactory().create() + ctx.i18n = i18nManager.locale('en') + + await exception.handle(exception, ctx) + assert.equal(ctx.response.getBody(), 'Access denied from translations') + assert.equal(ctx.response.getStatus(), 404) + }) + + test('use response translation identifier for message when using i18n', async ({ assert }) => { + const i18nManager = new I18nManagerFactory() + .merge({ + config: { + loaders: [ + () => { + return { + async load() { + return { + en: { + 'access.denied': 'Access denied from translations', + 'errors.not_found': 'Page not found', + }, + } + }, + } + }, + ], + }, + }) + .create() + + await i18nManager.loadTranslations() + + const exception = new E_AUTHORIZATION_FAILURE( + AuthorizationResponse.deny('Post not found', 404).t('errors.not_found') + ) + const ctx = new HttpContextFactory().create() + ctx.i18n = i18nManager.locale('en') + + await exception.handle(exception, ctx) + assert.equal(ctx.response.getBody(), 'Page not found') + assert.equal(ctx.response.getStatus(), 404) + }) + + test('get JSON response', async ({ assert }) => { + const exception = new E_AUTHORIZATION_FAILURE(new AuthorizationResponse(false)) + const ctx = new HttpContextFactory().create() + ctx.request.request.headers.accept = 'application/json' + + await exception.handle(exception, ctx) + assert.deepEqual(ctx.response.getBody(), { errors: [{ message: 'Access denied' }] }) + assert.equal(ctx.response.getStatus(), 403) + }) + + test('get JSONAPI response', async ({ assert }) => { + const exception = new E_AUTHORIZATION_FAILURE(new AuthorizationResponse(false)) + const ctx = new HttpContextFactory().create() + ctx.request.request.headers.accept = 'application/vnd.api+json' + + await exception.handle(exception, ctx) + assert.deepEqual(ctx.response.getBody(), { + errors: [{ code: 'access.denied', title: 'Access denied' }], + }) + assert.equal(ctx.response.getStatus(), 403) + }) +}) diff --git a/tests/bouncer.spec.ts b/tests/bouncer.spec.ts deleted file mode 100644 index dc4031b..0000000 --- a/tests/bouncer.spec.ts +++ /dev/null @@ -1,763 +0,0 @@ -/* - * @adonisjs/boucner - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { Container, inject } from '@adonisjs/core/container' - -import { Bouncer } from '../src/bouncer.js' -import { BasePolicy } from '../src/base_policy.js' -import { AuthorizationResponse } from '../src/response.js' - -test.group('Bouncer | actions | types', () => { - test('assert allowed actions by reference', async () => { - class User { - declare id: number - declare email: string - } - class Admin { - declare adminId: number - } - - const editPost = Bouncer.action((_: User) => false) - const editStaff = Bouncer.action((_: Admin) => false) - const bouncer = new Bouncer(new User()) - await bouncer.execute(editPost) - - /** - * Since, the editStaff action needs an instance of Admin - * class, it cannot used with a bouncer instance created - * for the User class - */ - // @ts-expect-error - await bouncer.execute(editStaff) - - /** - * Since, we have not passed any predefined actions to buncer, we - * cannot call them as string - */ - // @ts-expect-error - await bouncer.execute('editStaff') - }).throws(`Invalid bouncer action 'editStaff'`) - - test('assert allowed pre-defined actions', async () => { - class User { - declare id: number - declare email: string - } - class Admin { - declare adminId: number - } - - const editPost = Bouncer.action((_: User) => false) - const editStaff = Bouncer.action((_: Admin) => false) - const bouncer = new Bouncer(new User(), { editPost, editStaff }) - - await bouncer.execute('editPost') - - /** - * Since, the editStaff action needs an instance of Admin - * class, it cannot used with a bouncer instance created - * for the User class - */ - // @ts-expect-error - await bouncer.execute('editStaff') - // @ts-expect-error - await bouncer.execute(editStaff) - }) - - test('assert allowed actions from policies', async () => { - class User { - declare id: number - declare email: string - } - class Admin { - declare adminId: number - } - - class PostPolicy extends BasePolicy { - viewAll(_: User) { - return true - } - } - class StaffPolicy { - viewAll(_: Admin) { - return true - } - } - - const editPost = Bouncer.action((_: User) => false) - const editStaff = Bouncer.action((_: Admin) => false) - - const bouncer = new Bouncer(new User()) - await bouncer.execute(editPost) - await bouncer.execute([PostPolicy, 'viewAll']) - - /** - * Since, the editStaff action needs an instance of Admin - * class, it cannot used with a bouncer instance created - * for the User class - */ - // @ts-expect-error - await bouncer.execute(editStaff) - - /** - * Since, we have not passed any predefined actions to buncer, we - * cannot call them as string - */ - // @ts-expect-error - await bouncer.execute('editStaff') - - /** - * Since, the "StaffPolicy" needs Admin class, it cannot be - * used with the bouncer instance created for the User - * class. - */ - // @ts-expect-error - await bouncer.execute([StaffPolicy, 'viewAll']) - }).throws(`Invalid bouncer action 'editStaff'`) - - test('infer arguments accepted by an action', async () => { - class User { - declare id: number - declare email: string - } - class Post { - declare userId: null - declare title: string - } - - const editPost = Bouncer.action((_: User, __: Post) => { - return false - }) - const bouncer = new Bouncer(new User(), { editPost }) - - bouncer.allows('editPost', new Post()) - bouncer.allows(editPost, new Post()) - - /** - * Since, the editPost action needs post instance, it gives - * type error if do not pass the parameter - */ - // @ts-expect-error - bouncer.allows('editPost') - - /** - * Since, the editPost action needs post instance, it gives - * type error if do not pass the parameter - */ - // @ts-expect-error - bouncer.allows(editPost) - }) - - test('infer arguments accepted by a policy', async () => { - class User { - declare id: number - declare email: string - } - class Post { - declare userId: null - declare title: string - } - - class PostPolicy extends BasePolicy { - viewAll(_: User, __: Post) { - return true - } - } - - const bouncer = new Bouncer(new User()) - await bouncer.execute([PostPolicy, 'viewAll'], new Post()) - - /** - * Since, the "viewAll" action needs a post instance, it returns - * an error when post instance is not provided - */ - // @ts-expect-error - await bouncer.execute([PostPolicy, 'viewAll']) - }) - - test('assert allowed actions by reference with union of users', async () => { - class User { - declare id: number - declare email: string - } - class Admin { - declare adminId: number - } - - const editPost = Bouncer.action((_: User | Admin) => false) - const editStaff = Bouncer.action((_: User | Admin) => false) - - const bouncer = new Bouncer(new User()) - const bouncer1 = new Bouncer(new User()) - - await bouncer.allows(editPost) - await bouncer.allows(editStaff) - - await bouncer1.allows(editPost) - await bouncer1.allows(editStaff) - - /** - * Since, we have not passed any predefined actions to buncer, we - * cannot call them as string - */ - // @ts-expect-error - await bouncer.allows('editStaff') - /** - * Since, we have not passed any predefined actions to buncer, we - * cannot call them as string - */ - // @ts-expect-error - await bouncer1.allows('editStaff') - }).throws(`Invalid bouncer action 'editStaff'`) - - test('assert allowed pre-defined actions with union of users', async () => { - class User { - declare id: number - declare email: string - } - class Admin { - declare adminId: number - } - - const editPost = Bouncer.action((_: User | Admin) => false) - const editStaff = Bouncer.action((_: User | Admin) => false) - const actions = { editPost, editStaff } - - const bouncer = new Bouncer(new User(), actions) - const bouncer1 = new Bouncer(new User(), actions) - - await bouncer.allows('editPost') - await bouncer.allows('editStaff') - await bouncer.allows(editStaff) - - await bouncer1.allows('editPost') - await bouncer1.allows('editStaff') - await bouncer1.allows(editStaff) - }) - - test('assert allowed actions from policies with union of users', async () => { - class User { - declare id: number - declare email: string - } - class Admin { - declare adminId: number - } - - class PostPolicy extends BasePolicy { - viewAll(_: User | Admin) { - return true - } - } - class StaffPolicy extends BasePolicy { - viewAll(_: Admin | User) { - return true - } - } - - const bouncer = new Bouncer(new User()) - await bouncer.execute([PostPolicy, 'viewAll']) - await bouncer.execute([StaffPolicy, 'viewAll']) - - const bouncer1 = new Bouncer(new User()) - await bouncer1.execute([PostPolicy, 'viewAll']) - await bouncer1.execute([StaffPolicy, 'viewAll']) - }) -}) - -test.group('Bouncer | actions', () => { - test('execute action by reference', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action((_: User) => false) - const bouncer = new Bouncer(new User()) - - const response = await bouncer.execute(editPost) - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, false) - }) - - test('execute action from pre-defined list', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action((_: User) => false) - const bouncer = new Bouncer(new User(), { editPost }) - - const response = await bouncer.execute('editPost') - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, false) - }) - - test('execute policy action', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - class PostPolicy extends BasePolicy { - viewAll(_: User) { - return false - } - } - - const bouncer = new Bouncer(new User()) - const response = await bouncer.execute([PostPolicy, 'viewAll']) - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, false) - }) - - test('pass arguments to the action', async ({ assert }) => { - class User { - declare email: string - constructor(public id: number) {} - } - class Post { - constructor(public userId: number) {} - } - - const editPost = Bouncer.action((user: User, post: Post) => { - return post.userId === user.id - }) - - const bouncer = new Bouncer(new User(1), { editPost }) - - const response = await bouncer.execute('editPost', new Post(1)) - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, true) - - const referenceResponse = await bouncer.execute(editPost, new Post(2)) - assert.instanceOf(referenceResponse, AuthorizationResponse) - assert.equal(referenceResponse.authorized, false) - }) - - test('pass arguments to policy action', async ({ assert }) => { - class User { - declare email: string - constructor(public id: number) {} - } - class Post { - constructor(public userId: number) {} - } - - class PostPolicy extends BasePolicy { - viewAll(user: User, post: Post) { - return post.userId === user.id - } - } - - const bouncer = new Bouncer(new User(1)) - const response = await bouncer.execute([PostPolicy, 'viewAll'], new Post(1)) - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, true) - - const failingResponse = await bouncer.execute([PostPolicy, 'viewAll'], new Post(2)) - assert.instanceOf(failingResponse, AuthorizationResponse) - assert.equal(failingResponse.authorized, false) - }) - - test('check if user is allowed or denied to perform an action', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action((_: User) => false) - const bouncer = new Bouncer(new User(), { editPost }) - - assert.isFalse(await bouncer.allows(editPost)) - assert.isTrue(await bouncer.denies(editPost)) - - assert.isFalse(await bouncer.allows('editPost')) - assert.isTrue(await bouncer.denies('editPost')) - }) - - test('check if user is allowed or denied to perform a policy action', async ({ assert }) => { - class User { - declare email: string - constructor(public id: number) {} - } - - class PostPolicy extends BasePolicy { - viewAll(_: User) { - return false - } - } - - const editPost = Bouncer.action((_: User) => false) - const bouncer = new Bouncer(new User(1), { editPost }) - - assert.isFalse(await bouncer.allows(editPost)) - assert.isTrue(await bouncer.denies(editPost)) - - assert.isFalse(await bouncer.allows('editPost')) - assert.isTrue(await bouncer.denies('editPost')) - - assert.isFalse(await bouncer.allows([PostPolicy, 'viewAll'])) - assert.isTrue(await bouncer.denies([PostPolicy, 'viewAll'])) - }) - - test('deny access for guest users', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action((_: User) => { - throw new Error('Never executed to be invoked for guest users') - }) - const actions = { editPost } - - const bouncer = new Bouncer(null, actions) - - assert.isFalse(await bouncer.allows(editPost)) - assert.isTrue(await bouncer.denies(editPost)) - - assert.isFalse(await bouncer.allows('editPost')) - assert.isTrue(await bouncer.denies('editPost')) - }) - - test('deny access for guest users on policy actions', async ({ assert }) => { - class User { - declare email: string - constructor(public id: number) {} - } - - class PostPolicy extends BasePolicy { - viewAll(_: User): boolean { - throw new Error('Never expected to called for guest users') - } - } - - const bouncer = new Bouncer(null) - - assert.isFalse(await bouncer.allows([PostPolicy, 'viewAll'])) - assert.isTrue(await bouncer.denies([PostPolicy, 'viewAll'])) - }) - - test('execute action when guest users are allowed', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action( - (_: User | null) => { - return true - }, - { allowGuest: true } - ) - const actions = { editPost } - - const bouncer = new Bouncer(null, actions) - - assert.isTrue(await bouncer.allows(editPost)) - assert.isFalse(await bouncer.denies(editPost)) - - assert.isTrue(await bouncer.allows('editPost')) - assert.isFalse(await bouncer.denies('editPost')) - }) - - test('execute action for guest users on policy actions when guests are allowed', async ({ - assert, - }) => { - class User { - declare email: string - constructor(public id: number) {} - } - - class PostPolicy extends BasePolicy { - viewAll(_: User): boolean { - return true - } - } - PostPolicy.setActionMetaData('viewAll', { allowGuest: true }) - - const bouncer = new Bouncer(null) - - assert.isTrue(await bouncer.allows([PostPolicy, 'viewAll'])) - assert.isFalse(await bouncer.denies([PostPolicy, 'viewAll'])) - }) -}) - -test.group('Bouncer | actions | userResolver', () => { - test('execute action by reference', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action((_: User) => false) - const bouncer = new Bouncer(() => new User()) - - const response = await bouncer.execute(editPost) - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, false) - }) - - test('execute action from pre-defined list', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action((_: User) => false) - const bouncer = new Bouncer(() => new User(), { editPost }) - - const response = await bouncer.execute('editPost') - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, false) - }) - - test('execute policy action', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - class PostPolicy extends BasePolicy { - viewAll(_: User) { - return false - } - } - - const bouncer = new Bouncer(() => new User()) - const response = await bouncer.execute([PostPolicy, 'viewAll']) - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, false) - }) - - test('pass arguments to the action', async ({ assert }) => { - class User { - declare email: string - constructor(public id: number) {} - } - class Post { - constructor(public userId: number) {} - } - - const editPost = Bouncer.action((user: User, post: Post) => { - return post.userId === user.id - }) - - const bouncer = new Bouncer(() => new User(1), { editPost }) - - const response = await bouncer.execute('editPost', new Post(1)) - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, true) - - const referenceResponse = await bouncer.execute(editPost, new Post(2)) - assert.instanceOf(referenceResponse, AuthorizationResponse) - assert.equal(referenceResponse.authorized, false) - }) - - test('pass arguments to policy action', async ({ assert }) => { - class User { - declare email: string - constructor(public id: number) {} - } - class Post { - constructor(public userId: number) {} - } - - class PostPolicy extends BasePolicy { - viewAll(user: User, post: Post) { - return post.userId === user.id - } - } - - const bouncer = new Bouncer(() => new User(1)) - const response = await bouncer.execute([PostPolicy, 'viewAll'], new Post(1)) - assert.instanceOf(response, AuthorizationResponse) - assert.equal(response.authorized, true) - - const failingResponse = await bouncer.execute([PostPolicy, 'viewAll'], new Post(2)) - assert.instanceOf(failingResponse, AuthorizationResponse) - assert.equal(failingResponse.authorized, false) - }) - - test('check if user is allowed or denied to perform an action', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action((_: User) => false) - const bouncer = new Bouncer(() => new User(), { editPost }) - - assert.isFalse(await bouncer.allows(editPost)) - assert.isTrue(await bouncer.denies(editPost)) - - assert.isFalse(await bouncer.allows('editPost')) - assert.isTrue(await bouncer.denies('editPost')) - }) - - test('check if user is allowed or denied to perform a policy action', async ({ assert }) => { - class User { - declare email: string - constructor(public id: number) {} - } - - class PostPolicy extends BasePolicy { - viewAll(_: User) { - return false - } - } - - const editPost = Bouncer.action((_: User) => false) - const bouncer = new Bouncer(() => new User(1), { editPost }) - - assert.isFalse(await bouncer.allows(editPost)) - assert.isTrue(await bouncer.denies(editPost)) - - assert.isFalse(await bouncer.allows('editPost')) - assert.isTrue(await bouncer.denies('editPost')) - - assert.isFalse(await bouncer.allows([PostPolicy, 'viewAll'])) - assert.isTrue(await bouncer.denies([PostPolicy, 'viewAll'])) - }) - - test('deny access for guest users', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action((_: User) => { - throw new Error('Never executed to be invoked for guest users') - }) - const actions = { editPost } - - const bouncer = new Bouncer(() => null, actions) - - assert.isFalse(await bouncer.allows(editPost)) - assert.isTrue(await bouncer.denies(editPost)) - - assert.isFalse(await bouncer.allows('editPost')) - assert.isTrue(await bouncer.denies('editPost')) - }) - - test('deny access for guest users on policy actions', async ({ assert }) => { - class User { - declare email: string - constructor(public id: number) {} - } - - class PostPolicy extends BasePolicy { - viewAll(_: User): boolean { - throw new Error('Never expected to called for guest users') - } - } - - const bouncer = new Bouncer(() => null) - - assert.isFalse(await bouncer.allows([PostPolicy, 'viewAll'])) - assert.isTrue(await bouncer.denies([PostPolicy, 'viewAll'])) - }) - - test('execute action when guest users are allowed', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - const editPost = Bouncer.action( - (_: User | null) => { - return true - }, - { allowGuest: true } - ) - const actions = { editPost } - - const bouncer = new Bouncer(() => null, actions) - - assert.isTrue(await bouncer.allows(editPost)) - assert.isFalse(await bouncer.denies(editPost)) - - assert.isTrue(await bouncer.allows('editPost')) - assert.isFalse(await bouncer.denies('editPost')) - }) - - test('execute action for guest users on policy actions when guests are allowed', async ({ - assert, - }) => { - class User { - declare email: string - constructor(public id: number) {} - } - - class PostPolicy extends BasePolicy { - viewAll(_: User): boolean { - return true - } - } - PostPolicy.setActionMetaData('viewAll', { allowGuest: true }) - - const bouncer = new Bouncer(() => null) - - assert.isTrue(await bouncer.allows([PostPolicy, 'viewAll'])) - assert.isFalse(await bouncer.denies([PostPolicy, 'viewAll'])) - }) -}) - -test.group('Bouncer | policies | extendedChecks', () => { - test('throw error when policy is not a class', async () => { - class User { - declare id: number - declare email: string - } - - const bouncer = new Bouncer(new User()) - // @ts-expect-error - await bouncer.execute([{}, 'viewAll']) - }).throws('Invalid policy reference. It must be a class constructor') - - test('throw error when policy action is not defined on the class', async () => { - class User { - declare id: number - declare email: string - } - - class PostPolicy extends BasePolicy {} - - const bouncer = new Bouncer(new User()) - // @ts-expect-error - await bouncer.execute([PostPolicy, 'viewAll']) - }).throws('Cannot find method "viewAll" on "[class PostPolicy]"') - - test('constructor policy class using container', async ({ assert }) => { - class User { - declare id: number - declare email: string - } - - class Logger {} - - @inject() - class PostPolicy extends BasePolicy { - constructor(public logger: Logger) { - super() - } - - viewAll(_: User) { - return this.logger instanceof Logger - } - } - - const bouncer = new Bouncer(new User()) - bouncer.setContainerResolver(new Container().createResolver()) - assert.isTrue(await bouncer.allows([PostPolicy, 'viewAll'])) - }) -}) diff --git a/tests/bouncer/abilities.spec.ts b/tests/bouncer/abilities.spec.ts new file mode 100644 index 0000000..8837991 --- /dev/null +++ b/tests/bouncer/abilities.spec.ts @@ -0,0 +1,414 @@ +/* + * @adonisjs/boucner + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' + +import { Bouncer } from '../../src/bouncer.js' +import { AuthorizationResponse } from '../../src/response.js' + +test.group('Bouncer | actions | types', () => { + test('assert allowed actions by reference', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + const editPost = Bouncer.define((_: User) => false) + const editStaff = Bouncer.define((_: Admin) => false) + const bouncer = new Bouncer(new User()) + await bouncer.execute(editPost) + + /** + * Since, the editStaff action needs an instance of Admin + * class, it cannot used with a bouncer instance created + * for the User class + */ + // @ts-expect-error + await bouncer.execute(editStaff) + + /** + * Since, we have not passed any predefined actions to buncer, we + * cannot call them as string + */ + // @ts-expect-error + await bouncer.execute('editStaff') + }).throws(`Invalid bouncer ability "'editStaff'"`) + + test('assert allowed pre-defined actions', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + const editPost = Bouncer.define((_: User) => false) + const editStaff = Bouncer.define((_: Admin) => false) + const bouncer = new Bouncer(new User(), { editPost, editStaff }) + + await bouncer.execute('editPost') + + /** + * Since, the editStaff action needs an instance of Admin + * class, it cannot used with a bouncer instance created + * for the User class + */ + // @ts-expect-error + await bouncer.execute('editStaff') + // @ts-expect-error + await bouncer.execute(editStaff) + }) + + test('infer arguments accepted by an action', async () => { + class User { + declare id: number + declare email: string + } + class Post { + declare userId: null + declare title: string + } + + const editPost = Bouncer.define((_: User, __: Post) => { + return false + }) + const bouncer = new Bouncer(new User(), { editPost }) + + bouncer.allows('editPost', new Post()) + bouncer.allows(editPost, new Post()) + + /** + * Since, the editPost action needs post instance, it gives + * type error if do not pass the parameter + */ + // @ts-expect-error + bouncer.allows('editPost') + + /** + * Since, the editPost action needs post instance, it gives + * type error if do not pass the parameter + */ + // @ts-expect-error + bouncer.allows(editPost) + }) + + test('assert allowed actions by reference with union of users', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + const editPost = Bouncer.define((_: User | Admin) => false) + const editStaff = Bouncer.define((_: User | Admin) => false) + + const bouncer = new Bouncer(new User()) + const bouncer1 = new Bouncer(new User()) + + await bouncer.allows(editPost) + await bouncer.allows(editStaff) + + await bouncer1.allows(editPost) + await bouncer1.allows(editStaff) + + /** + * Since, we have not passed any predefined actions to buncer, we + * cannot call them as string + */ + // @ts-expect-error + await bouncer.allows('editStaff') + /** + * Since, we have not passed any predefined actions to buncer, we + * cannot call them as string + */ + // @ts-expect-error + await bouncer1.allows('editStaff') + }).throws(`Invalid bouncer ability "'editStaff'"`) + + test('assert allowed pre-defined actions with union of users', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + const editPost = Bouncer.define((_: User | Admin) => false) + const editStaff = Bouncer.define((_: User | Admin) => false) + const actions = { editPost, editStaff } + + const bouncer = new Bouncer(new User(), actions) + const bouncer1 = new Bouncer(new User(), actions) + + await bouncer.allows('editPost') + await bouncer.allows('editStaff') + await bouncer.allows(editStaff) + + await bouncer1.allows('editPost') + await bouncer1.allows('editStaff') + await bouncer1.allows(editStaff) + }) +}) + +test.group('Bouncer | actions', () => { + test('execute action by reference', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(new User()) + + const response = await bouncer.execute(editPost) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('execute action from pre-defined list', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(new User(), { editPost }) + + const response = await bouncer.execute('editPost') + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('pass arguments to the action', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + class Post { + constructor(public userId: number) {} + } + + const editPost = Bouncer.define((user: User, post: Post) => { + return post.userId === user.id + }) + + const bouncer = new Bouncer(new User(1), { editPost }) + + const response = await bouncer.execute('editPost', new Post(1)) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, true) + + const referenceResponse = await bouncer.execute(editPost, new Post(2)) + assert.instanceOf(referenceResponse, AuthorizationResponse) + assert.equal(referenceResponse.authorized, false) + }) + + test('check if user is allowed or denied to perform an action', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(new User(), { editPost }) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + }) + + test('deny access for guest users', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => { + throw new Error('Never executed to be invoked for guest users') + }) + const actions = { editPost } + + const bouncer = new Bouncer(null, actions) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + }) + + test('execute action when guest users are allowed', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define( + (_: User | null) => { + return true + }, + { allowGuest: true } + ) + const actions = { editPost } + + const bouncer = new Bouncer(null, actions) + + assert.isTrue(await bouncer.allows(editPost)) + assert.isFalse(await bouncer.denies(editPost)) + + assert.isTrue(await bouncer.allows('editPost')) + assert.isFalse(await bouncer.denies('editPost')) + }) +}) + +test.group('Bouncer | actions | userResolver', () => { + test('execute action by reference', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(() => new User()) + + const response = await bouncer.execute(editPost) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('execute action from pre-defined list', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(() => new User(), { editPost }) + + const response = await bouncer.execute('editPost') + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, false) + }) + + test('pass arguments to the action', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + class Post { + constructor(public userId: number) {} + } + + const editPost = Bouncer.define((user: User, post: Post) => { + return post.userId === user.id + }) + + const bouncer = new Bouncer(() => new User(1), { editPost }) + + const response = await bouncer.execute('editPost', new Post(1)) + assert.instanceOf(response, AuthorizationResponse) + assert.equal(response.authorized, true) + + const referenceResponse = await bouncer.execute(editPost, new Post(2)) + assert.instanceOf(referenceResponse, AuthorizationResponse) + assert.equal(referenceResponse.authorized, false) + }) + + test('check if user is allowed or denied to perform an action', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(() => new User(), { editPost }) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + }) + + test('deny access for guest users', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => { + throw new Error('Never executed to be invoked for guest users') + }) + const actions = { editPost } + + const bouncer = new Bouncer(() => null, actions) + + assert.isFalse(await bouncer.allows(editPost)) + assert.isTrue(await bouncer.denies(editPost)) + + assert.isFalse(await bouncer.allows('editPost')) + assert.isTrue(await bouncer.denies('editPost')) + }) + + test('execute action when guest users are allowed', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define( + (_: User | null) => { + return true + }, + { allowGuest: true } + ) + const actions = { editPost } + + const bouncer = new Bouncer(() => null, actions) + + assert.isTrue(await bouncer.allows(editPost)) + assert.isFalse(await bouncer.denies(editPost)) + + assert.isTrue(await bouncer.allows('editPost')) + assert.isFalse(await bouncer.denies('editPost')) + }) + + test('authorize action by reference', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(() => new User()) + + await assert.rejects(() => bouncer.authorize(editPost), 'Access denied') + }) + + test('authorize action from pre-defined list', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(() => new User(), { editPost }) + + await assert.rejects(() => bouncer.authorize('editPost'), 'Access denied') + }) +}) diff --git a/tests/bouncer/policies.spec.ts b/tests/bouncer/policies.spec.ts new file mode 100644 index 0000000..6bbfaf3 --- /dev/null +++ b/tests/bouncer/policies.spec.ts @@ -0,0 +1,864 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { inject } from '@adonisjs/core' +import { Container } from '@adonisjs/core/container' + +import { Bouncer } from '../../src/bouncer.js' +import { BasePolicy } from '../../src/base_policy.js' +import { action } from '../../src/decorators/action.js' +import type { AuthorizerResponse } from '../../src/types.js' + +test.group('Bouncer | policies | types', () => { + test('assert with method arguments with policy reference', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + class StaffPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: Admin): AuthorizerResponse { + return true + } + + viewAll(_: Admin): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User()) + + /** + * Both policy references should work, because we cannot infer + * in advance if all the methods of a given policy works + * with a specific user type or not. + */ + bouncer.with(PostPolicy) + bouncer.with(StaffPolicy) + + // @ts-expect-error + bouncer.with({}) + // @ts-expect-error + bouncer.with('foo') + // @ts-expect-error + bouncer.with(new StaffPolicy()) + }).throws('Invalid bouncer policy "{}"') + + test('assert with method arguments with pre-registered policies', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + class StaffPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: Admin): AuthorizerResponse { + return true + } + + viewAll(_: Admin): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + StaffPolicy: async () => { + return { + default: StaffPolicy, + } + }, + }) + + /** + * Both policy references should work, because we cannot infer + * in advance if all the methods of a given policy works + * with a specific user type or not. + */ + bouncer.with('PostPolicy') + bouncer.with('StaffPolicy') + + // @ts-expect-error + bouncer.with({}) + // @ts-expect-error + bouncer.with('foo') + // @ts-expect-error + bouncer.with(new StaffPolicy()) + }).throws('Invalid bouncer policy "{}"') + + test('infer policy methods', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + class StaffPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: Admin): AuthorizerResponse { + return true + } + + viewAll(_: Admin): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User()) + + /** + * Both policy references should work, because we cannot infer + * in advance if all the methods of a given policy works + * with a specific user type or not. + */ + await bouncer.with(PostPolicy).execute('view') + await bouncer.with(PostPolicy).execute('viewAll') + + /** + * The resolvePermission method does not accept the user + * and neither returns AuthorizerResponse + */ + // @ts-expect-error + await bouncer.with(PostPolicy).execute('resolvePermissions') + + /** + * The StaffPolicy methods works with Admin class and hence + * they cannot be used with a bouncer instance created for + * the User class + */ + // @ts-expect-error + bouncer.with(StaffPolicy).execute('view') + // @ts-expect-error + bouncer.with(StaffPolicy).execute('viewAll') + // @ts-expect-error + bouncer.with(StaffPolicy).execute('resolvePermissions') + }) + + test('infer policy methods of a pre-registered policy', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + class StaffPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: Admin): AuthorizerResponse { + return true + } + + viewAll(_: Admin): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + StaffPolicy: async () => { + return { + default: StaffPolicy, + } + }, + }) + + /** + * Both policy references should work, because we cannot infer + * in advance if all the methods of a given policy works + * with a specific user type or not. + */ + await bouncer.with('PostPolicy').execute('view') + await bouncer.with('PostPolicy').execute('viewAll') + + /** + * The resolvePermission method does not accept the user + * and neither returns AuthorizerResponse + */ + // @ts-expect-error + await bouncer.with('PostPolicy').execute('resolvePermissions') + + /** + * The StaffPolicy methods works with Admin class and hence + * they cannot be used with a bouncer instance created for + * the User class + */ + // @ts-expect-error + bouncer.with('StaffPolicy').execute('view') + // @ts-expect-error + bouncer.with('StaffPolicy').execute('viewAll') + // @ts-expect-error + bouncer.with('StaffPolicy').execute('resolvePermissions') + }) + + test('infer policy method arguments', async () => { + class User { + declare id: number + declare email: string + } + class Post { + declare userId: null + declare title: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(user: User, post: Post): AuthorizerResponse { + return user.id === post.userId + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User()) + + /** + * Both policy references should work, because we cannot infer + * in advance if all the methods of a given policy works + * with a specific user type or not. + */ + await bouncer.with(PostPolicy).execute('view', new Post()) + await bouncer.with(PostPolicy).execute('viewAll') + + /** + * Fails because we are not passing an instance of the post + * class + */ + // @ts-expect-error + await bouncer.with(PostPolicy).execute('view') + + /** + * The resolvePermission method does not accept the user + * and neither returns AuthorizerResponse + */ + // @ts-expect-error + await bouncer.with(PostPolicy).execute('resolvePermissions') + }).throws(`Cannot read properties of undefined (reading 'userId')`) + + test('infer policy method arguments of a pre-registered policy', async () => { + class User { + declare id: number + declare email: string + } + class Post { + declare userId: null + declare title: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(user: User, post: Post): AuthorizerResponse { + return user.id === post.userId + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + }) + + /** + * Both policy references should work, because we cannot infer + * in advance if all the methods of a given policy works + * with a specific user type or not. + */ + await bouncer.with('PostPolicy').execute('view', new Post()) + await bouncer.with('PostPolicy').execute('viewAll') + + /** + * Fails because we are not passing an instance of the post + * class + */ + // @ts-expect-error + await bouncer.with('PostPolicy').execute('view') + + /** + * The resolvePermission method does not accept the user + * and neither returns AuthorizerResponse + */ + // @ts-expect-error + await bouncer.with('PostPolicy').execute('resolvePermissions') + }).throws(`Cannot read properties of undefined (reading 'userId')`) + + test('infer policy methods for guest users', async () => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User | null): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User()) + + /** + * Both policy references should work, because we cannot infer + * in advance if all the methods of a given policy works + * with a specific user type or not. + */ + await bouncer.with(PostPolicy).execute('view') + await bouncer.with(PostPolicy).execute('viewAll') + + /** + * The resolvePermission method does not accept the user + * and neither returns AuthorizerResponse + */ + // @ts-expect-error + await bouncer.with(PostPolicy).execute('resolvePermissions') + }) + + test('infer policy methods for union of users', async () => { + class User { + declare id: number + declare email: string + } + class Admin { + declare adminId: number + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User | Admin): AuthorizerResponse { + return true + } + + viewAll(_: User | Admin): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User()) + + /** + * Both policy references should work, because we cannot infer + * in advance if all the methods of a given policy works + * with a specific user type or not. + */ + await bouncer.with(PostPolicy).execute('view') + await bouncer.with(PostPolicy).execute('viewAll') + + /** + * The resolvePermission method does not accept the user + * and neither returns AuthorizerResponse + */ + // @ts-expect-error + await bouncer.with(PostPolicy).execute('resolvePermissions') + }) +}) + +test.group('Bouncer | policies', () => { + test('execute policy action', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User()) + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isFalse(canViewAll.authorized) + }) + + test('execute policy action on a pre-registered policy', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + }) + + const postsPolicy = bouncer.with('PostPolicy') + + const canView = await postsPolicy.execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await postsPolicy.execute('viewAll') + assert.isFalse(canViewAll.authorized) + }) + + test('cache lazily imported policies', async ({ assert }) => { + let importsCounter: number = 0 + + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + importsCounter++ + return { + default: PostPolicy, + } + }, + }) + const canView = await bouncer.with('PostPolicy').execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer.with('PostPolicy').execute('viewAll') + assert.isFalse(canViewAll.authorized) + + assert.equal(importsCounter, 1) + }) + + test('cache lazily imported policies across bouncer instances', async ({ assert }) => { + let importsCounter: number = 0 + + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const policies = { + PostPolicy: async () => { + importsCounter++ + return { + default: PostPolicy, + } + }, + } + + const bouncer = new Bouncer(new User(), undefined, policies) + const bouncer1 = new Bouncer(new User(), undefined, policies) + + const canView = await bouncer.with('PostPolicy').execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer1.with('PostPolicy').execute('viewAll') + assert.isFalse(canViewAll.authorized) + + assert.equal(importsCounter, 1) + }) + + test('do not cache lazily imported policies when import function is not shared by reference', async ({ + assert, + }) => { + let importsCounter: number = 0 + + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + importsCounter++ + return { + default: PostPolicy, + } + }, + }) + const bouncer1 = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + importsCounter++ + return { + default: PostPolicy, + } + }, + }) + + const canView = await bouncer.with('PostPolicy').execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer1.with('PostPolicy').execute('viewAll') + assert.isFalse(canViewAll.authorized) + + assert.equal(importsCounter, 2) + }) + + test('deny access when authorizing for guests', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(null) + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isFalse(canView.authorized) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isFalse(canViewAll.authorized) + }) + + test('invoke action that allows guest users', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + @action({ allowGuest: true }) + view(_: User | null): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(null) + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isFalse(canViewAll.authorized) + }) + + test('throw error when policy method is not defined', async () => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + }) + + const postsPolicy = bouncer.with('PostPolicy') + + // @ts-expect-error + await postsPolicy.execute('foo') + }).throws('Cannot find method "foo" on "[class PostPolicy]"') + + test('construct policy using the container', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PermissionsResolver { + resolve() { + return ['can-view'] + } + } + + @inject() + class PostPolicy extends BasePolicy { + constructor(protected permissionsResolver: PermissionsResolver) { + super() + } + + view(_: User): AuthorizerResponse { + return this.permissionsResolver.resolve().includes('can-view') + } + + viewAll(_: User): AuthorizerResponse { + return this.permissionsResolver.resolve().includes('can-view-all') + } + } + + const bouncer = new Bouncer(new User()) + bouncer.setContainerResolver(new Container().createResolver()) + + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isFalse(canViewAll.authorized) + }) + + test('construct pre-registered policy using the container', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PermissionsResolver { + resolve() { + return ['can-view'] + } + } + + @inject() + class PostPolicy extends BasePolicy { + constructor(protected permissionsResolver: PermissionsResolver) { + super() + } + + view(_: User): AuthorizerResponse { + return this.permissionsResolver.resolve().includes('can-view') + } + + viewAll(_: User): AuthorizerResponse { + return this.permissionsResolver.resolve().includes('can-view-all') + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + }) + bouncer.setContainerResolver(new Container().createResolver()) + + const canView = await bouncer.with('PostPolicy').execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer.with('PostPolicy').execute('viewAll') + assert.isFalse(canViewAll.authorized) + }) + + test('check if a user is allowed or denied access', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + }) + + const postsPolicy = bouncer.with('PostPolicy') + + assert.isTrue(await postsPolicy.allows('view')) + assert.isFalse(await postsPolicy.allows('viewAll')) + + assert.isFalse(await postsPolicy.denies('view')) + assert.isTrue(await postsPolicy.denies('viewAll')) + }) + + test('authorize for an action', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + resolvePermissions() {} + + view(_: User): AuthorizerResponse { + return true + } + + viewAll(_: User): AuthorizerResponse { + return false + } + } + + const bouncer = new Bouncer(new User(), undefined, { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + }) + + const postsPolicy = bouncer.with('PostPolicy') + await assert.doesNotRejects(() => postsPolicy.authorize('view')) + await assert.rejects(() => postsPolicy.authorize('viewAll'), 'Access denied') + }) +}) diff --git a/tests/response.spec.ts b/tests/response.spec.ts new file mode 100644 index 0000000..62027e4 --- /dev/null +++ b/tests/response.spec.ts @@ -0,0 +1,37 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { AuthorizationResponse } from '../src/response.js' + +test.group('AuthorizationResponse', () => { + test('create denied response', ({ assert }) => { + const response = AuthorizationResponse.deny() + assert.isUndefined(response.message) + assert.isUndefined(response.status) + }) + + test('create denied response with custom status code and message', ({ assert }) => { + const response = AuthorizationResponse.deny('Post not found', 404) + assert.equal(response.message, 'Post not found') + assert.equal(response.status, 404) + }) + test('create denied response with translation', ({ assert }) => { + const response = AuthorizationResponse.deny('Post not found', 404).t('errors.not_found') + assert.equal(response.message, 'Post not found') + assert.equal(response.status, 404) + assert.deepEqual(response.translation, { identifier: 'errors.not_found', data: undefined }) + }) + + test('create allowed response', ({ assert }) => { + const response = AuthorizationResponse.allow() + assert.isUndefined(response.message) + assert.isUndefined(response.status) + }) +}) From 9e28719f1c749295779bc307bc1a964a2bf9e15c Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 12:03:26 +0530 Subject: [PATCH 03/27] feat: implement policies hooks --- src/bouncer.ts | 2 +- src/decorators/action.ts | 7 ++ src/policy_authorizer.ts | 53 +++++++- tests/bouncer/policies.spec.ts | 221 ++++++++++++++++++++++++++++++++- 4 files changed, 277 insertions(+), 6 deletions(-) diff --git a/src/bouncer.ts b/src/bouncer.ts index f4e4363..1e05475 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -269,7 +269,7 @@ export class Bouncer< /** * Authorize a user against a given ability * - * @throws {E_AUTHORIZATION_FAILURE} + * @throws {@link E_AUTHORIZATION_FAILURE} */ authorize>( ability: Ability, diff --git a/src/decorators/action.ts b/src/decorators/action.ts index 3bff0b0..b472b2c 100644 --- a/src/decorators/action.ts +++ b/src/decorators/action.ts @@ -19,3 +19,10 @@ export function action(options: { allowGuest: boolean }) { Policy.setActionMetaData(property, options) } } + +/** + * Allow guests on a policy action + */ +export function allowGuest() { + return action({ allowGuest: true }) +} diff --git a/src/policy_authorizer.ts b/src/policy_authorizer.ts index 285d0e3..4a330e3 100644 --- a/src/policy_authorizer.ts +++ b/src/policy_authorizer.ts @@ -126,6 +126,41 @@ export class PolicyAuthorizer< return this.#policy } + /** + * Executes the after hook on policy and handles various + * flows around using original or modified response. + */ + async #executeAfterHook( + policy: any, + action: any, + response: boolean | AuthorizationResponse, + args: any[] + ): Promise { + /** + * Return the action response when no after is defined + */ + if (typeof policy.after !== 'function') { + return this.#responseBuilder(response) + } + + const modifiedResponse = await policy.after(this.#user, action, response, ...args) + /** + * If modified response is a valid authorizer response, when use that + * modified response + */ + if ( + typeof modifiedResponse === 'boolean' || + modifiedResponse instanceof AuthorizationResponse + ) { + return this.#responseBuilder(modifiedResponse) + } + + /** + * Otherwise fallback to original response + */ + return this.#responseBuilder(response) + } + /** * Set a container resolver to use for resolving policies */ @@ -166,18 +201,30 @@ export class PolicyAuthorizer< ) } + /** + * Execute before hook and shortcircuit if before hook returns + * a valid authorizer response + */ + let hookResponse: unknown + if (typeof policyInstance.before === 'function') { + hookResponse = await policyInstance.before(this.#user, action, ...args) + } + if (typeof hookResponse === 'boolean' || hookResponse instanceof AuthorizationResponse) { + return this.#executeAfterHook(policyInstance, action, hookResponse, args) + } + /** * Disallow action for guest users */ if (this.#user === null && !this.#policyAllowsGuests(Policy, action as string)) { - return this.#responseBuilder(AuthorizationResponse.deny()) + return this.#executeAfterHook(policyInstance, action, AuthorizationResponse.deny(), args) } /** * Invoke action manually and normalize its response */ const response = await policyInstance[action](this.#user, ...args) - return this.#responseBuilder(response) + return this.#executeAfterHook(policyInstance, action, response, args) } /** @@ -217,7 +264,7 @@ export class PolicyAuthorizer< /** * Authorize a user against a given policy action * - * @throws {E_AUTHORIZATION_FAILURE} + * @throws {@link E_AUTHORIZATION_FAILURE} */ async authorize>>( action: Method, diff --git a/tests/bouncer/policies.spec.ts b/tests/bouncer/policies.spec.ts index 6bbfaf3..a85f3d3 100644 --- a/tests/bouncer/policies.spec.ts +++ b/tests/bouncer/policies.spec.ts @@ -13,8 +13,9 @@ import { Container } from '@adonisjs/core/container' import { Bouncer } from '../../src/bouncer.js' import { BasePolicy } from '../../src/base_policy.js' -import { action } from '../../src/decorators/action.js' +import { allowGuest } from '../../src/decorators/action.js' import type { AuthorizerResponse } from '../../src/types.js' +import { AuthorizationResponse } from '../../src/response.js' test.group('Bouncer | policies | types', () => { test('assert with method arguments with policy reference', async () => { @@ -666,7 +667,7 @@ test.group('Bouncer | policies', () => { class PostPolicy extends BasePolicy { resolvePermissions() {} - @action({ allowGuest: true }) + @allowGuest() view(_: User | null): AuthorizerResponse { return true } @@ -862,3 +863,219 @@ test.group('Bouncer | policies', () => { await assert.rejects(() => postsPolicy.authorize('viewAll'), 'Access denied') }) }) + +test.group('Bouncer | policies | before hook', () => { + test('execute action when hook returns undefined or null', async ({ assert }) => { + let actionsCounter = 0 + + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + before() { + return + } + + view(_: User): AuthorizerResponse { + actionsCounter++ + return true + } + + viewAll(_: User): AuthorizerResponse { + actionsCounter++ + return false + } + } + + const bouncer = new Bouncer(new User()) + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isFalse(canViewAll.authorized) + + assert.equal(actionsCounter, 2) + }) + + test('deny access when before hook returns false', async ({ assert }) => { + let actionsCounter = 0 + + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + before() { + return false + } + + view(_: User): AuthorizerResponse { + actionsCounter++ + return true + } + + viewAll(_: User): AuthorizerResponse { + actionsCounter++ + return false + } + } + + const bouncer = new Bouncer(new User()) + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isFalse(canView.authorized) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isFalse(canViewAll.authorized) + + assert.equal(actionsCounter, 0) + }) + + test('return custom response from before hook', async ({ assert }) => { + let actionsCounter = 0 + + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + before() { + return AuthorizationResponse.deny('Post not found', 404) + } + + view(_: User): AuthorizerResponse { + actionsCounter++ + return true + } + + viewAll(_: User): AuthorizerResponse { + actionsCounter++ + return false + } + } + + const bouncer = new Bouncer(new User()) + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isFalse(canView.authorized) + assert.equal(canView.message, 'Post not found') + assert.equal(canView.status, 404) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isFalse(canViewAll.authorized) + assert.equal(canViewAll.message, 'Post not found') + assert.equal(canViewAll.status, 404) + + assert.equal(actionsCounter, 0) + }) +}) + +test.group('Bouncer | policies | after hook', () => { + test('passthrough action response when hook returns undefined', async ({ assert }) => { + let actionsCounter = 0 + + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + after() { + return + } + + view(_: User): AuthorizerResponse { + actionsCounter++ + return true + } + + viewAll(_: User): AuthorizerResponse { + actionsCounter++ + return false + } + } + + const bouncer = new Bouncer(new User()) + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isFalse(canViewAll.authorized) + + assert.equal(actionsCounter, 2) + }) + + test('overwrite action response from after hook', async ({ assert }) => { + let actionsCounter = 0 + + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + after() { + return false + } + + view(_: User): AuthorizerResponse { + actionsCounter++ + return true + } + + viewAll(_: User): AuthorizerResponse { + actionsCounter++ + return false + } + } + + const bouncer = new Bouncer(new User()) + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isFalse(canView.authorized) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isFalse(canViewAll.authorized) + + assert.equal(actionsCounter, 2) + }) + + test('overwrite before hook response from after response', async ({ assert }) => { + let actionsCounter = 0 + + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + before() { + return AuthorizationResponse.deny('Post not found', 404) + } + + after() { + return AuthorizationResponse.allow() + } + + view(_: User): AuthorizerResponse { + actionsCounter++ + return true + } + + viewAll(_: User): AuthorizerResponse { + actionsCounter++ + return false + } + } + + const bouncer = new Bouncer(new User()) + const canView = await bouncer.with(PostPolicy).execute('view') + assert.isTrue(canView.authorized) + + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') + assert.isTrue(canViewAll.authorized) + + assert.equal(actionsCounter, 0) + }) +}) From 28ad3099b64877ba5cabcc8d19e1d29204c2b07e Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 12:58:23 +0530 Subject: [PATCH 04/27] feat: add events --- src/bouncer.ts | 62 +++++++++++++++++++++++++-------- src/policy_authorizer.ts | 44 ++++++++++++++++++++--- src/types.ts | 12 +++++++ tests/bouncer/abilities.spec.ts | 28 ++++++++++++--- tests/bouncer/policies.spec.ts | 18 ++++++++-- tests/helpers.ts | 18 ++++++++++ 6 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 tests/helpers.ts diff --git a/src/bouncer.ts b/src/bouncer.ts index 1e05475..2a8b4ba 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -8,6 +8,7 @@ */ import { inspect } from 'node:util' +import { Emitter } from '@adonisjs/core/events' import { RuntimeException } from '@poppinss/utils' import { type ContainerResolver } from '@adonisjs/core/container' @@ -24,6 +25,7 @@ import type { UnWrapLazyImport, AuthorizerResponse, NarrowAbilitiesForAUser, + AuthorizationEvents, } from './types.js' /** @@ -73,6 +75,11 @@ export class Bouncer< */ #containerResolver?: ContainerResolver + /** + * Emitter to emit events + */ + #emitter?: Emitter + constructor( userOrResolver: User | (() => User | null) | null, abilities?: Abilities, @@ -98,6 +105,23 @@ export class Bouncer< return this.#user } + /** + * Emits the event and sends normalized response + */ + #emitAndRespond(abilitiy: string, result: boolean | AuthorizationResponse, args: any[]) { + const response = Bouncer.responseBuilder(result) + if (this.#emitter) { + this.#emitter.emit('authorization:finished', { + user: this.#user, + action: abilitiy, + response, + parameters: args, + }) + } + + return response + } + /** * Returns an instance of PolicyAuthorizer. PolicyAuthorizer is * used to authorize user and actions using a given policy @@ -117,18 +141,14 @@ export class Bouncer< throw new RuntimeException(`Invalid bouncer policy "${inspect(policy)}"`) } - return new PolicyAuthorizer( - this.#getUser(), - this.#policies[policy], - Bouncer.responseBuilder - ).setContainerResolver(this.#containerResolver) + return new PolicyAuthorizer(this.#getUser(), this.#policies[policy], Bouncer.responseBuilder) + .setContainerResolver(this.#containerResolver) + .setEmitter(this.#emitter) } - return new PolicyAuthorizer( - this.#getUser(), - policy, - Bouncer.responseBuilder - ).setContainerResolver(this.#containerResolver) + return new PolicyAuthorizer(this.#getUser(), policy, Bouncer.responseBuilder) + .setContainerResolver(this.#containerResolver) + .setEmitter(this.#emitter) } /** @@ -139,6 +159,15 @@ export class Bouncer< return this } + /** + * Define the event emitter instance to use for emitting + * authorization events + */ + setEmitter(emitter?: Emitter): this { + this.#emitter = emitter + return this + } + /** * Execute an ability by reference */ @@ -169,8 +198,10 @@ export class Bouncer< */ if (this.#abilities && this.#abilities[ability]) { debug('executing pre-registered ability "%s"', ability) - return Bouncer.responseBuilder( - await this.#abilities[ability].execute(this.#getUser(), ...args) + return this.#emitAndRespond( + ability, + await this.#abilities[ability].execute(this.#getUser(), ...args), + args ) } @@ -187,8 +218,11 @@ export class Bouncer< if (debug.enabled) { debug('executing ability "%s"', ability.name) } - return Bouncer.responseBuilder( - await (ability as BouncerAbility).execute(this.#getUser(), ...args) + + return this.#emitAndRespond( + ability.original.name, + await (ability as BouncerAbility).execute(this.#getUser(), ...args), + args ) } diff --git a/src/policy_authorizer.ts b/src/policy_authorizer.ts index 4a330e3..3ec2cab 100644 --- a/src/policy_authorizer.ts +++ b/src/policy_authorizer.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import { Emitter } from '@adonisjs/core/events' import { RuntimeException } from '@poppinss/utils' import { ContainerResolver } from '@adonisjs/core/container' @@ -20,6 +21,7 @@ import type { ResponseBuilder, GetPolicyMethods, AuthorizerResponse, + AuthorizationEvents, } from './types.js' /** @@ -49,6 +51,11 @@ export class PolicyAuthorizer< */ #containerResolver?: ContainerResolver + /** + * Emitter to emit events + */ + #emitter?: Emitter + /** * Response builder is used to normalize bouncer responses */ @@ -126,6 +133,23 @@ export class PolicyAuthorizer< return this.#policy } + /** + * Emits the event and sends normalized response + */ + #emitAndRespond(action: any, result: boolean | AuthorizationResponse, args: any[]) { + const response = this.#responseBuilder(result) + if (this.#emitter) { + this.#emitter.emit('authorization:finished', { + user: this.#user, + action: `${this.#policy?.name}.${action}`, + response, + parameters: args, + }) + } + + return response + } + /** * Executes the after hook on policy and handles various * flows around using original or modified response. @@ -133,17 +157,18 @@ export class PolicyAuthorizer< async #executeAfterHook( policy: any, action: any, - response: boolean | AuthorizationResponse, + result: boolean | AuthorizationResponse, args: any[] ): Promise { /** * Return the action response when no after is defined */ if (typeof policy.after !== 'function') { - return this.#responseBuilder(response) + return this.#emitAndRespond(action, result, args) } - const modifiedResponse = await policy.after(this.#user, action, response, ...args) + const modifiedResponse = await policy.after(this.#user, action, result, ...args) + /** * If modified response is a valid authorizer response, when use that * modified response @@ -152,13 +177,13 @@ export class PolicyAuthorizer< typeof modifiedResponse === 'boolean' || modifiedResponse instanceof AuthorizationResponse ) { - return this.#responseBuilder(modifiedResponse) + return this.#emitAndRespond(action, modifiedResponse, args) } /** * Otherwise fallback to original response */ - return this.#responseBuilder(response) + return this.#emitAndRespond(action, result, args) } /** @@ -169,6 +194,15 @@ export class PolicyAuthorizer< return this } + /** + * Define the event emitter instance to use for emitting + * authorization events + */ + setEmitter(emitter?: Emitter): this { + this.#emitter = emitter + return this + } + /** * Execute an action from the list of pre-defined actions */ diff --git a/src/types.ts b/src/types.ts index 7c584b0..39ff57d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,3 +73,15 @@ export type BouncerAbility = { * an instanceof AuthorizationResponse */ export type ResponseBuilder = (response: boolean | AuthorizationResponse) => AuthorizationResponse + +/** + * Events emitted by bouncer + */ +export type AuthorizationEvents = { + 'authorization:finished': { + user: any + action?: string + parameters: any[] + response: AuthorizationResponse + } +} diff --git a/tests/bouncer/abilities.spec.ts b/tests/bouncer/abilities.spec.ts index 8837991..ff7616d 100644 --- a/tests/bouncer/abilities.spec.ts +++ b/tests/bouncer/abilities.spec.ts @@ -9,6 +9,7 @@ import { test } from '@japa/runner' +import { createEmitter } from '../helpers.js' import { Bouncer } from '../../src/bouncer.js' import { AuthorizationResponse } from '../../src/response.js' @@ -164,33 +165,52 @@ test.group('Bouncer | actions | types', () => { }) test.group('Bouncer | actions', () => { - test('execute action by reference', async ({ assert }) => { + test('execute action by reference', async ({ assert }, done) => { class User { declare id: number declare email: string } + const emitter = createEmitter() const editPost = Bouncer.define((_: User) => false) const bouncer = new Bouncer(new User()) + bouncer.setEmitter(emitter) + + emitter.on('authorization:finished', (event) => { + assert.instanceOf(event.user, User) + assert.deepEqual(event.parameters, []) + assert.instanceOf(event.response, AuthorizationResponse) + done() + }) const response = await bouncer.execute(editPost) assert.instanceOf(response, AuthorizationResponse) assert.equal(response.authorized, false) - }) + }).waitForDone() - test('execute action from pre-defined list', async ({ assert }) => { + test('execute action from pre-defined list', async ({ assert }, done) => { class User { declare id: number declare email: string } + const emitter = createEmitter() const editPost = Bouncer.define((_: User) => false) const bouncer = new Bouncer(new User(), { editPost }) + bouncer.setEmitter(emitter) + + emitter.on('authorization:finished', (event) => { + assert.instanceOf(event.user, User) + assert.equal(event.action, 'editPost') + assert.deepEqual(event.parameters, []) + assert.instanceOf(event.response, AuthorizationResponse) + done() + }) const response = await bouncer.execute('editPost') assert.instanceOf(response, AuthorizationResponse) assert.equal(response.authorized, false) - }) + }).waitForDone() test('pass arguments to the action', async ({ assert }) => { class User { diff --git a/tests/bouncer/policies.spec.ts b/tests/bouncer/policies.spec.ts index a85f3d3..882065a 100644 --- a/tests/bouncer/policies.spec.ts +++ b/tests/bouncer/policies.spec.ts @@ -16,6 +16,7 @@ import { BasePolicy } from '../../src/base_policy.js' import { allowGuest } from '../../src/decorators/action.js' import type { AuthorizerResponse } from '../../src/types.js' import { AuthorizationResponse } from '../../src/response.js' +import { createEmitter } from '../helpers.js' test.group('Bouncer | policies | types', () => { test('assert with method arguments with policy reference', async () => { @@ -445,7 +446,7 @@ test.group('Bouncer | policies | types', () => { }) test.group('Bouncer | policies', () => { - test('execute policy action', async ({ assert }) => { + test('execute policy action', async ({ assert }, done) => { class User { declare id: number declare email: string @@ -463,13 +464,26 @@ test.group('Bouncer | policies', () => { } } + const emitter = createEmitter() const bouncer = new Bouncer(new User()) + bouncer.setEmitter(emitter) + + emitter.on('authorization:finished', (event) => { + assert.instanceOf(event.user, User) + assert.equal(event.action, 'PostPolicy.view') + assert.deepEqual(event.parameters, []) + assert.instanceOf(event.response, AuthorizationResponse) + done() + }) + const canView = await bouncer.with(PostPolicy).execute('view') assert.isTrue(canView.authorized) + bouncer.setEmitter(undefined) + const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') assert.isFalse(canViewAll.authorized) - }) + }).waitForDone() test('execute policy action on a pre-registered policy', async ({ assert }) => { class User { diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..bff25a9 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,18 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Emitter } from '@adonisjs/core/events' +import { AppFactory } from '@adonisjs/core/factories/app' +import type { AuthorizationEvents } from '../src/types.js' + +const BASE_URL = new URL('./tmp', import.meta.url) + +export const createEmitter = () => { + return new Emitter(new AppFactory().create(BASE_URL, () => {})) +} From 506697dea31bd58666ed45a66e5e55842983ff7a Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 14:27:12 +0530 Subject: [PATCH 05/27] feat: add edge plugin for registering tags --- bin/test.ts | 3 +- package.json | 2 + src/bouncer.ts | 32 ++++ src/plugins/edge.ts | 97 ++++++++++ tests/plugins/edge.spec.ts | 375 +++++++++++++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 src/plugins/edge.ts create mode 100644 tests/plugins/edge.spec.ts diff --git a/bin/test.ts b/bin/test.ts index 1f3394e..5213bde 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -1,12 +1,13 @@ import 'reflect-metadata' import { assert } from '@japa/assert' +import { snapshot } from '@japa/snapshot' import { expectTypeOf } from '@japa/expect-type' import { configure, processCLIArgs, run } from '@japa/runner' processCLIArgs(process.argv.splice(2)) configure({ files: ['tests/**/*.spec.ts'], - plugins: [assert(), expectTypeOf()], + plugins: [assert(), expectTypeOf(), snapshot()], }) run() diff --git a/package.json b/package.json index f803add..0d19f89 100644 --- a/package.json +++ b/package.json @@ -60,11 +60,13 @@ "@japa/assert": "^2.0.0-2", "@japa/expect-type": "^2.0.0-1", "@japa/runner": "^3.0.0-9", + "@japa/snapshot": "^2.0.3", "@swc/core": "^1.3.100", "c8": "^8.0.1", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "del-cli": "^5.1.0", + "edge.js": "^6.0.0", "github-label-sync": "^2.3.1", "husky": "^8.0.3", "np": "^9.0.0", diff --git a/src/bouncer.ts b/src/bouncer.ts index 2a8b4ba..036a3aa 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -319,4 +319,36 @@ export class Bouncer< throw new E_AUTHORIZATION_FAILURE(response) } } + + /** + * Returns an object with untyped API to perform authorization + * checks within edge templates + */ + edgeHelpers(): { + bouncer: { + parent: Bouncer + can(action: string, ...args: any[]): Promise + cannot(action: string, ...args: any[]): Promise + } + } { + return { + bouncer: { + parent: this, + can(action: string, ...args: any[]) { + const [policyName, ...policyMethods] = action.split('.') + if (policyMethods.length) { + return this.parent.with(policyName as any).allows(policyMethods.join('.'), ...args) + } + return this.parent.allows(policyName as any, ...args) + }, + cannot(action: string, ...args: any[]) { + const [policyName, ...policyMethods] = action.split('.') + if (policyMethods.length) { + return this.parent.with(policyName as any).denies(policyMethods.join('.'), ...args) + } + return this.parent.denies(policyName as any, ...args) + }, + }, + } + } } diff --git a/src/plugins/edge.ts b/src/plugins/edge.ts new file mode 100644 index 0000000..c366391 --- /dev/null +++ b/src/plugins/edge.ts @@ -0,0 +1,97 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { PluginFn } from 'edge.js/types' +import debug from '../debug.js' + +/** + * The edge plugin for Bouncer to perform authorization checks + * within templates. + */ +export const edgePluginBouncer: PluginFn = (edge) => { + debug('registering bouncer tags with edge') + + edge.registerTag({ + tagName: 'can', + seekable: true, + block: true, + compile(parser, buffer, token) { + const expression = parser.utils.transformAst( + parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), + token.filename, + parser + ) + + const openingBrace = expression.type !== 'SequenceExpression' ? '(' : '' + const closingBrace = expression.type !== 'SequenceExpression' ? ')' : '' + const parameters = parser.utils.stringify(expression) + const methodCall = `can${openingBrace}${parameters}${closingBrace}` + + /** + * Write an if statement + */ + buffer.writeStatement( + `if (await state.bouncer.${methodCall}) {`, + token.filename, + token.loc.start.line + ) + + /** + * Process component children using the parser + */ + token.children.forEach((child) => { + parser.processToken(child, buffer) + }) + + /** + * Close if statement + */ + buffer.writeStatement(`}`, token.filename, token.loc.start.line) + }, + }) + + edge.registerTag({ + tagName: 'cannot', + seekable: true, + block: true, + compile(parser, buffer, token) { + const expression = parser.utils.transformAst( + parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), + token.filename, + parser + ) + + const openingBrace = expression.type !== 'SequenceExpression' ? '(' : '' + const closingBrace = expression.type !== 'SequenceExpression' ? ')' : '' + const parameters = parser.utils.stringify(expression) + const methodCall = `cannot${openingBrace}${parameters}${closingBrace}` + + /** + * Write an if statement + */ + buffer.writeStatement( + `if (await state.bouncer.${methodCall}) {`, + token.filename, + token.loc.start.line + ) + + /** + * Process component children using the parser + */ + token.children.forEach((child) => { + parser.processToken(child, buffer) + }) + + /** + * Close if statement + */ + buffer.writeStatement(`}`, token.filename, token.loc.start.line) + }, + }) +} diff --git a/tests/plugins/edge.spec.ts b/tests/plugins/edge.spec.ts new file mode 100644 index 0000000..94ccb3f --- /dev/null +++ b/tests/plugins/edge.spec.ts @@ -0,0 +1,375 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Edge } from 'edge.js' +import { test } from '@japa/runner' +import { Bouncer } from '../../src/bouncer.js' +import { edgePluginBouncer } from '../../src/plugins/edge.js' +import { BasePolicy } from '../../src/base_policy.js' + +test.group('Edge plugin | compile', () => { + test('assert @can tag compiled output', async ({ assert }) => { + const edge = new Edge() + edge.use(edgePluginBouncer) + edge.createRenderer() + + const output = edge.asyncCompiler.compileRaw( + `@can('editPost', post) + Can edit post + @else + Cannot edit post + @end + ` + ) + + assert.deepEqual(output.toString().split('\n'), [ + `async function anonymous(template,state,$context`, + `) {`, + `let out = "";`, + `let $lineNumber = 1;`, + `let $filename = "eval.edge";`, + `try {`, + `if (await state.bouncer.can('editPost', state.post)) {`, + `out += "\\n";`, + `out += " Can edit post";`, + `} else {`, + `out += "\\n";`, + `out += " Cannot edit post";`, + `}`, + `out += "\\n";`, + `out += " ";`, + `} catch (error) {`, + `template.reThrow(error, $filename, $lineNumber);`, + `}`, + `return out;`, + `}`, + ]) + }) + + test('assert @cannot tag compiled output', async ({ assert }) => { + const edge = new Edge() + edge.use(edgePluginBouncer) + edge.createRenderer() + + const output = edge.asyncCompiler.compileRaw( + `@cannot('editPost', post) + Cannot edit post + @end + ` + ) + + assert.deepEqual(output.toString().split('\n'), [ + `async function anonymous(template,state,$context`, + `) {`, + `let out = "";`, + `let $lineNumber = 1;`, + `let $filename = "eval.edge";`, + `try {`, + `if (await state.bouncer.cannot('editPost', state.post)) {`, + `out += "\\n";`, + `out += " Cannot edit post";`, + `}`, + `out += "\\n";`, + `out += " ";`, + `} catch (error) {`, + `template.reThrow(error, $filename, $lineNumber);`, + `}`, + `return out;`, + `}`, + ]) + }) +}) + +test.group('Edge plugin | abilities', () => { + test('use @can tag to authorize', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(new User(), { editPost }) + + const edge = new Edge() + edge.use(edgePluginBouncer) + + const text = await edge.share(bouncer.edgeHelpers()).renderRaw(` + @can('editPost') + Can edit post + @else + Cannot edit post + @end + `) + + assert.equal(text.trim(), 'Cannot edit post') + }) + + test('use @cannot tag to authorize', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + const editPost = Bouncer.define((_: User) => false) + const bouncer = new Bouncer(new User(), { editPost }) + + const edge = new Edge() + edge.use(edgePluginBouncer) + + const text = await edge.share(bouncer.edgeHelpers()).renderRaw(` + @cannot('editPost') + Cannot edit post + @else + Can edit post + @end + `) + + assert.equal(text.trim(), 'Cannot edit post') + }) + + test('pass additional params via @can tag', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class Post { + constructor(public userId: number) {} + } + + const editPost = Bouncer.define((user: User, post: Post) => user.id === post.userId) + const bouncer = new Bouncer(new User(1), { editPost }) + + const edge = new Edge() + edge.use(edgePluginBouncer) + + const text = await edge.share(bouncer.edgeHelpers()).renderRaw( + ` + @can('editPost', posts[0]) + Can edit post 1 + @end + @can('editPost', posts[1]) + Can edit post 2 + @end + `, + { + posts: [new Post(1), new Post(2)], + } + ) + + assert.equal(text.trim(), 'Can edit post 1') + }) + + test('pass additional params via @cannot tag', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class Post { + constructor(public userId: number) {} + } + + const editPost = Bouncer.define((user: User, post: Post) => user.id === post.userId) + const bouncer = new Bouncer(new User(1), { editPost }) + + const edge = new Edge() + edge.use(edgePluginBouncer) + + const text = await edge.share(bouncer.edgeHelpers()).renderRaw( + ` + @cannot('editPost', posts[0]) + Cannot edit post 1 + @end + @cannot('editPost', posts[1]) + Cannot edit post 2 + @end + `, + { + posts: [new Post(1), new Post(2)], + } + ) + + assert.equal(text.trim(), 'Cannot edit post 2') + }) +}) + +test.group('Edge plugin | policies', () => { + test('use @can tag to authorize', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + edit(_: User) { + return false + } + } + + const bouncer = new Bouncer( + new User(), + {}, + { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + } + ) + + const edge = new Edge() + edge.use(edgePluginBouncer) + + const text = await edge.share(bouncer.edgeHelpers()).renderRaw(` + @can('PostPolicy.edit') + Can edit post + @else + Cannot edit post + @end + `) + + assert.equal(text.trim(), 'Cannot edit post') + }) + + test('use @cannot tag to authorize', async ({ assert }) => { + class User { + declare id: number + declare email: string + } + + class PostPolicy extends BasePolicy { + edit(_: User) { + return false + } + } + + const bouncer = new Bouncer( + new User(), + {}, + { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + } + ) + + const edge = new Edge() + edge.use(edgePluginBouncer) + + const text = await edge.share(bouncer.edgeHelpers()).renderRaw(` + @cannot('PostPolicy.edit') + Cannot edit post + @else + Can edit post + @end + `) + + assert.equal(text.trim(), 'Cannot edit post') + }) + + test('pass additional params via @can tag', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class Post { + constructor(public userId: number) {} + } + + class PostPolicy extends BasePolicy { + edit(user: User, post: Post) { + return user.id === post.userId + } + } + + const bouncer = new Bouncer( + new User(1), + {}, + { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + } + ) + + const edge = new Edge() + edge.use(edgePluginBouncer) + + const text = await edge.share(bouncer.edgeHelpers()).renderRaw( + ` + @can('PostPolicy.edit', posts[0]) + Can edit post 1 + @end + @can('PostPolicy.edit', posts[1]) + Can edit post 2 + @end + `, + { + posts: [new Post(1), new Post(2)], + } + ) + + assert.equal(text.trim(), 'Can edit post 1') + }) + + test('pass additional params via @cannot tag', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + + class Post { + constructor(public userId: number) {} + } + + class PostPolicy extends BasePolicy { + edit(user: User, post: Post) { + return user.id === post.userId + } + } + + const bouncer = new Bouncer( + new User(1), + {}, + { + PostPolicy: async () => { + return { + default: PostPolicy, + } + }, + } + ) + + const edge = new Edge() + edge.use(edgePluginBouncer) + + const text = await edge.share(bouncer.edgeHelpers()).renderRaw( + ` + @cannot('PostPolicy.edit', posts[0]) + Cannot edit post 1 + @end + @cannot('PostPolicy.edit', posts[1]) + Cannot edit post 2 + @end + `, + { + posts: [new Post(1), new Post(2)], + } + ) + + assert.equal(text.trim(), 'Cannot edit post 2') + }) +}) From 5eff877991bee8fe849a455c8738d60302ae828d Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 14:54:26 +0530 Subject: [PATCH 06/27] feat: add provider --- index.ts | 14 ++++++++++++ package.json | 4 +++- providers/bouncer_provider.ts | 34 ++++++++++++++++++++++++++++++ src/bouncer.ts | 30 +++++++++----------------- src/policy_authorizer.ts | 8 +++---- src/types.ts | 8 +++++++ stubs/init_bouncer_middleware.stub | 30 ++++++++++++++++++++++++++ tests/bouncer/abilities.spec.ts | 10 +++++---- tests/bouncer/policies.spec.ts | 9 ++++---- 9 files changed, 113 insertions(+), 34 deletions(-) create mode 100644 index.ts create mode 100644 providers/bouncer_provider.ts create mode 100644 stubs/init_bouncer_middleware.stub diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..07ad703 --- /dev/null +++ b/index.ts @@ -0,0 +1,14 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export * as errors from './src/errors.js' +export { Bouncer } from './src/bouncer.js' +export { BasePolicy } from './src/base_policy.js' +export { AuthorizationResponse } from './src/response.js' +export { action, allowGuest } from './src/decorators/action.js' diff --git a/package.json b/package.json index 0d19f89..4dfdb04 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ ], "exports": { ".": "./build/index.js", - "./types": "./build/src/types.js" + "./types": "./build/src/types.js", + "./bouncer_provider": "./build/providers/bouncer_provider.js", + "./plugins/edge": "./build/src/plugins/edge.js" }, "scripts": { "pretest": "npm run lint", diff --git a/providers/bouncer_provider.ts b/providers/bouncer_provider.ts new file mode 100644 index 0000000..dafa34e --- /dev/null +++ b/providers/bouncer_provider.ts @@ -0,0 +1,34 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { ApplicationService } from '@adonisjs/core/types' + +import { Bouncer } from '../src/bouncer.js' +import type { AuthorizationEvents } from '../src/types.js' + +declare module '@adonisjs/core/types' { + export interface EventsList extends AuthorizationEvents {} +} + +/** + * Register edge tags and shares the app emitter with Bouncer + */ +export default class BouncerProvider { + constructor(protected app: ApplicationService) {} + + async boot() { + if (this.app.usingEdgeJS) { + const edge = await import('edge.js') + const { edgePluginBouncer } = await import('../src/plugins/edge.js') + edge.default.use(edgePluginBouncer) + } + + Bouncer.emitter = await this.app.container.make('emitter') + } +} diff --git a/src/bouncer.ts b/src/bouncer.ts index 036a3aa..6303ca9 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -8,7 +8,6 @@ */ import { inspect } from 'node:util' -import { Emitter } from '@adonisjs/core/events' import { RuntimeException } from '@poppinss/utils' import { type ContainerResolver } from '@adonisjs/core/container' @@ -24,8 +23,8 @@ import type { ResponseBuilder, UnWrapLazyImport, AuthorizerResponse, + AuthorizationEmitter, NarrowAbilitiesForAUser, - AuthorizationEvents, } from './types.js' /** @@ -44,6 +43,11 @@ export class Bouncer< return typeof response === 'boolean' ? new AuthorizationResponse(response) : response } + /** + * Emitter to emit events + */ + static emitter?: AuthorizationEmitter + /** * Define a bouncer ability from a callback */ @@ -75,11 +79,6 @@ export class Bouncer< */ #containerResolver?: ContainerResolver - /** - * Emitter to emit events - */ - #emitter?: Emitter - constructor( userOrResolver: User | (() => User | null) | null, abilities?: Abilities, @@ -110,8 +109,8 @@ export class Bouncer< */ #emitAndRespond(abilitiy: string, result: boolean | AuthorizationResponse, args: any[]) { const response = Bouncer.responseBuilder(result) - if (this.#emitter) { - this.#emitter.emit('authorization:finished', { + if (Bouncer.emitter) { + Bouncer.emitter.emit('authorization:finished', { user: this.#user, action: abilitiy, response, @@ -143,12 +142,12 @@ export class Bouncer< return new PolicyAuthorizer(this.#getUser(), this.#policies[policy], Bouncer.responseBuilder) .setContainerResolver(this.#containerResolver) - .setEmitter(this.#emitter) + .setEmitter(Bouncer.emitter) } return new PolicyAuthorizer(this.#getUser(), policy, Bouncer.responseBuilder) .setContainerResolver(this.#containerResolver) - .setEmitter(this.#emitter) + .setEmitter(Bouncer.emitter) } /** @@ -159,15 +158,6 @@ export class Bouncer< return this } - /** - * Define the event emitter instance to use for emitting - * authorization events - */ - setEmitter(emitter?: Emitter): this { - this.#emitter = emitter - return this - } - /** * Execute an ability by reference */ diff --git a/src/policy_authorizer.ts b/src/policy_authorizer.ts index 3ec2cab..88065b8 100644 --- a/src/policy_authorizer.ts +++ b/src/policy_authorizer.ts @@ -6,8 +6,6 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - -import { Emitter } from '@adonisjs/core/events' import { RuntimeException } from '@poppinss/utils' import { ContainerResolver } from '@adonisjs/core/container' @@ -21,7 +19,7 @@ import type { ResponseBuilder, GetPolicyMethods, AuthorizerResponse, - AuthorizationEvents, + AuthorizationEmitter, } from './types.js' /** @@ -54,7 +52,7 @@ export class PolicyAuthorizer< /** * Emitter to emit events */ - #emitter?: Emitter + #emitter?: AuthorizationEmitter /** * Response builder is used to normalize bouncer responses @@ -198,7 +196,7 @@ export class PolicyAuthorizer< * Define the event emitter instance to use for emitting * authorization events */ - setEmitter(emitter?: Emitter): this { + setEmitter(emitter?: AuthorizationEmitter): this { this.#emitter = emitter return this } diff --git a/src/types.ts b/src/types.ts index 39ff57d..610bf9f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,3 +85,11 @@ export type AuthorizationEvents = { response: AuthorizationResponse } } + +/** + * The emitter accepted by bouncer for emit authorization + * events + */ +export type AuthorizationEmitter = { + emit(key: 'authorization:finished', event: AuthorizationEvents['authorization:finished']): any +} diff --git a/stubs/init_bouncer_middleware.stub b/stubs/init_bouncer_middleware.stub new file mode 100644 index 0000000..cf35e0c --- /dev/null +++ b/stubs/init_bouncer_middleware.stub @@ -0,0 +1,30 @@ +{{#var middlewareName = generators.middlewareName(entity.name)}} +{{#var middlewareFileName = generators.middlewareFileName(entity.name)}} +{{{ + exports({ to: app.middlewarePath(entity.path, middlewareFileName) }) +}}} +import { policies } from '#policies/main' +import * as abilities from '#abilities/main' +import { Bouncer } from '@adonisjs/bouncer' +import emitter from '@adonisjs/core/services/emitter' + +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * Init bouncer middleware is used to create a bouncer instance + * during an HTTP request + */ +export default class {{ middlewareName }} { + async handle( + ctx: HttpContext, + next: NextFn, + ) { + const bouncer = Bouncer + .create(() => ctx.auth.user, abilities, policies) + .setContainerResolver(ctx.containerResolver) + .setEmitter(emitter) + + return next() + } +} diff --git a/tests/bouncer/abilities.spec.ts b/tests/bouncer/abilities.spec.ts index ff7616d..d0cbe7a 100644 --- a/tests/bouncer/abilities.spec.ts +++ b/tests/bouncer/abilities.spec.ts @@ -165,7 +165,7 @@ test.group('Bouncer | actions | types', () => { }) test.group('Bouncer | actions', () => { - test('execute action by reference', async ({ assert }, done) => { + test('execute action by reference', async ({ assert, cleanup }, done) => { class User { declare id: number declare email: string @@ -174,7 +174,8 @@ test.group('Bouncer | actions', () => { const emitter = createEmitter() const editPost = Bouncer.define((_: User) => false) const bouncer = new Bouncer(new User()) - bouncer.setEmitter(emitter) + Bouncer.emitter = emitter + cleanup(() => (Bouncer.emitter = undefined)) emitter.on('authorization:finished', (event) => { assert.instanceOf(event.user, User) @@ -188,7 +189,7 @@ test.group('Bouncer | actions', () => { assert.equal(response.authorized, false) }).waitForDone() - test('execute action from pre-defined list', async ({ assert }, done) => { + test('execute action from pre-defined list', async ({ assert, cleanup }, done) => { class User { declare id: number declare email: string @@ -197,7 +198,8 @@ test.group('Bouncer | actions', () => { const emitter = createEmitter() const editPost = Bouncer.define((_: User) => false) const bouncer = new Bouncer(new User(), { editPost }) - bouncer.setEmitter(emitter) + Bouncer.emitter = emitter + cleanup(() => (Bouncer.emitter = undefined)) emitter.on('authorization:finished', (event) => { assert.instanceOf(event.user, User) diff --git a/tests/bouncer/policies.spec.ts b/tests/bouncer/policies.spec.ts index 882065a..9d33992 100644 --- a/tests/bouncer/policies.spec.ts +++ b/tests/bouncer/policies.spec.ts @@ -11,12 +11,12 @@ import { test } from '@japa/runner' import { inject } from '@adonisjs/core' import { Container } from '@adonisjs/core/container' +import { createEmitter } from '../helpers.js' import { Bouncer } from '../../src/bouncer.js' import { BasePolicy } from '../../src/base_policy.js' import { allowGuest } from '../../src/decorators/action.js' import type { AuthorizerResponse } from '../../src/types.js' import { AuthorizationResponse } from '../../src/response.js' -import { createEmitter } from '../helpers.js' test.group('Bouncer | policies | types', () => { test('assert with method arguments with policy reference', async () => { @@ -446,7 +446,7 @@ test.group('Bouncer | policies | types', () => { }) test.group('Bouncer | policies', () => { - test('execute policy action', async ({ assert }, done) => { + test('execute policy action', async ({ assert, cleanup }, done) => { class User { declare id: number declare email: string @@ -466,7 +466,8 @@ test.group('Bouncer | policies', () => { const emitter = createEmitter() const bouncer = new Bouncer(new User()) - bouncer.setEmitter(emitter) + Bouncer.emitter = emitter + cleanup(() => (Bouncer.emitter = undefined)) emitter.on('authorization:finished', (event) => { assert.instanceOf(event.user, User) @@ -479,7 +480,7 @@ test.group('Bouncer | policies', () => { const canView = await bouncer.with(PostPolicy).execute('view') assert.isTrue(canView.authorized) - bouncer.setEmitter(undefined) + Bouncer.emitter = undefined const canViewAll = await bouncer.with(PostPolicy).execute('viewAll') assert.isFalse(canViewAll.authorized) From ed989c2ee87512c8bea2b199b8cad50bac29df0b Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 15:02:42 +0530 Subject: [PATCH 07/27] feat: add configure command --- configure.ts | 36 +++++++++++++++++++ index.ts | 2 ++ package.json | 27 ++++++++++---- ...tub => initialize_bouncer_middleware.stub} | 0 stubs/main.ts | 12 +++++++ 5 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 configure.ts rename stubs/{init_bouncer_middleware.stub => initialize_bouncer_middleware.stub} (100%) create mode 100644 stubs/main.ts diff --git a/configure.ts b/configure.ts new file mode 100644 index 0000000..311bec2 --- /dev/null +++ b/configure.ts @@ -0,0 +1,36 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type Configure from '@adonisjs/core/commands/configure' + +/** + * Configures the package + */ +export async function configure(command: Configure) { + const codemods = await command.createCodemods() + + /** + * Register provider + */ + await codemods.updateRcFile((rcFile) => { + rcFile.addProvider('@adonisjs/bouncer/bouncer_provider') + }) + + /** + * Publish and register middleware + */ + await command.publishStub('initialize_bouncer_middleware.stub', { + entity: command.app.generators.createEntity('initialize_bouncer'), + }) + await codemods.registerMiddleware('router', [ + { + path: '#middleware/initialize_bouncer_middleware', + }, + ]) +} diff --git a/index.ts b/index.ts index 07ad703..8d36c16 100644 --- a/index.ts +++ b/index.ts @@ -9,6 +9,8 @@ export * as errors from './src/errors.js' export { Bouncer } from './src/bouncer.js' +export { configure } from './configure.js' +export { stubsRoot } from './stubs/main.js' export { BasePolicy } from './src/base_policy.js' export { AuthorizationResponse } from './src/response.js' export { action, allowGuest } from './src/decorators/action.js' diff --git a/package.json b/package.json index 4dfdb04..6040443 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "files": [ "build", "!build/bin", - "!build/tests", - "!build/tests_helpers" + "!build/tests" ], "exports": { ".": "./build/index.js", @@ -52,6 +51,7 @@ "adonisjs" ], "devDependencies": { + "@adonisjs/assembler": "^6.1.3-29", "@adonisjs/core": "^6.1.5-33", "@adonisjs/eslint-config": "^1.2.0", "@adonisjs/i18n": "^2.0.0-8", @@ -77,6 +77,21 @@ "tsup": "^8.0.1", "typescript": "5.2.2" }, + "dependencies": { + "@poppinss/utils": "^6.6.0" + }, + "peerDependencies": { + "@adonisjs/core": "^6.1.5-33", + "@adonisjs/i18n": "^2.0.0-8" + }, + "peerDependenciesMeta": { + "@adonisjs/core": { + "optional": true + }, + "@adonisjs/i18n": { + "optional": true + } + }, "eslintConfig": { "extends": "@adonisjs/eslint-config/package" }, @@ -107,7 +122,9 @@ }, "tsup": { "entry": [ - "./index.ts" + "./index.ts", + "./providers/bouncer_provider.ts", + "./src/plugins/edge.ts" ], "outDir": "./build", "clean": true, @@ -115,9 +132,5 @@ "dts": false, "sourcemap": true, "target": "esnext" - }, - "dependencies": { - "@poppinss/hooks": "^7.2.1", - "@poppinss/utils": "^6.6.0" } } diff --git a/stubs/init_bouncer_middleware.stub b/stubs/initialize_bouncer_middleware.stub similarity index 100% rename from stubs/init_bouncer_middleware.stub rename to stubs/initialize_bouncer_middleware.stub diff --git a/stubs/main.ts b/stubs/main.ts new file mode 100644 index 0000000..0c7a922 --- /dev/null +++ b/stubs/main.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { getDirname } from '@poppinss/utils' + +export const stubsRoot = getDirname(import.meta.url) From 6e45c386ef96462a933b500da712d6447360b8b9 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 15:11:22 +0530 Subject: [PATCH 08/27] refactor: make bouncer.policies and bouncer.abilities public --- src/bouncer.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/bouncer.ts b/src/bouncer.ts index 6303ca9..38f025e 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -66,12 +66,12 @@ export class Bouncer< /** * Pre-defined abilities */ - #abilities?: Abilities + abilities?: Abilities /** * Pre-defined policies */ - #policies?: Policies + policies?: Policies /** * Reference to the container resolver to construct @@ -85,8 +85,8 @@ export class Bouncer< policies?: Policies ) { this.#userOrResolver = userOrResolver - this.#abilities = abilities - this.#policies = policies + this.abilities = abilities + this.policies = policies } /** @@ -136,11 +136,11 @@ export class Bouncer< /** * Ensure the policy is pre-registered */ - if (!this.#policies || !this.#policies[policy]) { + if (!this.policies || !this.policies[policy]) { throw new RuntimeException(`Invalid bouncer policy "${inspect(policy)}"`) } - return new PolicyAuthorizer(this.#getUser(), this.#policies[policy], Bouncer.responseBuilder) + return new PolicyAuthorizer(this.#getUser(), this.policies[policy], Bouncer.responseBuilder) .setContainerResolver(this.#containerResolver) .setEmitter(Bouncer.emitter) } @@ -186,11 +186,11 @@ export class Bouncer< /** * Executing ability from a pre-defined list of abilities */ - if (this.#abilities && this.#abilities[ability]) { + if (this.abilities && this.abilities[ability]) { debug('executing pre-registered ability "%s"', ability) return this.#emitAndRespond( ability, - await this.#abilities[ability].execute(this.#getUser(), ...args), + await this.abilities[ability].execute(this.#getUser(), ...args), args ) } From fe350be627eeb961ac8b658b9ef2cff232d44e75 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 15:56:45 +0530 Subject: [PATCH 09/27] refactor: rename Bouncer.define to Bouncer.ability --- src/bouncer.ts | 2 +- tests/bouncer/abilities.spec.ts | 46 ++++++++++++++++----------------- tests/plugins/edge.spec.ts | 8 +++--- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/bouncer.ts b/src/bouncer.ts index 38f025e..3798b2e 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -51,7 +51,7 @@ export class Bouncer< /** * Define a bouncer ability from a callback */ - static define = createAbility + static ability = createAbility /** * User resolver to lazily resolve the user diff --git a/tests/bouncer/abilities.spec.ts b/tests/bouncer/abilities.spec.ts index d0cbe7a..968bcd4 100644 --- a/tests/bouncer/abilities.spec.ts +++ b/tests/bouncer/abilities.spec.ts @@ -23,8 +23,8 @@ test.group('Bouncer | actions | types', () => { declare adminId: number } - const editPost = Bouncer.define((_: User) => false) - const editStaff = Bouncer.define((_: Admin) => false) + const editPost = Bouncer.ability((_: User) => false) + const editStaff = Bouncer.ability((_: Admin) => false) const bouncer = new Bouncer(new User()) await bouncer.execute(editPost) @@ -53,8 +53,8 @@ test.group('Bouncer | actions | types', () => { declare adminId: number } - const editPost = Bouncer.define((_: User) => false) - const editStaff = Bouncer.define((_: Admin) => false) + const editPost = Bouncer.ability((_: User) => false) + const editStaff = Bouncer.ability((_: Admin) => false) const bouncer = new Bouncer(new User(), { editPost, editStaff }) await bouncer.execute('editPost') @@ -80,7 +80,7 @@ test.group('Bouncer | actions | types', () => { declare title: string } - const editPost = Bouncer.define((_: User, __: Post) => { + const editPost = Bouncer.ability((_: User, __: Post) => { return false }) const bouncer = new Bouncer(new User(), { editPost }) @@ -112,8 +112,8 @@ test.group('Bouncer | actions | types', () => { declare adminId: number } - const editPost = Bouncer.define((_: User | Admin) => false) - const editStaff = Bouncer.define((_: User | Admin) => false) + const editPost = Bouncer.ability((_: User | Admin) => false) + const editStaff = Bouncer.ability((_: User | Admin) => false) const bouncer = new Bouncer(new User()) const bouncer1 = new Bouncer(new User()) @@ -147,8 +147,8 @@ test.group('Bouncer | actions | types', () => { declare adminId: number } - const editPost = Bouncer.define((_: User | Admin) => false) - const editStaff = Bouncer.define((_: User | Admin) => false) + const editPost = Bouncer.ability((_: User | Admin) => false) + const editStaff = Bouncer.ability((_: User | Admin) => false) const actions = { editPost, editStaff } const bouncer = new Bouncer(new User(), actions) @@ -172,7 +172,7 @@ test.group('Bouncer | actions', () => { } const emitter = createEmitter() - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(new User()) Bouncer.emitter = emitter cleanup(() => (Bouncer.emitter = undefined)) @@ -196,7 +196,7 @@ test.group('Bouncer | actions', () => { } const emitter = createEmitter() - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(new User(), { editPost }) Bouncer.emitter = emitter cleanup(() => (Bouncer.emitter = undefined)) @@ -223,7 +223,7 @@ test.group('Bouncer | actions', () => { constructor(public userId: number) {} } - const editPost = Bouncer.define((user: User, post: Post) => { + const editPost = Bouncer.ability((user: User, post: Post) => { return post.userId === user.id }) @@ -244,7 +244,7 @@ test.group('Bouncer | actions', () => { declare email: string } - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(new User(), { editPost }) assert.isFalse(await bouncer.allows(editPost)) @@ -260,7 +260,7 @@ test.group('Bouncer | actions', () => { declare email: string } - const editPost = Bouncer.define((_: User) => { + const editPost = Bouncer.ability((_: User) => { throw new Error('Never executed to be invoked for guest users') }) const actions = { editPost } @@ -280,7 +280,7 @@ test.group('Bouncer | actions', () => { declare email: string } - const editPost = Bouncer.define( + const editPost = Bouncer.ability( (_: User | null) => { return true }, @@ -305,7 +305,7 @@ test.group('Bouncer | actions | userResolver', () => { declare email: string } - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(() => new User()) const response = await bouncer.execute(editPost) @@ -319,7 +319,7 @@ test.group('Bouncer | actions | userResolver', () => { declare email: string } - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(() => new User(), { editPost }) const response = await bouncer.execute('editPost') @@ -336,7 +336,7 @@ test.group('Bouncer | actions | userResolver', () => { constructor(public userId: number) {} } - const editPost = Bouncer.define((user: User, post: Post) => { + const editPost = Bouncer.ability((user: User, post: Post) => { return post.userId === user.id }) @@ -357,7 +357,7 @@ test.group('Bouncer | actions | userResolver', () => { declare email: string } - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(() => new User(), { editPost }) assert.isFalse(await bouncer.allows(editPost)) @@ -373,7 +373,7 @@ test.group('Bouncer | actions | userResolver', () => { declare email: string } - const editPost = Bouncer.define((_: User) => { + const editPost = Bouncer.ability((_: User) => { throw new Error('Never executed to be invoked for guest users') }) const actions = { editPost } @@ -393,7 +393,7 @@ test.group('Bouncer | actions | userResolver', () => { declare email: string } - const editPost = Bouncer.define( + const editPost = Bouncer.ability( (_: User | null) => { return true }, @@ -416,7 +416,7 @@ test.group('Bouncer | actions | userResolver', () => { declare email: string } - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(() => new User()) await assert.rejects(() => bouncer.authorize(editPost), 'Access denied') @@ -428,7 +428,7 @@ test.group('Bouncer | actions | userResolver', () => { declare email: string } - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(() => new User(), { editPost }) await assert.rejects(() => bouncer.authorize('editPost'), 'Access denied') diff --git a/tests/plugins/edge.spec.ts b/tests/plugins/edge.spec.ts index 94ccb3f..633938f 100644 --- a/tests/plugins/edge.spec.ts +++ b/tests/plugins/edge.spec.ts @@ -93,7 +93,7 @@ test.group('Edge plugin | abilities', () => { declare email: string } - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(new User(), { editPost }) const edge = new Edge() @@ -116,7 +116,7 @@ test.group('Edge plugin | abilities', () => { declare email: string } - const editPost = Bouncer.define((_: User) => false) + const editPost = Bouncer.ability((_: User) => false) const bouncer = new Bouncer(new User(), { editPost }) const edge = new Edge() @@ -143,7 +143,7 @@ test.group('Edge plugin | abilities', () => { constructor(public userId: number) {} } - const editPost = Bouncer.define((user: User, post: Post) => user.id === post.userId) + const editPost = Bouncer.ability((user: User, post: Post) => user.id === post.userId) const bouncer = new Bouncer(new User(1), { editPost }) const edge = new Edge() @@ -176,7 +176,7 @@ test.group('Edge plugin | abilities', () => { constructor(public userId: number) {} } - const editPost = Bouncer.define((user: User, post: Post) => user.id === post.userId) + const editPost = Bouncer.ability((user: User, post: Post) => user.id === post.userId) const bouncer = new Bouncer(new User(1), { editPost }) const edge = new Edge() From eedc5145f3fa30e30db6c5f84b1e2dc5c9c546f0 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 16:21:39 +0530 Subject: [PATCH 10/27] feat: abilities builder to be compatible with the old API --- src/abilities_builder.ts | 45 +++++++++++++ src/bouncer.ts | 20 ++++++ tests/abilities_builder.spec.ts | 113 ++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 src/abilities_builder.ts create mode 100644 tests/abilities_builder.spec.ts diff --git a/src/abilities_builder.ts b/src/abilities_builder.ts new file mode 100644 index 0000000..c603244 --- /dev/null +++ b/src/abilities_builder.ts @@ -0,0 +1,45 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { ability } from './ability.js' +import { AuthorizerResponse, BouncerAbility, BouncerAuthorizer } from './types.js' + +/** + * Abilities builder exposes a chainable API to fluently create an object + * of abilities by chaining the ".define" method. + */ +export class AbilitiesBuilder>> { + constructor(public abilities: Abilities) {} + + /** + * Helper to convert a user defined authorizer function to a bouncer ability + */ + define>( + name: Name, + authorizer: Authorizer, + options?: { allowGuest: boolean } + ) { + this.abilities[name] = ability(authorizer, options) as any + + return this as unknown as AbilitiesBuilder< + Abilities & { + [K in Name]: Authorizer extends ( + user: infer User, + ...args: infer Args + ) => AuthorizerResponse + ? { + allowGuest: false + original: Authorizer + execute(user: User | null, ...args: Args): AuthorizerResponse + } + : never + } + > + } +} diff --git a/src/bouncer.ts b/src/bouncer.ts index 3798b2e..35b1a29 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -15,6 +15,7 @@ import debug from './debug.js' import { AuthorizationResponse } from './response.js' import { E_AUTHORIZATION_FAILURE } from './errors.js' import { ability as createAbility } from './ability.js' +import { AbilitiesBuilder } from './abilities_builder.js' import { PolicyAuthorizer } from './policy_authorizer.js' import type { LazyImport, @@ -22,6 +23,7 @@ import type { BouncerAbility, ResponseBuilder, UnWrapLazyImport, + BouncerAuthorizer, AuthorizerResponse, AuthorizationEmitter, NarrowAbilitiesForAUser, @@ -43,6 +45,17 @@ export class Bouncer< return typeof response === 'boolean' ? new AuthorizationResponse(response) : response } + /** + * Define an ability using the AbilityBuilder + */ + static define>( + name: Name, + authorizer: Authorizer, + options?: { allowGuest: boolean } + ) { + return new AbilitiesBuilder({}).define(name, authorizer, options) + } + /** * Emitter to emit events */ @@ -341,4 +354,11 @@ export class Bouncer< }, } } + + /** + * Create AuthorizationResponse to deny access + */ + deny(message: string, status?: number) { + return AuthorizationResponse.deny(message, status) + } } diff --git a/tests/abilities_builder.spec.ts b/tests/abilities_builder.spec.ts new file mode 100644 index 0000000..0d3f105 --- /dev/null +++ b/tests/abilities_builder.spec.ts @@ -0,0 +1,113 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { AuthorizerResponse } from '../src/types.js' +import { Bouncer } from '../src/bouncer.js' + +test.group('AbilitiesBuilder', () => { + test('define abilities using abilities builder', ({ assert, expectTypeOf }) => { + class User { + declare email: string + constructor(public id: number) {} + } + class Post { + constructor(public userId: number) {} + } + + const { abilities } = Bouncer.define('editPost', (user: User, post: Post) => { + return user.id === post.userId + }) + .define('deletePost', (user: User, post: Post) => { + return user.id === post.userId + }) + .define('createPost', () => { + return true + }) + + assert.properties(abilities, ['editPost', 'deletePost', 'createPost']) + expectTypeOf(abilities).toEqualTypeOf< + { + editPost: { + allowGuest: false + original: (user: User, post: Post) => boolean + execute(user: User | null, post: Post): AuthorizerResponse + } + } & { + deletePost: { + allowGuest: false + original: (user: User, post: Post) => boolean + execute(user: User | null, post: Post): AuthorizerResponse + } + } & { + createPost: { + allowGuest: false + original: () => true + execute(user: unknown): AuthorizerResponse + } + } + >() + }) + + test('authorize using abilities created using the abilities builder', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + class Post { + constructor(public userId: number) {} + } + + const { abilities } = Bouncer.define('editPost', (user: User, post: Post) => { + return user.id === post.userId + }) + .define('deletePost', (user: User, post: Post) => { + return user.id === post.userId + }) + .define('createPost', () => { + return true + }) + + const bouncer = new Bouncer(new User(1)) + assert.isTrue(await bouncer.allows(abilities.createPost)) + assert.isTrue(await bouncer.allows(abilities.editPost, new Post(1))) + assert.isTrue(await bouncer.allows(abilities.deletePost, new Post(1))) + + assert.isTrue(await bouncer.denies(abilities.editPost, new Post(2))) + assert.isTrue(await bouncer.denies(abilities.deletePost, new Post(2))) + }) + + test('authorize by pre-registering builder abilites', async ({ assert }) => { + class User { + declare email: string + constructor(public id: number) {} + } + class Post { + constructor(public userId: number) {} + } + + const { abilities } = Bouncer.define('editPost', (user: User, post: Post) => { + return user.id === post.userId + }) + .define('deletePost', (user: User, post: Post) => { + return user.id === post.userId + }) + .define('createPost', () => { + return true + }) + + const bouncer = new Bouncer(new User(1), abilities) + assert.isTrue(await bouncer.allows('createPost')) + assert.isTrue(await bouncer.allows('editPost', new Post(1))) + assert.isTrue(await bouncer.allows('deletePost', new Post(1))) + + assert.isTrue(await bouncer.denies('editPost', new Post(2))) + assert.isTrue(await bouncer.denies('deletePost', new Post(2))) + }) +}) From 1070b91028559f356feaa6d987868cc5b3ba1e36 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 16:31:06 +0530 Subject: [PATCH 11/27] refactor: convert edgeHelpers method to edgeHelpers property --- src/bouncer.ts | 62 ++++++++++++++++++-------------------- tests/plugins/edge.spec.ts | 16 +++++----- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/src/bouncer.ts b/src/bouncer.ts index 35b1a29..5b7c0ab 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -92,6 +92,36 @@ export class Bouncer< */ #containerResolver?: ContainerResolver + /** + * An object with helpers to be shared with Edge for + * performing authorization. + */ + edgeHelpers: { + bouncer: { + parent: Bouncer + can(action: string, ...args: any[]): Promise + cannot(action: string, ...args: any[]): Promise + } + } = { + bouncer: { + parent: this, + can(action: string, ...args: any[]) { + const [policyName, ...policyMethods] = action.split('.') + if (policyMethods.length) { + return this.parent.with(policyName as any).allows(policyMethods.join('.'), ...args) + } + return this.parent.allows(policyName as any, ...args) + }, + cannot(action: string, ...args: any[]) { + const [policyName, ...policyMethods] = action.split('.') + if (policyMethods.length) { + return this.parent.with(policyName as any).denies(policyMethods.join('.'), ...args) + } + return this.parent.denies(policyName as any, ...args) + }, + }, + } + constructor( userOrResolver: User | (() => User | null) | null, abilities?: Abilities, @@ -323,38 +353,6 @@ export class Bouncer< } } - /** - * Returns an object with untyped API to perform authorization - * checks within edge templates - */ - edgeHelpers(): { - bouncer: { - parent: Bouncer - can(action: string, ...args: any[]): Promise - cannot(action: string, ...args: any[]): Promise - } - } { - return { - bouncer: { - parent: this, - can(action: string, ...args: any[]) { - const [policyName, ...policyMethods] = action.split('.') - if (policyMethods.length) { - return this.parent.with(policyName as any).allows(policyMethods.join('.'), ...args) - } - return this.parent.allows(policyName as any, ...args) - }, - cannot(action: string, ...args: any[]) { - const [policyName, ...policyMethods] = action.split('.') - if (policyMethods.length) { - return this.parent.with(policyName as any).denies(policyMethods.join('.'), ...args) - } - return this.parent.denies(policyName as any, ...args) - }, - }, - } - } - /** * Create AuthorizationResponse to deny access */ diff --git a/tests/plugins/edge.spec.ts b/tests/plugins/edge.spec.ts index 633938f..036cb61 100644 --- a/tests/plugins/edge.spec.ts +++ b/tests/plugins/edge.spec.ts @@ -99,7 +99,7 @@ test.group('Edge plugin | abilities', () => { const edge = new Edge() edge.use(edgePluginBouncer) - const text = await edge.share(bouncer.edgeHelpers()).renderRaw(` + const text = await edge.share(bouncer.edgeHelpers).renderRaw(` @can('editPost') Can edit post @else @@ -122,7 +122,7 @@ test.group('Edge plugin | abilities', () => { const edge = new Edge() edge.use(edgePluginBouncer) - const text = await edge.share(bouncer.edgeHelpers()).renderRaw(` + const text = await edge.share(bouncer.edgeHelpers).renderRaw(` @cannot('editPost') Cannot edit post @else @@ -149,7 +149,7 @@ test.group('Edge plugin | abilities', () => { const edge = new Edge() edge.use(edgePluginBouncer) - const text = await edge.share(bouncer.edgeHelpers()).renderRaw( + const text = await edge.share(bouncer.edgeHelpers).renderRaw( ` @can('editPost', posts[0]) Can edit post 1 @@ -182,7 +182,7 @@ test.group('Edge plugin | abilities', () => { const edge = new Edge() edge.use(edgePluginBouncer) - const text = await edge.share(bouncer.edgeHelpers()).renderRaw( + const text = await edge.share(bouncer.edgeHelpers).renderRaw( ` @cannot('editPost', posts[0]) Cannot edit post 1 @@ -228,7 +228,7 @@ test.group('Edge plugin | policies', () => { const edge = new Edge() edge.use(edgePluginBouncer) - const text = await edge.share(bouncer.edgeHelpers()).renderRaw(` + const text = await edge.share(bouncer.edgeHelpers).renderRaw(` @can('PostPolicy.edit') Can edit post @else @@ -266,7 +266,7 @@ test.group('Edge plugin | policies', () => { const edge = new Edge() edge.use(edgePluginBouncer) - const text = await edge.share(bouncer.edgeHelpers()).renderRaw(` + const text = await edge.share(bouncer.edgeHelpers).renderRaw(` @cannot('PostPolicy.edit') Cannot edit post @else @@ -308,7 +308,7 @@ test.group('Edge plugin | policies', () => { const edge = new Edge() edge.use(edgePluginBouncer) - const text = await edge.share(bouncer.edgeHelpers()).renderRaw( + const text = await edge.share(bouncer.edgeHelpers).renderRaw( ` @can('PostPolicy.edit', posts[0]) Can edit post 1 @@ -356,7 +356,7 @@ test.group('Edge plugin | policies', () => { const edge = new Edge() edge.use(edgePluginBouncer) - const text = await edge.share(bouncer.edgeHelpers()).renderRaw( + const text = await edge.share(bouncer.edgeHelpers).renderRaw( ` @cannot('PostPolicy.edit', posts[0]) Cannot edit post 1 From 2cd6e43789d5423b9ccc36ad43e683e103cc5b35 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 16:51:29 +0530 Subject: [PATCH 12/27] refactor: accept ability options as first parameter --- src/abilities_builder.ts | 2 +- src/ability.ts | 64 ++++++++++++++++++++++----------- tests/bouncer/abilities.spec.ts | 18 ++++------ 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/src/abilities_builder.ts b/src/abilities_builder.ts index c603244..fb64953 100644 --- a/src/abilities_builder.ts +++ b/src/abilities_builder.ts @@ -25,7 +25,7 @@ export class AbilitiesBuilder = Authorizer extends ( + user: infer User, + ...args: infer Args +) => AuthorizerResponse + ? { + allowGuest: false + original: Authorizer + execute(user: User | null, ...args: Args): AuthorizerResponse + } + : never + /** * Helper to convert a user defined authorizer function to a bouncer ability */ export function ability>( - authorizer: Authorizer, - options?: { allowGuest: boolean } + options: { allowGuest: boolean }, + authorizer: Authorizer +): AuthorizerToAbility +export function ability>( + authorizer: Authorizer +): AuthorizerToAbility +export function ability>( + authorizerOrOptions: Authorizer | { allowGuest: boolean }, + authorizer?: Authorizer ) { - return { - allowGuest: options?.allowGuest || false, - original: authorizer, - execute(user, ...args) { - if (user === null && !this.allowGuest) { - return AuthorizationResponse.deny() - } - return this.original(user, ...args) - }, - } satisfies BouncerAbility as Authorizer extends ( - user: infer User, - ...args: infer Args - ) => AuthorizerResponse - ? { - allowGuest: false - original: Authorizer - execute(user: User | null, ...args: Args): AuthorizerResponse - } - : never + if (typeof authorizerOrOptions === 'function') { + return { + allowGuest: false, + original: authorizerOrOptions, + execute(user, ...args) { + if (user === null && !this.allowGuest) { + return AuthorizationResponse.deny() + } + return this.original(user, ...args) + }, + } satisfies BouncerAbility + } else { + return { + allowGuest: authorizerOrOptions?.allowGuest || false, + original: authorizer!, + execute(user, ...args) { + if (user === null && !this.allowGuest) { + return AuthorizationResponse.deny() + } + return this.original(user, ...args) + }, + } satisfies BouncerAbility + } } diff --git a/tests/bouncer/abilities.spec.ts b/tests/bouncer/abilities.spec.ts index 968bcd4..c94b824 100644 --- a/tests/bouncer/abilities.spec.ts +++ b/tests/bouncer/abilities.spec.ts @@ -280,12 +280,9 @@ test.group('Bouncer | actions', () => { declare email: string } - const editPost = Bouncer.ability( - (_: User | null) => { - return true - }, - { allowGuest: true } - ) + const editPost = Bouncer.ability({ allowGuest: true }, (_: User | null) => { + return true + }) const actions = { editPost } const bouncer = new Bouncer(null, actions) @@ -393,12 +390,9 @@ test.group('Bouncer | actions | userResolver', () => { declare email: string } - const editPost = Bouncer.ability( - (_: User | null) => { - return true - }, - { allowGuest: true } - ) + const editPost = Bouncer.ability({ allowGuest: true }, (_: User | null) => { + return true + }) const actions = { editPost } const bouncer = new Bouncer(() => null, actions) From 324002e10c45614e55bac7d6d935362223c478da Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 17:13:33 +0530 Subject: [PATCH 13/27] feat: add support for make:policy command --- bin/test.ts | 3 +- commands/make_policy.ts | 50 +++++++++++++++++ package.json | 6 +- stubs/make/policy/main.stub | 22 ++++++++ tests/commands/make_policy.spec.ts | 89 ++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 commands/make_policy.ts create mode 100644 stubs/make/policy/main.stub create mode 100644 tests/commands/make_policy.spec.ts diff --git a/bin/test.ts b/bin/test.ts index 5213bde..223cb71 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -1,13 +1,14 @@ import 'reflect-metadata' import { assert } from '@japa/assert' import { snapshot } from '@japa/snapshot' +import { fileSystem } from '@japa/file-system' import { expectTypeOf } from '@japa/expect-type' import { configure, processCLIArgs, run } from '@japa/runner' processCLIArgs(process.argv.splice(2)) configure({ files: ['tests/**/*.spec.ts'], - plugins: [assert(), expectTypeOf(), snapshot()], + plugins: [assert(), expectTypeOf(), snapshot(), fileSystem()], }) run() diff --git a/commands/make_policy.ts b/commands/make_policy.ts new file mode 100644 index 0000000..a4dfae1 --- /dev/null +++ b/commands/make_policy.ts @@ -0,0 +1,50 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import string from '@adonisjs/core/helpers/string' +import { BaseCommand, args, flags } from '@adonisjs/core/ace' +import type { CommandOptions } from '@adonisjs/core/types/ace' + +import { stubsRoot } from '../stubs/main.js' + +export default class MakePolicy extends BaseCommand { + static commandName = 'make:policy' + static description = 'Make a new policy class' + static options: CommandOptions = { + allowUnknownFlags: true, + } + + /** + * The name of the policy file + */ + @args.string({ description: 'Name of the policy file' }) + declare name: string + + @args.spread({ description: 'Method names to pre-define on the policy', required: false }) + declare actions?: string[] + + /** + * The model for which to generate the policy. + */ + @flags.string({ description: 'The name of the policy model' }) + declare model?: string + + /** + * Execute command + */ + async run(): Promise { + const codemods = await this.createCodemods() + await codemods.makeUsingStub(stubsRoot, 'make/policy/main.stub', { + flags: this.parsed.flags, + actions: this.actions?.map((action) => string.camelCase(action)) || [], + entity: this.app.generators.createEntity(this.name), + model: this.app.generators.createEntity(this.model || this.name), + }) + } +} diff --git a/package.json b/package.json index 6040443..b50f7d5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "exports": { ".": "./build/index.js", "./types": "./build/src/types.js", + "./commands": "./build/commands/main.js", "./bouncer_provider": "./build/providers/bouncer_provider.js", "./plugins/edge": "./build/src/plugins/edge.js" }, @@ -26,13 +27,14 @@ "copy:templates": "copyfiles \"stubs/**/*.stub\" --up=\"1\" build", "precompile": "npm run lint && npm run clean", "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", - "postcompile": "npm run copy:templates", + "postcompile": "npm run copy:templates && npm run index:commands", "build": "npm run compile", "prepublishOnly": "npm run build", "lint": "eslint . --ext=.ts", "format": "prettier --write .", "release": "np", "version": "npm run build", + "index:commands": "adonis-kit index build/commands", "sync-labels": "github-label-sync --labels .github/labels.json adonisjs/bouncer", "quick:test": "c8 node --enable-source-maps --loader=ts-node/esm bin/test.ts" }, @@ -61,6 +63,7 @@ "@commitlint/config-conventional": "^18.4.3", "@japa/assert": "^2.0.0-2", "@japa/expect-type": "^2.0.0-1", + "@japa/file-system": "^2.0.1", "@japa/runner": "^3.0.0-9", "@japa/snapshot": "^2.0.3", "@swc/core": "^1.3.100", @@ -124,6 +127,7 @@ "entry": [ "./index.ts", "./providers/bouncer_provider.ts", + "./commands/make_policy.ts", "./src/plugins/edge.ts" ], "outDir": "./build", diff --git a/stubs/make/policy/main.stub b/stubs/make/policy/main.stub new file mode 100644 index 0000000..6d0a2b9 --- /dev/null +++ b/stubs/make/policy/main.stub @@ -0,0 +1,22 @@ +{{#var policyName = generators.policyName(entity.name)}} +{{#var modelName = generators.modelName(model.name)}} +{{#var modelFileName = generators.modelFileName(model.name)}} +{{#var policyFileName = generators.policyFileName(entity.name)}} +{{#var modelImportPath = generators.importPath('#models', model.path, modelFileName.replace(/\.ts$/, ''))}} +{{{ + exports({ + to: app.policiesPath(entity.path, policyFileName) + }) +}}} +import User from '#models/user' +import {{modelName}} from '{{modelImportPath}}' +import { BasePolicy } from '@adonisjs/bouncer' +import { AuthorizerResponse } from '@adonisjs/bouncer/types' + +export default class {{ policyName }} extends BasePolicy { + {{#each actions as action}} + {{action}}(user: User): AuthorizerResponse { + return false + } + {{/each}} +} diff --git a/tests/commands/make_policy.spec.ts b/tests/commands/make_policy.spec.ts new file mode 100644 index 0000000..cffc528 --- /dev/null +++ b/tests/commands/make_policy.spec.ts @@ -0,0 +1,89 @@ +/* + * @adonisjs/bouncer + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { AceFactory } from '@adonisjs/core/factories' +import MakePolicy from '../../commands/make_policy.js' + +test.group('MakePolicy', () => { + test('make policy class using the stub', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) + await ace.app.init() + ace.ui.switchMode('raw') + + const command = await ace.create(MakePolicy, ['post']) + await command.exec() + + command.assertLog('green(DONE:) create app/policies/post_policy.ts') + await assert.fileContains('app/policies/post_policy.ts', `import Post from '#models/post'`) + await assert.fileContains('app/policies/post_policy.ts', `import User from '#models/user'`) + await assert.fileContains( + 'app/policies/post_policy.ts', + `import { BasePolicy } from '@adonisjs/bouncer'` + ) + await assert.fileContains( + 'app/policies/post_policy.ts', + `import { AuthorizerResponse } from '@adonisjs/bouncer/types'` + ) + await assert.fileContains( + 'app/policies/post_policy.ts', + `export default class PostPolicy extends BasePolicy` + ) + }) + + test('make policy class inside nested directories', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) + await ace.app.init() + ace.ui.switchMode('raw') + + const command = await ace.create(MakePolicy, ['post/published', '--model=post']) + await command.exec() + + command.assertLog('green(DONE:) create app/policies/post/published_policy.ts') + await assert.fileContains( + 'app/policies/post/published_policy.ts', + `import Post from '#models/post'` + ) + await assert.fileContains( + 'app/policies/post/published_policy.ts', + `import User from '#models/user'` + ) + await assert.fileContains( + 'app/policies/post/published_policy.ts', + `import { BasePolicy } from '@adonisjs/bouncer'` + ) + await assert.fileContains( + 'app/policies/post/published_policy.ts', + `import { AuthorizerResponse } from '@adonisjs/bouncer/types'` + ) + await assert.fileContains( + 'app/policies/post/published_policy.ts', + `export default class PublishedPolicy extends BasePolicy` + ) + }) + + test('define policy actions', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) + await ace.app.init() + ace.ui.switchMode('raw') + + const command = await ace.create(MakePolicy, ['post', 'view', 'edit', 'delete']) + await command.exec() + + command.assertLog('green(DONE:) create app/policies/post_policy.ts') + await assert.fileContains('app/policies/post_policy.ts', `import Post from '#models/post'`) + await assert.fileContains('app/policies/post_policy.ts', `import User from '#models/user'`) + await assert.fileContains('app/policies/post_policy.ts', `view(user: User): AuthorizerResponse`) + await assert.fileContains('app/policies/post_policy.ts', `edit(user: User): AuthorizerResponse`) + await assert.fileContains( + 'app/policies/post_policy.ts', + `delete(user: User): AuthorizerResponse` + ) + }) +}) From a58144c450f14eb9b0aa9a7fe69011204ee53841 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 17:14:38 +0530 Subject: [PATCH 14/27] ci: update checks yaml file --- .github/workflows/checks.yml | 57 ++++-------------------------------- 1 file changed, 5 insertions(+), 52 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8c26a40..c27fb04 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,61 +1,14 @@ -name: test +name: checks on: - push - pull_request + jobs: + test: + uses: adonisjs/.github/.github/workflows/test.yml@main + lint: uses: adonisjs/.github/.github/workflows/lint.yml@main typecheck: uses: adonisjs/.github/.github/workflows/typecheck.yml@main - - test_linux: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20.10.0, 21.x] - services: - redis: - image: redis - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Install - run: npm install - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - name: Run tests - run: npm test - env: - REDIS_HOST: 0.0.0.0 - REDIS_PORT: 6379 - - test_windows: - runs-on: windows-latest - strategy: - matrix: - node-version: [20.10.0, 21.x] - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Install - run: npm install - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - name: Run tests - run: npm test - env: - NO_REDIS: true From e47c0d3fe798d75738d35bc3d47b2951e26d7338 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 17:19:17 +0530 Subject: [PATCH 15/27] ci: skip edge compiler output tests on windows --- README.md | 0 tests/plugins/edge.spec.ts | 6 +++++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/edge.spec.ts b/tests/plugins/edge.spec.ts index 036cb61..da30eb0 100644 --- a/tests/plugins/edge.spec.ts +++ b/tests/plugins/edge.spec.ts @@ -13,7 +13,11 @@ import { Bouncer } from '../../src/bouncer.js' import { edgePluginBouncer } from '../../src/plugins/edge.js' import { BasePolicy } from '../../src/base_policy.js' -test.group('Edge plugin | compile', () => { +test.group('Edge plugin | compile', (group) => { + group.tap((t) => + t.skip(process.platform === 'win32', 'Skipping on windows because of newline breaks') + ) + test('assert @can tag compiled output', async ({ assert }) => { const edge = new Edge() edge.use(edgePluginBouncer) From 44ca3be798f7322da7ff7698ef3244561a9fd94b Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 17:22:15 +0530 Subject: [PATCH 16/27] docs: add README file --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index e69de29..cebcecc 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,33 @@ +# @adonisjs/bouncer + +
+ +[![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] + +## Introduction +AdonisJS bouncer provides JavaScript first API to implementation authorization checks in AdonisJS applications. + +## Official Documentation +The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/authorization) + +## Contributing +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. + +We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework. + +## Code of Conduct +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). + +## License +AdonisJS bouncer is open-sourced software licensed under the [MIT license](LICENSE.md). + +[gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/bouncer/checks.yml?style=for-the-badge +[gh-workflow-url]: https://github.com/adonisjs/bouncer/actions/workflows/checks.yml "Github action" + +[npm-image]: https://img.shields.io/npm/v/@adonisjs/bouncer/latest.svg?style=for-the-badge&logo=npm +[npm-url]: https://www.npmjs.com/package/@adonisjs/bouncer/v/latest "npm" + +[typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript + +[license-url]: LICENSE.md +[license-image]: https://img.shields.io/github/license/adonisjs/bouncer?style=for-the-badge From 70acc43d3323a8fc3de97d7a2979f4403bb9df9e Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 17:24:20 +0530 Subject: [PATCH 17/27] chore(release): 3.0.0-0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b50f7d5..3ac0d72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/bouncer", - "version": "2.3.0", + "version": "3.0.0-0", "description": "Authorization layer for AdonisJS", "engines": { "node": ">=18.16.0" From 2c0b82a97c8aa82b1cb99e8f0a0d6c2b0f373f95 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 21:25:47 +0530 Subject: [PATCH 18/27] feat: create initial policies and abilities files --- configure.ts | 7 +++ stubs/abilities.stub | 26 +++++++++ stubs/initialize_bouncer_middleware.stub | 38 ++++++++++---- stubs/policies.stub | 18 +++++++ tests/configure.spec.ts | 67 ++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 stubs/abilities.stub create mode 100644 stubs/policies.stub create mode 100644 tests/configure.spec.ts diff --git a/configure.ts b/configure.ts index 311bec2..98f849c 100644 --- a/configure.ts +++ b/configure.ts @@ -15,6 +15,13 @@ import type Configure from '@adonisjs/core/commands/configure' export async function configure(command: Configure) { const codemods = await command.createCodemods() + /** + * Publish stubs to define abilities and collect + * policies + */ + await command.publishStub('abilities.stub', {}) + await command.publishStub('policies.stub', {}) + /** * Register provider */ diff --git a/stubs/abilities.stub b/stubs/abilities.stub new file mode 100644 index 0000000..f915f6a --- /dev/null +++ b/stubs/abilities.stub @@ -0,0 +1,26 @@ +{{{ + exports({ to: app.makePath('app/abilities/main.ts') }) +}}} +/* +|-------------------------------------------------------------------------- +| Bouncer abilities +|-------------------------------------------------------------------------- +| +| You may export multiple abilities from this file and pre-register them +| when creating the Bouncer instance. +| +| Pre-registered policies and abilities can be referenced as a string by their +| name. Also they are must if want to perform authorization inside Edge +| templates. +| +*/ + +import { Bouncer } from '@adonisjs/bouncer' + +/** + * Delete the following ability to start from + * scratch + */ +export const editUser = Bouncer.ability(() => { + return true +}) diff --git a/stubs/initialize_bouncer_middleware.stub b/stubs/initialize_bouncer_middleware.stub index cf35e0c..7d8d17f 100644 --- a/stubs/initialize_bouncer_middleware.stub +++ b/stubs/initialize_bouncer_middleware.stub @@ -5,9 +5,8 @@ }}} import { policies } from '#policies/main' import * as abilities from '#abilities/main' -import { Bouncer } from '@adonisjs/bouncer' -import emitter from '@adonisjs/core/services/emitter' +import { Bouncer } from '@adonisjs/bouncer' import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' @@ -16,15 +15,34 @@ import type { NextFn } from '@adonisjs/core/types/http' * during an HTTP request */ export default class {{ middlewareName }} { - async handle( - ctx: HttpContext, - next: NextFn, - ) { - const bouncer = Bouncer - .create(() => ctx.auth.user, abilities, policies) - .setContainerResolver(ctx.containerResolver) - .setEmitter(emitter) + async handle(ctx: HttpContext, next: NextFn) { + /** + * Create bouncer instance for the ongoing HTTP request. + * We will pull the user from the HTTP context. + */ + ctx.bouncer = new Bouncer( + () => ctx.auth.user || null, + abilities, + policies + ).setContainerResolver(ctx.containerResolver) + + /** + * Share bouncer helpers with Edge templates. + */ + if ('view' in ctx) { + ctx.view.share(ctx.bouncer.edgeHelpers) + } return next() } } + +declare module '@adonisjs/core/http' { + export interface HttpContext { + bouncer: Bouncer< + Exclude, + typeof abilities, + typeof policies + > + } +} diff --git a/stubs/policies.stub b/stubs/policies.stub new file mode 100644 index 0000000..fde6b47 --- /dev/null +++ b/stubs/policies.stub @@ -0,0 +1,18 @@ +{{{ + exports({ to: app.policiesPath('main.ts') }) +}}} +/* +|-------------------------------------------------------------------------- +| Bouncer policies +|-------------------------------------------------------------------------- +| +| You may define a collection of policies inside this file and pre-register +| them when creating a new bouncer instance. +| +| Pre-registered policies and abilities can be referenced as a string by their +| name. Also they are must if want to perform authorization inside Edge +| templates. +| +*/ + +export const policies = {} diff --git a/tests/configure.spec.ts b/tests/configure.spec.ts new file mode 100644 index 0000000..3daac73 --- /dev/null +++ b/tests/configure.spec.ts @@ -0,0 +1,67 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { fileURLToPath } from 'node:url' +import { IgnitorFactory } from '@adonisjs/core/factories' +import Configure from '@adonisjs/core/commands/configure' + +import { stubsRoot } from '../index.js' +const BASE_URL = new URL('./tmp/', import.meta.url) + +test.group('Configure', (group) => { + group.each.setup(({ context }) => { + context.fs.baseUrl = BASE_URL + context.fs.basePath = fileURLToPath(BASE_URL) + }) + + test('register provider and publish stubs', async ({ fs, assert }) => { + const ignitor = new IgnitorFactory() + .withCoreProviders() + .withCoreConfig() + .create(BASE_URL, { + importer: (filePath) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, BASE_URL).href) + } + + return import(filePath) + }, + }) + + await fs.createJson('tsconfig.json', {}) + await fs.create('start/kernel.ts', `router.use([])`) + await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) + + const app = ignitor.createApp('web') + await app.init() + await app.boot() + + const ace = await app.container.make('ace') + const command = await ace.create(Configure, ['../../index.js']) + await command.exec() + + const stubsManager = await app.stubs.create() + const abilitiesStub = await stubsManager + .build('abilities.stub', { source: stubsRoot }) + .then((stub) => stub.prepare({})) + + const policiesStub = await stubsManager + .build('policies.stub', { source: stubsRoot }) + .then((stub) => stub.prepare({})) + + await assert.fileContains('adonisrc.ts', '@adonisjs/bouncer/bouncer_provider') + await assert.fileContains('app/abilities/main.ts', abilitiesStub.contents) + await assert.fileContains('app/policies/main.ts', policiesStub.contents) + await assert.fileContains( + 'app/middleware/initialize_bouncer_middleware.ts', + `export default class InitializeBouncerMiddleware {` + ) + }).disableTimeout() +}) From 4cdfe9d73877350851152c1cd6cbffbea23c2b27 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 5 Dec 2023 21:30:36 +0530 Subject: [PATCH 19/27] chore(release): 3.0.0-1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3ac0d72..38b9a36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/bouncer", - "version": "3.0.0-0", + "version": "3.0.0-1", "description": "Authorization layer for AdonisJS", "engines": { "node": ">=18.16.0" From 563680453005467add1f97708a4e3161b3904900 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 25 Dec 2023 17:26:49 +0530 Subject: [PATCH 20/27] chore: update dependencies and use EmitterLike --- package.json | 32 ++++++++++++++++---------------- providers/bouncer_provider.ts | 6 +++--- src/bouncer.ts | 5 +++-- src/policy_authorizer.ts | 9 +++++---- src/response.ts | 18 ++++++++++++++++++ src/types.ts | 10 +--------- tests/helpers.ts | 4 ++-- 7 files changed, 48 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 38b9a36..e891044 100644 --- a/package.json +++ b/package.json @@ -53,20 +53,20 @@ "adonisjs" ], "devDependencies": { - "@adonisjs/assembler": "^6.1.3-29", - "@adonisjs/core": "^6.1.5-33", + "@adonisjs/assembler": "^7.0.0-0", + "@adonisjs/core": "^6.1.5-34", "@adonisjs/eslint-config": "^1.2.0", - "@adonisjs/i18n": "^2.0.0-8", + "@adonisjs/i18n": "^2.0.0-9", "@adonisjs/prettier-config": "^1.2.0", "@adonisjs/tsconfig": "^1.2.0", "@commitlint/cli": "^18.4.3", "@commitlint/config-conventional": "^18.4.3", - "@japa/assert": "^2.0.0-2", - "@japa/expect-type": "^2.0.0-1", - "@japa/file-system": "^2.0.1", - "@japa/runner": "^3.0.0-9", - "@japa/snapshot": "^2.0.3", - "@swc/core": "^1.3.100", + "@japa/assert": "^2.1.0", + "@japa/expect-type": "^2.0.1", + "@japa/file-system": "^2.1.1", + "@japa/runner": "^3.1.1", + "@japa/snapshot": "^2.0.4", + "@swc/core": "^1.3.101", "c8": "^8.0.1", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", @@ -74,18 +74,18 @@ "edge.js": "^6.0.0", "github-label-sync": "^2.3.1", "husky": "^8.0.3", - "np": "^9.0.0", - "reflect-metadata": "^0.1.13", - "ts-node": "^10.9.1", + "np": "^9.2.0", + "reflect-metadata": "^0.2.1", + "ts-node": "^10.9.2", "tsup": "^8.0.1", - "typescript": "5.2.2" + "typescript": "^5.3.3" }, "dependencies": { - "@poppinss/utils": "^6.6.0" + "@poppinss/utils": "^6.7.0" }, "peerDependencies": { - "@adonisjs/core": "^6.1.5-33", - "@adonisjs/i18n": "^2.0.0-8" + "@adonisjs/core": "^6.1.5-34", + "@adonisjs/i18n": "^2.0.0-9" }, "peerDependenciesMeta": { "@adonisjs/core": { diff --git a/providers/bouncer_provider.ts b/providers/bouncer_provider.ts index dafa34e..5e3d616 100644 --- a/providers/bouncer_provider.ts +++ b/providers/bouncer_provider.ts @@ -7,13 +7,13 @@ * file that was distributed with this source code. */ -import { ApplicationService } from '@adonisjs/core/types' +import type { ApplicationService } from '@adonisjs/core/types' import { Bouncer } from '../src/bouncer.js' -import type { AuthorizationEvents } from '../src/types.js' +import type { BouncerEvents } from '../src/types.js' declare module '@adonisjs/core/types' { - export interface EventsList extends AuthorizationEvents {} + export interface EventsList extends BouncerEvents {} } /** diff --git a/src/bouncer.ts b/src/bouncer.ts index 5b7c0ab..a495d24 100644 --- a/src/bouncer.ts +++ b/src/bouncer.ts @@ -9,6 +9,7 @@ import { inspect } from 'node:util' import { RuntimeException } from '@poppinss/utils' +import type { EmitterLike } from '@adonisjs/core/types/events' import { type ContainerResolver } from '@adonisjs/core/container' import debug from './debug.js' @@ -20,12 +21,12 @@ import { PolicyAuthorizer } from './policy_authorizer.js' import type { LazyImport, Constructor, + BouncerEvents, BouncerAbility, ResponseBuilder, UnWrapLazyImport, BouncerAuthorizer, AuthorizerResponse, - AuthorizationEmitter, NarrowAbilitiesForAUser, } from './types.js' @@ -59,7 +60,7 @@ export class Bouncer< /** * Emitter to emit events */ - static emitter?: AuthorizationEmitter + static emitter?: EmitterLike /** * Define a bouncer ability from a callback diff --git a/src/policy_authorizer.ts b/src/policy_authorizer.ts index 88065b8..9eed6c0 100644 --- a/src/policy_authorizer.ts +++ b/src/policy_authorizer.ts @@ -7,7 +7,8 @@ * file that was distributed with this source code. */ import { RuntimeException } from '@poppinss/utils' -import { ContainerResolver } from '@adonisjs/core/container' +import type { EmitterLike } from '@adonisjs/core/types/events' +import type { ContainerResolver } from '@adonisjs/core/container' import debug from './debug.js' import { BasePolicy } from './base_policy.js' @@ -16,10 +17,10 @@ import { AuthorizationResponse } from './response.js' import type { LazyImport, Constructor, + BouncerEvents, ResponseBuilder, GetPolicyMethods, AuthorizerResponse, - AuthorizationEmitter, } from './types.js' /** @@ -52,7 +53,7 @@ export class PolicyAuthorizer< /** * Emitter to emit events */ - #emitter?: AuthorizationEmitter + #emitter?: EmitterLike /** * Response builder is used to normalize bouncer responses @@ -196,7 +197,7 @@ export class PolicyAuthorizer< * Define the event emitter instance to use for emitting * authorization events */ - setEmitter(emitter?: AuthorizationEmitter): this { + setEmitter(emitter?: EmitterLike): this { this.#emitter = emitter return this } diff --git a/src/response.ts b/src/response.ts index cf414bb..6d7239f 100644 --- a/src/response.ts +++ b/src/response.ts @@ -8,6 +8,9 @@ */ export class AuthorizationResponse { + /** + * Create a deny response + */ static deny(message?: string, statusCode?: number) { const response = new AuthorizationResponse(false) response.message = message @@ -15,12 +18,27 @@ export class AuthorizationResponse { return response } + /** + * Create an allowed response + */ static allow() { return new AuthorizationResponse(true) } + /** + * HTTP status for the authorization response + */ declare status?: number + + /** + * Response message + */ declare message?: string + + /** + * Translation identifier to use for creating the + * authorization response + */ declare translation?: { identifier: string data?: Record diff --git a/src/types.ts b/src/types.ts index 610bf9f..2df2e25 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,7 +77,7 @@ export type ResponseBuilder = (response: boolean | AuthorizationResponse) => Aut /** * Events emitted by bouncer */ -export type AuthorizationEvents = { +export type BouncerEvents = { 'authorization:finished': { user: any action?: string @@ -85,11 +85,3 @@ export type AuthorizationEvents = { response: AuthorizationResponse } } - -/** - * The emitter accepted by bouncer for emit authorization - * events - */ -export type AuthorizationEmitter = { - emit(key: 'authorization:finished', event: AuthorizationEvents['authorization:finished']): any -} diff --git a/tests/helpers.ts b/tests/helpers.ts index bff25a9..5d8d8fa 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -9,10 +9,10 @@ import { Emitter } from '@adonisjs/core/events' import { AppFactory } from '@adonisjs/core/factories/app' -import type { AuthorizationEvents } from '../src/types.js' +import type { BouncerEvents } from '../src/types.js' const BASE_URL = new URL('./tmp', import.meta.url) export const createEmitter = () => { - return new Emitter(new AppFactory().create(BASE_URL, () => {})) + return new Emitter(new AppFactory().create(BASE_URL, () => {})) } From 44fbb5153eb50a5b8c6d19c00d7ff568a0ba5418 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 25 Dec 2023 18:48:50 +0530 Subject: [PATCH 21/27] feat: auto register policy to policies list --- commands/make_policy.ts | 56 ++++++++++++- package.json | 4 +- stubs/make/policy/main.stub | 2 +- tests/commands/make_policy.spec.ts | 123 ++++++++++++++++++++--------- 4 files changed, 143 insertions(+), 42 deletions(-) diff --git a/commands/make_policy.ts b/commands/make_policy.ts index a4dfae1..84ff6f1 100644 --- a/commands/make_policy.ts +++ b/commands/make_policy.ts @@ -8,6 +8,7 @@ */ import string from '@adonisjs/core/helpers/string' +import { extname, relative, basename } from 'node:path' import { BaseCommand, args, flags } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' @@ -15,7 +16,7 @@ import { stubsRoot } from '../stubs/main.js' export default class MakePolicy extends BaseCommand { static commandName = 'make:policy' - static description = 'Make a new policy class' + static description = 'Make a new bouncer policy class' static options: CommandOptions = { allowUnknownFlags: true, } @@ -29,6 +30,13 @@ export default class MakePolicy extends BaseCommand { @args.spread({ description: 'Method names to pre-define on the policy', required: false }) declare actions?: string[] + @flags.boolean({ + description: 'Auto register the policy inside the app/policies/main.ts file', + showNegatedVariantInHelp: true, + alias: 'r', + }) + declare register?: boolean + /** * The model for which to generate the policy. */ @@ -39,12 +47,56 @@ export default class MakePolicy extends BaseCommand { * Execute command */ async run(): Promise { + /** + * Display prompt to know if we should register the policy + * file inside the "app/policies/main.ts" file. + */ + if (this.register === undefined) { + this.register = await this.prompt.confirm( + 'Do you want to register the policy inside the app/policies/main.ts file?' + ) + } + const codemods = await this.createCodemods() - await codemods.makeUsingStub(stubsRoot, 'make/policy/main.stub', { + const { destination } = await codemods.makeUsingStub(stubsRoot, 'make/policy/main.stub', { flags: this.parsed.flags, actions: this.actions?.map((action) => string.camelCase(action)) || [], entity: this.app.generators.createEntity(this.name), model: this.app.generators.createEntity(this.model || this.name), }) + + /** + * Do not register when prompt has been denied or "--no-register" + * flag was used + */ + if (!this.register) { + return + } + + /** + * Creative relative path for the policy file from + * the "./app/policies" directory + */ + const policyRelativePath = relative(this.app.policiesPath(), destination).replace( + extname(destination), + '' + ) + + /** + * Convert the policy path to pascalCase. Remember, do not take + * the basename in this case, because we want scoped policies + * to be registered with their fully qualified name. + */ + const name = string.pascalCase(policyRelativePath) + + /** + * Register policy + */ + await codemods.registerPolicies([ + { + name: name, + path: `#policies/${policyRelativePath}`, + }, + ]) } } diff --git a/package.json b/package.json index e891044..a2cb48a 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,8 @@ "adonisjs" ], "devDependencies": { - "@adonisjs/assembler": "^7.0.0-0", - "@adonisjs/core": "^6.1.5-34", + "@adonisjs/assembler": "^7.0.0-1", + "@adonisjs/core": "^6.1.5-35", "@adonisjs/eslint-config": "^1.2.0", "@adonisjs/i18n": "^2.0.0-9", "@adonisjs/prettier-config": "^1.2.0", diff --git a/stubs/make/policy/main.stub b/stubs/make/policy/main.stub index 6d0a2b9..75a7cdd 100644 --- a/stubs/make/policy/main.stub +++ b/stubs/make/policy/main.stub @@ -1,7 +1,7 @@ {{#var policyName = generators.policyName(entity.name)}} +{{#var policyFileName = generators.policyFileName(entity.name)}} {{#var modelName = generators.modelName(model.name)}} {{#var modelFileName = generators.modelFileName(model.name)}} -{{#var policyFileName = generators.policyFileName(entity.name)}} {{#var modelImportPath = generators.importPath('#models', model.path, modelFileName.replace(/\.ts$/, ''))}} {{{ exports({ diff --git a/tests/commands/make_policy.spec.ts b/tests/commands/make_policy.spec.ts index cffc528..f65463b 100644 --- a/tests/commands/make_policy.spec.ts +++ b/tests/commands/make_policy.spec.ts @@ -13,77 +13,126 @@ import MakePolicy from '../../commands/make_policy.js' test.group('MakePolicy', () => { test('make policy class using the stub', async ({ assert, fs }) => { + await fs.createJson('tsconfig.json', {}) + await fs.create('app/policies/main.ts', `export const policies = {}`) + const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) await ace.app.init() ace.ui.switchMode('raw') const command = await ace.create(MakePolicy, ['post']) + command.prompt + .trap('Do you want to register the policy inside the app/policies/main.ts file?') + .accept() + await command.exec() + command.assertSucceeded() command.assertLog('green(DONE:) create app/policies/post_policy.ts') - await assert.fileContains('app/policies/post_policy.ts', `import Post from '#models/post'`) - await assert.fileContains('app/policies/post_policy.ts', `import User from '#models/user'`) - await assert.fileContains( - 'app/policies/post_policy.ts', - `import { BasePolicy } from '@adonisjs/bouncer'` - ) + + await assert.fileContains('app/policies/post_policy.ts', [ + `import Post from '#models/post'`, + `import User from '#models/user'`, + `import { BasePolicy } from '@adonisjs/bouncer'`, + `import { AuthorizerResponse } from '@adonisjs/bouncer/types'`, + `export default class PostPolicy extends BasePolicy`, + ]) + await assert.fileContains( - 'app/policies/post_policy.ts', - `import { AuthorizerResponse } from '@adonisjs/bouncer/types'` + 'app/policies/main.ts', + `PostPolicy: () => import('#policies/post_policy')` ) + }) + + test('do not display prompt when --register flag is used', async ({ assert, fs }) => { + await fs.createJson('tsconfig.json', {}) + await fs.create('app/policies/main.ts', `export const policies = {}`) + + const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) + await ace.app.init() + ace.ui.switchMode('raw') + + const command = await ace.create(MakePolicy, ['post', '--register']) + await command.exec() + command.assertSucceeded() + + command.assertLog('green(DONE:) create app/policies/post_policy.ts') await assert.fileContains( - 'app/policies/post_policy.ts', - `export default class PostPolicy extends BasePolicy` + 'app/policies/main.ts', + `PostPolicy: () => import('#policies/post_policy')` ) }) + test('do not register policy when --no-register flag is used', async ({ assert, fs }) => { + await fs.createJson('tsconfig.json', {}) + await fs.create('app/policies/main.ts', `export const policies = {}`) + + const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) + await ace.app.init() + ace.ui.switchMode('raw') + + const command = await ace.create(MakePolicy, ['post', '--no-register']) + await command.exec() + command.assertSucceeded() + + command.assertLog('green(DONE:) create app/policies/post_policy.ts') + await assert.fileEquals('app/policies/main.ts', `export const policies = {}`) + }) + test('make policy class inside nested directories', async ({ assert, fs }) => { + await fs.createJson('tsconfig.json', {}) + await fs.create('app/policies/main.ts', `export const policies = {}`) + const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) await ace.app.init() ace.ui.switchMode('raw') const command = await ace.create(MakePolicy, ['post/published', '--model=post']) + command.prompt + .trap('Do you want to register the policy inside the app/policies/main.ts file?') + .accept() + await command.exec() + command.assertSucceeded() command.assertLog('green(DONE:) create app/policies/post/published_policy.ts') + await assert.fileContains('app/policies/post/published_policy.ts', [ + `import Post from '#models/post'`, + `import User from '#models/user'`, + `import { BasePolicy } from '@adonisjs/bouncer'`, + `import { AuthorizerResponse } from '@adonisjs/bouncer/types'`, + `export default class PublishedPolicy extends BasePolicy`, + ]) + await assert.fileContains( - 'app/policies/post/published_policy.ts', - `import Post from '#models/post'` - ) - await assert.fileContains( - 'app/policies/post/published_policy.ts', - `import User from '#models/user'` - ) - await assert.fileContains( - 'app/policies/post/published_policy.ts', - `import { BasePolicy } from '@adonisjs/bouncer'` - ) - await assert.fileContains( - 'app/policies/post/published_policy.ts', - `import { AuthorizerResponse } from '@adonisjs/bouncer/types'` - ) - await assert.fileContains( - 'app/policies/post/published_policy.ts', - `export default class PublishedPolicy extends BasePolicy` + 'app/policies/main.ts', + `PostPublishedPolicy: () => import('#policies/post/published_policy')` ) }) - test('define policy actions', async ({ assert, fs }) => { + test('define policy with actions', async ({ assert, fs }) => { + await fs.createJson('tsconfig.json', {}) + await fs.create('app/policies/main.ts', `export const policies = {}`) + const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) await ace.app.init() ace.ui.switchMode('raw') const command = await ace.create(MakePolicy, ['post', 'view', 'edit', 'delete']) + command.prompt + .trap('Do you want to register the policy inside the app/policies/main.ts file?') + .accept() + await command.exec() + command.assertSucceeded() command.assertLog('green(DONE:) create app/policies/post_policy.ts') - await assert.fileContains('app/policies/post_policy.ts', `import Post from '#models/post'`) - await assert.fileContains('app/policies/post_policy.ts', `import User from '#models/user'`) - await assert.fileContains('app/policies/post_policy.ts', `view(user: User): AuthorizerResponse`) - await assert.fileContains('app/policies/post_policy.ts', `edit(user: User): AuthorizerResponse`) - await assert.fileContains( - 'app/policies/post_policy.ts', - `delete(user: User): AuthorizerResponse` - ) + await assert.fileContains('app/policies/post_policy.ts', [ + `import Post from '#models/post'`, + `import User from '#models/user'`, + `view(user: User): AuthorizerResponse`, + `edit(user: User): AuthorizerResponse`, + `delete(user: User): AuthorizerResponse`, + ]) }) }) From 528fbb04801c1e8f7faf5b9fd350c457f32002a1 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 25 Dec 2023 18:50:51 +0530 Subject: [PATCH 22/27] refactor: upgrade configure hook to use latest APIs --- configure.ts | 7 ++++--- index.ts | 1 - tests/configure.spec.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/configure.ts b/configure.ts index 98f849c..81b62c5 100644 --- a/configure.ts +++ b/configure.ts @@ -8,6 +8,7 @@ */ import type Configure from '@adonisjs/core/commands/configure' +import { stubsRoot } from './stubs/main.js' /** * Configures the package @@ -19,8 +20,8 @@ export async function configure(command: Configure) { * Publish stubs to define abilities and collect * policies */ - await command.publishStub('abilities.stub', {}) - await command.publishStub('policies.stub', {}) + await codemods.makeUsingStub(stubsRoot, 'abilities.stub', {}) + await codemods.makeUsingStub(stubsRoot, 'policies.stub', {}) /** * Register provider @@ -32,7 +33,7 @@ export async function configure(command: Configure) { /** * Publish and register middleware */ - await command.publishStub('initialize_bouncer_middleware.stub', { + await codemods.makeUsingStub(stubsRoot, 'initialize_bouncer_middleware.stub', { entity: command.app.generators.createEntity('initialize_bouncer'), }) await codemods.registerMiddleware('router', [ diff --git a/index.ts b/index.ts index 8d36c16..96d43a9 100644 --- a/index.ts +++ b/index.ts @@ -10,7 +10,6 @@ export * as errors from './src/errors.js' export { Bouncer } from './src/bouncer.js' export { configure } from './configure.js' -export { stubsRoot } from './stubs/main.js' export { BasePolicy } from './src/base_policy.js' export { AuthorizationResponse } from './src/response.js' export { action, allowGuest } from './src/decorators/action.js' diff --git a/tests/configure.spec.ts b/tests/configure.spec.ts index 3daac73..76d8005 100644 --- a/tests/configure.spec.ts +++ b/tests/configure.spec.ts @@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url' import { IgnitorFactory } from '@adonisjs/core/factories' import Configure from '@adonisjs/core/commands/configure' -import { stubsRoot } from '../index.js' +import { stubsRoot } from '../stubs/main.js' const BASE_URL = new URL('./tmp/', import.meta.url) test.group('Configure', (group) => { From 429435d9ab04f6ac9dab826ebd1122b1fc18eeb5 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 25 Dec 2023 18:57:23 +0530 Subject: [PATCH 23/27] fix: convert import path to use unix slash --- commands/make_policy.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/commands/make_policy.ts b/commands/make_policy.ts index 84ff6f1..4236d9c 100644 --- a/commands/make_policy.ts +++ b/commands/make_policy.ts @@ -7,8 +7,9 @@ * file that was distributed with this source code. */ +import { slash } from '@poppinss/utils' +import { extname, relative } from 'node:path' import string from '@adonisjs/core/helpers/string' -import { extname, relative, basename } from 'node:path' import { BaseCommand, args, flags } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' @@ -77,9 +78,8 @@ export default class MakePolicy extends BaseCommand { * Creative relative path for the policy file from * the "./app/policies" directory */ - const policyRelativePath = relative(this.app.policiesPath(), destination).replace( - extname(destination), - '' + const policyRelativePath = slash( + relative(this.app.policiesPath(), destination).replace(extname(destination), '') ) /** From b07d6911498ab1802284c3419da9449b7bdc1dd6 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 25 Dec 2023 19:02:55 +0530 Subject: [PATCH 24/27] chore(release): 3.0.0-2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a2cb48a..309d3d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/bouncer", - "version": "3.0.0-1", + "version": "3.0.0-2", "description": "Authorization layer for AdonisJS", "engines": { "node": ">=18.16.0" From 42c90e3d17c2db4ec11c2d466ca0915986027c79 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 9 Jan 2024 16:30:36 +0530 Subject: [PATCH 25/27] chore: update dependencies --- package.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 309d3d2..f9ceaac 100644 --- a/package.json +++ b/package.json @@ -53,25 +53,25 @@ "adonisjs" ], "devDependencies": { - "@adonisjs/assembler": "^7.0.0-1", - "@adonisjs/core": "^6.1.5-35", - "@adonisjs/eslint-config": "^1.2.0", - "@adonisjs/i18n": "^2.0.0-9", - "@adonisjs/prettier-config": "^1.2.0", - "@adonisjs/tsconfig": "^1.2.0", - "@commitlint/cli": "^18.4.3", - "@commitlint/config-conventional": "^18.4.3", + "@adonisjs/assembler": "^7.0.0", + "@adonisjs/core": "^6.2.0", + "@adonisjs/eslint-config": "^1.2.1", + "@adonisjs/i18n": "^2.0.0", + "@adonisjs/prettier-config": "^1.2.1", + "@adonisjs/tsconfig": "^1.2.1", + "@commitlint/cli": "^18.4.4", + "@commitlint/config-conventional": "^18.4.4", "@japa/assert": "^2.1.0", "@japa/expect-type": "^2.0.1", "@japa/file-system": "^2.1.1", "@japa/runner": "^3.1.1", "@japa/snapshot": "^2.0.4", - "@swc/core": "^1.3.101", - "c8": "^8.0.1", + "@swc/core": "^1.3.102", + "c8": "^9.0.0", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "del-cli": "^5.1.0", - "edge.js": "^6.0.0", + "edge.js": "^6.0.1", "github-label-sync": "^2.3.1", "husky": "^8.0.3", "np": "^9.2.0", @@ -84,8 +84,8 @@ "@poppinss/utils": "^6.7.0" }, "peerDependencies": { - "@adonisjs/core": "^6.1.5-34", - "@adonisjs/i18n": "^2.0.0-9" + "@adonisjs/core": "^6.2.0", + "@adonisjs/i18n": "^2.0.0" }, "peerDependenciesMeta": { "@adonisjs/core": { From 5cca17fd89da840576db3b3c7060423dac3f691a Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 9 Jan 2024 16:31:20 +0530 Subject: [PATCH 26/27] refactor: export stubsRoot --- index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/index.ts b/index.ts index 96d43a9..8d36c16 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ export * as errors from './src/errors.js' export { Bouncer } from './src/bouncer.js' export { configure } from './configure.js' +export { stubsRoot } from './stubs/main.js' export { BasePolicy } from './src/base_policy.js' export { AuthorizationResponse } from './src/response.js' export { action, allowGuest } from './src/decorators/action.js' From b74f57026a253771c1470e00a8e466be72aafa59 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 9 Jan 2024 16:31:36 +0530 Subject: [PATCH 27/27] chore: bundle types.ts file via tsup as well --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index f9ceaac..d388315 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "tsup": { "entry": [ "./index.ts", + "./src/types.ts", "./providers/bouncer_provider.ts", "./commands/make_policy.ts", "./src/plugins/edge.ts"