├── .codeclimate.yml ├── .github ├── issue_template.md └── workflows │ ├── node.js.yaml │ └── pkg.pr.new.yaml ├── .gitignore ├── .nvmrc ├── .vscode ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── build.config.ts ├── docs ├── .vitepress │ ├── config.ts │ └── style │ │ └── index.scss ├── channels.md ├── client-side.md ├── cookbook.md ├── getting-started.md ├── gotchas.md ├── hooks.md ├── index.md ├── public │ ├── favicon.ico │ └── img │ │ └── logo.svg └── utils.md ├── eslint.config.mjs ├── netlify.toml ├── package.json ├── pnpm-lock.yaml ├── src ├── channels │ ├── channels.utils.ts │ ├── getChannelsWithReadAbility.ts │ └── index.ts ├── hooks │ ├── authorize │ │ ├── authorize.hook.after.ts │ │ ├── authorize.hook.before.ts │ │ ├── authorize.hook.ts │ │ └── authorize.hook.utils.ts │ ├── checkBasicPermission.hook.ts │ ├── common.ts │ └── index.ts ├── index.ts ├── initialize.ts ├── types.ts └── utils │ ├── checkBasicPermission.ts │ ├── checkCan.ts │ ├── convertRuleToQuery.ts │ ├── couldHaveRestrictingFields.ts │ ├── getAvailableFields.ts │ ├── getFieldsForConditions.ts │ ├── getMethodName.ts │ ├── getMinimalFields.ts │ ├── getModelName.ts │ ├── hasRestrictingConditions.ts │ ├── hasRestrictingFields.ts │ ├── index.ts │ ├── mergeQueryFromAbility.ts │ └── simplifyQuery.ts ├── test ├── app │ └── options.test.ts ├── channels │ ├── .mockServer │ │ ├── config │ │ │ └── default.json │ │ └── index.ts │ ├── channels.utils.test.ts │ ├── custom-actions │ │ ├── channels.custom-actions.test.ts │ │ ├── mockChannels.custom-actions.ts │ │ └── mockServices.custom-actions.ts │ ├── defaultSettings │ │ ├── channels.default.test.ts │ │ ├── mockChannels.default.ts │ │ └── mockServices.default.ts │ ├── receive │ │ ├── channels.receive.test.ts │ │ ├── mockChannels.receive.ts │ │ └── mockServices.receive.ts │ └── withAvailableFields │ │ ├── channels.availableFields.test.ts │ │ ├── mockChannels.availableFields.ts │ │ └── mockServices.availableFields.ts ├── hooks │ ├── authorize.users.test.ts │ ├── authorize │ │ ├── adapters │ │ │ ├── @feathersjs │ │ │ │ ├── knex.test.ts │ │ │ │ ├── memory.around.test.ts │ │ │ │ ├── memory.test.ts │ │ │ │ └── mongodb.test.ts │ │ │ ├── feathers-mongoose.test_.ts │ │ │ ├── feathers-nedb.test_.ts │ │ │ ├── feathers-objection.test_.ts │ │ │ ├── feathers-sequelize.test.ts │ │ │ └── makeTests │ │ │ │ ├── _makeTests.types.ts │ │ │ │ ├── create-multi.ts │ │ │ │ ├── create.ts │ │ │ │ ├── find.ts │ │ │ │ ├── get.ts │ │ │ │ ├── index.ts │ │ │ │ ├── patch-data.ts │ │ │ │ ├── patch-multi.ts │ │ │ │ ├── patch.ts │ │ │ │ ├── remove-multi.ts │ │ │ │ ├── remove.ts │ │ │ │ ├── update-data.ts │ │ │ │ └── update.ts │ │ ├── authorize.general.test.ts │ │ ├── authorize.options.method.test.ts │ │ ├── authorize.options.test.ts │ │ └── authorize.relations.test.ts │ └── checkBasicPermission.test.ts ├── index.test.ts ├── test-utils.ts └── utils │ ├── checkCan.test.ts │ ├── convertRuleToQuery.test.ts │ ├── getFieldsForConditions.test.ts │ ├── getMinimalFields.test.ts │ ├── getModelName.test.ts │ ├── hasRestrictingFields.test.ts │ └── simplifyQuery.test.ts ├── tsconfig.json └── vite.config.ts /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" # required to adjust maintainability checks 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 6 6 | complex-logic: 7 | config: 8 | threshold: 50 9 | file-lines: 10 | config: 11 | threshold: 300 12 | method-complexity: 13 | config: 14 | threshold: 8 15 | method-count: 16 | config: 17 | threshold: 20 18 | method-lines: 19 | config: 20 | threshold: 100 21 | nested-control-flow: 22 | config: 23 | threshold: 6 24 | return-statements: 25 | config: 26 | threshold: 6 27 | similar-code: 28 | config: 29 | threshold: 50 30 | identical-code: 31 | config: 32 | threshold: 25 33 | plugins: 34 | duplication: 35 | enabled: true 36 | config: 37 | count_threshold: 3 38 | exclude_patterns: 39 | - "**/test/*" 40 | - "**/dist/*" 41 | - "**/docs/*" 42 | - "**/coverage/*" -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Steps to reproduce 2 | 3 | - [ ] Tell me what broke. The more detailed the better. 4 | - [ ] If you can, please create a simple example that reproduces the issue and link to a gist, jsbin, repo, etc. This makes it much easier to debug and issues that have a reproducable example will get higher priority. 5 | 6 | ### Expected behavior 7 | Tell me what should happen 8 | 9 | ### Actual behavior 10 | Tell me what happens instead 11 | 12 | ### System configuration 13 | 14 | Tell me about the applicable parts of your setup. 15 | 16 | **Module versions**: 17 | 18 | **NodeJS version**: -------------------------------------------------------------------------------- /.github/workflows/node.js.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [20.x, 22.x] 14 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - run: npm install -g corepack@latest 19 | - run: corepack enable 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: "pnpm" 25 | - run: pnpm i 26 | - run: pnpm run lint 27 | - run: pnpm run test 28 | 29 | coverage: 30 | name: coverage 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - run: npm install -g corepack@latest 36 | - run: corepack enable 37 | - uses: actions/setup-node@v4 38 | with: 39 | node-version: "22" 40 | cache: "pnpm" 41 | - run: pnpm i 42 | - uses: paambaati/codeclimate-action@v9 43 | env: 44 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 45 | with: 46 | coverageCommand: pnpm run coverage 47 | 48 | build: 49 | name: build 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | 54 | - run: npm install -g corepack@latest 55 | - run: corepack enable 56 | - uses: actions/setup-node@v4 57 | with: 58 | node-version: "22" 59 | cache: "pnpm" 60 | - run: pnpm i 61 | - run: pnpm run build 62 | -------------------------------------------------------------------------------- /.github/workflows/pkg.pr.new.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Any Commit 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v4 11 | 12 | - run: npm install -g corepack@latest 13 | - run: corepack enable 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | cache: "pnpm" 18 | 19 | - name: Install dependencies 20 | run: pnpm install 21 | 22 | - name: Build 23 | run: pnpm build 24 | 25 | - run: pnpx pkg-pr-new publish 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test/.data/** 2 | 3 | # Logs 4 | *.log 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | .nyc_output 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # IDE - JetBrains 22 | *.http 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directory 28 | # Commenting this out is preferred by some people, see 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 30 | node_modules 31 | 32 | # Users Environment Variables 33 | .lock-wscript 34 | 35 | # IDEs and editors (shamelessly copied from @angular/cli's .gitignore) 36 | /.idea 37 | .project 38 | .classpath 39 | .c9/ 40 | *.launch 41 | .settings/ 42 | *.sublime-workspace 43 | 44 | # IDE - VSCode 45 | .vscode/* 46 | !.vscode/settings.json 47 | !.vscode/tasks.json 48 | !.vscode/launch.json 49 | !.vscode/extensions.json 50 | 51 | ### Linux ### 52 | *~ 53 | 54 | # temporary files which can be created if a process still has a handle open of a deleted file 55 | .fuse_hidden* 56 | 57 | # KDE directory preferences 58 | .directory 59 | 60 | # Linux trash folder which might appear on any partition or disk 61 | .Trash-* 62 | 63 | # .nfs files are created when an open file is removed but is still being accessed 64 | .nfs* 65 | 66 | ### OSX ### 67 | *.DS_Store 68 | .AppleDouble 69 | .LSOverride 70 | 71 | # Icon must end with two \r 72 | Icon 73 | 74 | 75 | # Thumbnails 76 | ._* 77 | 78 | # Files that might appear in the root of a volume 79 | .DocumentRevisions-V100 80 | .fseventsd 81 | .Spotlight-V100 82 | .TemporaryItems 83 | .Trashes 84 | .VolumeIcon.icns 85 | .com.apple.timemachine.donotpresent 86 | 87 | # Directories potentially created on remote AFP share 88 | .AppleDB 89 | .AppleDesktop 90 | Network Trash Folder 91 | Temporary Items 92 | .apdisk 93 | 94 | ### Windows ### 95 | # Windows thumbnail cache files 96 | Thumbs.db 97 | ehthumbs.db 98 | ehthumbs_vista.db 99 | 100 | # Folder config file 101 | Desktop.ini 102 | 103 | # Recycle Bin used on file shares 104 | $RECYCLE.BIN/ 105 | 106 | # Windows Installer files 107 | *.cab 108 | *.msi 109 | *.msm 110 | *.msp 111 | 112 | # Windows shortcuts 113 | *.lnk 114 | 115 | # dot_env 116 | .env.* 117 | 118 | # dist 119 | dist/** 120 | 121 | docs/.vitepress/dist 122 | docs/.vitepress/cache 123 | 124 | .cache 125 | .temp 126 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.12.2 -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug Current Test File", 8 | "autoAttachChildProcesses": true, 9 | "skipFiles": ["/**", "**/node_modules/**"], 10 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 11 | "args": ["run", "${relativeFile}"], 12 | "smartStep": true, 13 | "console": "integratedTerminal" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "search.exclude": { 4 | "**/node_modules": true, 5 | "**/public": true, 6 | "**/package-lock.json": true 7 | }, 8 | "workbench.colorCustomizations": { 9 | "activityBar.background": "#29b757", 10 | "titleBar.activeBackground": "#29b757", 11 | "titleBar.activeForeground": "#FAFBF4" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [Discord](https://discord.gg/qa8kez8QBx). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Frederik Schmatz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feathers-casl 2 | 3 |

4 | 5 |

6 | 7 | [![npm](https://img.shields.io/npm/v/feathers-casl)](https://www.npmjs.com/package/feathers-casl) 8 | [![Github CI](https://github.com/fratzinger/feathers-casl/actions/workflows/node.js.yml/badge.svg)](https://github.com/fratzinger/feathers-casl/actions) 9 | [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/fratzinger/feathers-casl)](https://codeclimate.com/github/fratzinger/feathers-casl) 10 | [![Code Climate coverage](https://img.shields.io/codeclimate/coverage/fratzinger/feathers-casl)](https://codeclimate.com/github/fratzinger/feathers-casl) 11 | [![libraries.io](https://img.shields.io/librariesio/release/npm/feathers-casl)](https://libraries.io/npm/feathers-casl) 12 | [![npm](https://img.shields.io/npm/dm/feathers-casl)](https://www.npmjs.com/package/feathers-casl) 13 | [![GitHub license](https://img.shields.io/github/license/fratzinger/feathers-casl)](https://github.com/fratzinger/feathers-casl/blob/main/LICENSE) 14 | [![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://discord.gg/qa8kez8QBx) 15 | 16 | > NOTE: This is the version for Feathers v5. For Feathers v4 use [feathers-casl v0](https://github.com/fratzinger/feathers-casl/tree/crow) 17 | 18 | ## About 19 | 20 | Add access control with CASL to your feathers application. 21 | 22 | This project is built for [FeathersJS](http://feathersjs.com). An open source web framework for building modern real-time applications. 23 | It's based on [CASL](https://casl.js.org/) and is a convenient layer to use **CASL** in feathers.js. 24 | 25 | ## Features 26 | 27 | - Fully powered by Feathers 5 & CASL 6 28 | - Allows permissions for all methods `create`, `find`, `get`, `update`, `patch`, `remove`, or `create`, `read`, `update`, `delete` 29 | - Define permissions not based on methods: `can('view', 'Settings')` (Bring your custom logic) 30 | - Restrict by conditions: `can('create', 'Task', { userId: user.id })` 31 | - Restrict by individual fields: `cannot('update', 'User', ['roleId'])` 32 | - Native support for restrictive `$select`: `can('read', 'User', ['id', 'username'])` -> `$select: ['id', 'username']` 33 | - Support to define abilities for anything (providers, users, roles, 3rd party apps, ...) 34 | - Fully supported adapters: `@feathersjs/knex`, `@feathersjs/memory`, `@feathersjs/mongodb`, `feathers-sequelize`, not supported: `feathers-mongoose`, `feathers-nedb`, `feathers-objection` 35 | - Support for dynamic rules stored in your database (Bring your own implementation ;) ) 36 | - hooks: 37 | - `checkBasicPermission` hook for client side usage as a before-hook 38 | - `authorize` hook for complex rules 39 | - Disallow/allow `multi` methods (`create`, `patch`, `remove`) dynamically with: `can('remove-multi', 'Task', { userId: user.id })` 40 | - channels: 41 | - every connection only receives updates based on rules 42 | - `channels`-support also regards restrictive fields 43 | - rules can be defined individually for events 44 | - utils: 45 | - `checkCan` to be used in hooks to check authorization before operations 46 | - Baked in support for `@casl/angular`, `@casl/react`, `@casl/vue` and `@casl/aurelia` 47 | 48 | ## Documentation 49 | 50 | You need more information? Please have a look: 51 | https://feathers-casl.netlify.app/ 52 | 53 | ## Installation 54 | 55 | ```bash 56 | npm i feathers-casl @casl/ability 57 | ``` 58 | 59 | ## Testing 60 | 61 | Simply run `npm test` and all your tests in the `test/` directory will be run. It has full support for _Visual Studio Code_. You can use the debugger to set breakpoints. 62 | 63 | ## Help 64 | 65 | For more information on all the things you can do, visit [FeathersJS](http://docs.feathersjs.com) and [CASL](https://casl.js.org/v6/en/). 66 | 67 | ## License 68 | 69 | Licensed under the [MIT license](LICENSE). 70 | 71 | Deploys by Netlify -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | import pkg from './package.json' 3 | 4 | export default defineBuildConfig({ 5 | entries: ['./src/index'], 6 | outDir: './dist', 7 | declaration: true, 8 | externals: [ 9 | ...Object.keys(pkg.dependencies), 10 | ...Object.keys(pkg.devDependencies), 11 | ], 12 | rollup: { 13 | emitCJS: true, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | export default defineConfig({ 4 | title: 'feathers-casl', 5 | description: 'Add access control with CASL to your feathers application', 6 | head: [['link', { rel: 'icon', href: '/favicon.ico' }]], 7 | themeConfig: { 8 | logo: '/img/logo.svg', 9 | editLink: { 10 | pattern: 11 | 'https://github.com/fratzinger/feathers-casl/edit/main/docs/:path', 12 | text: 'Edit this page on GitHub', 13 | }, 14 | lastUpdatedText: 'Last Updated', 15 | socialLinks: [ 16 | { 17 | icon: 'twitter', 18 | link: 'https://twitter.com/feathersjs', 19 | }, 20 | { 21 | icon: 'discord', 22 | link: 'https://discord.gg/qa8kez8QBx', 23 | }, 24 | { icon: 'github', link: 'https://github.com/fratzinger/feathers-casl' }, 25 | ], 26 | sidebar: [ 27 | { 28 | text: 'Guide', 29 | items: [ 30 | { text: 'Getting Started', link: '/getting-started' }, 31 | { text: 'Hooks', link: '/hooks' }, 32 | { text: 'Channels', link: '/channels' }, 33 | { text: 'Utils', link: '/utils' }, 34 | { text: 'Client Side', link: '/client-side' }, 35 | { text: 'Gotchas', link: '/gotchas' }, 36 | { text: 'Cookbook', link: '/cookbook' }, 37 | ], 38 | }, 39 | ], 40 | nav: [ 41 | { 42 | text: 'Ecosystem', 43 | items: [ 44 | { 45 | text: 'www.feathersjs.com', 46 | link: 'https://feathersjs.com/', 47 | }, 48 | { 49 | text: 'Feathers Github Repo', 50 | link: 'https://github.com/feathersjs/feathers', 51 | }, 52 | { 53 | text: 'Awesome Feathersjs', 54 | link: 'https://github.com/feathersjs/awesome-feathersjs', 55 | }, 56 | ], 57 | }, 58 | ], 59 | footer: { 60 | message: 'Released under the MIT License.', 61 | copyright: 62 | 'Copyright © 2020-present Frederik Schmatz
This site is powered by Netlify', 63 | }, 64 | algolia: { 65 | appId: 'XJKV0V1N7U', 66 | apiKey: 'a4c3e7c6c2fcd7b1baa2ac04e17b9f72', 67 | indexName: 'feathers-casl', 68 | }, 69 | }, 70 | }) 71 | -------------------------------------------------------------------------------- /docs/.vitepress/style/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | // brand colors 3 | --c-brand: #29b757; 4 | --c-brand-light: #3ec96a; 5 | 6 | // background colors 7 | --c-bg: #ffffff; 8 | --c-bg-light: #f3f4f5; 9 | --c-bg-lighter: #eeeeee; 10 | --c-bg-navbar: var(--c-bg); 11 | --c-bg-sidebar: var(--c-bg); 12 | --c-bg-arrow: #cccccc; 13 | 14 | // text colors 15 | --c-text: #2c3e50; 16 | --c-text-accent: var(--c-brand); 17 | --c-text-light: #3a5169; 18 | --c-text-lighter: #4e6e8e; 19 | --c-text-lightest: #6a8bad; 20 | --c-text-quote: #999999; 21 | 22 | // border colors 23 | --c-border: #eaecef; 24 | --c-border-dark: #dfe2e5; 25 | 26 | // custom container colors 27 | --c-tip: #42b983; 28 | --c-tip-bg: var(--c-bg-light); 29 | --c-tip-title: var(--c-text); 30 | --c-tip-text: var(--c-text); 31 | --c-tip-text-accent: var(--c-text-accent); 32 | --c-warning: #e7c000; 33 | --c-warning-bg: #fffae3; 34 | --c-warning-title: #ad9000; 35 | --c-warning-text: #746000; 36 | --c-warning-text-accent: var(--c-text); 37 | --c-danger: #cc0000; 38 | --c-danger-bg: #ffe0e0; 39 | --c-danger-title: #990000; 40 | --c-danger-text: #660000; 41 | --c-danger-text-accent: var(--c-text); 42 | --c-details-bg: #eeeeee; 43 | 44 | // badge component colors 45 | --c-badge-tip: var(--c-tip); 46 | --c-badge-warning: var(--c-warning); 47 | --c-badge-danger: var(--c-danger); 48 | 49 | // transition vars 50 | --t-color: 0.3s ease; 51 | --t-transform: 0.3s ease; 52 | 53 | // code blocks vars 54 | --code-bg-color: #282c34; 55 | --code-hl-bg-color: rgba(0, 0, 0, 0.66); 56 | --code-ln-color: #9e9e9e; 57 | --code-ln-wrapper-width: 3.5rem; 58 | 59 | // font vars 60 | --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 61 | Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 62 | --font-family-code: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 63 | 64 | // layout vars 65 | --navbar-height: 3.6rem; 66 | --navbar-padding-v: 0.7rem; 67 | --navbar-padding-h: 1.5rem; 68 | --sidebar-width: 20rem; 69 | --sidebar-width-mobile: calc(var(--sidebar-width) * 0.82); 70 | --content-width: 740px; 71 | --homepage-width: 960px; 72 | } 73 | 74 | html.dark { 75 | // brand colors 76 | --c-brand: #29b757; 77 | --c-brand-light: #3ec96a; 78 | 79 | // background colors 80 | --c-bg: #22272e; 81 | --c-bg-light: #2b313a; 82 | --c-bg-lighter: #262c34; 83 | 84 | // text colors 85 | --c-text: #adbac7; 86 | --c-text-light: #96a7b7; 87 | --c-text-lighter: #8b9eb0; 88 | --c-text-lightest: #8094a8; 89 | 90 | // border colors 91 | --c-border: #3e4c5a; 92 | --c-border-dark: #34404c; 93 | 94 | // custom container colors 95 | --c-tip: #318a62; 96 | --c-warning: #ceab00; 97 | --c-warning-bg: #7e755b; 98 | --c-warning-title: #ceac03; 99 | --c-warning-text: #362e00; 100 | --c-danger: #940000; 101 | --c-danger-bg: #806161; 102 | --c-danger-title: #610000; 103 | --c-danger-text: #3a0000; 104 | --c-details-bg: #323843; 105 | 106 | // code blocks vars 107 | --code-hl-bg-color: #363b46; 108 | } 109 | 110 | // plugin-docsearch 111 | html.dark .DocSearch { 112 | --docsearch-logo-color: var(--c-text); 113 | --docsearch-modal-shadow: inset 1px 1px 0 0 #2c2e40, 0 3px 8px 0 #000309; 114 | --docsearch-key-shadow: inset 0 -2px 0 0 #282d55, inset 0 0 1px 1px #51577d, 115 | 0 2px 2px 0 rgba(3, 4, 9, 0.3); 116 | --docsearch-key-gradient: linear-gradient(-225deg, #444950, #1c1e21); 117 | --docsearch-footer-shadow: inset 0 1px 0 0 rgba(73, 76, 106, 0.5), 118 | 0 -4px 8px 0 rgba(0, 0, 0, 0.2); 119 | } 120 | -------------------------------------------------------------------------------- /docs/client-side.md: -------------------------------------------------------------------------------- 1 | # Client side 2 | 3 | ## General 4 | 5 | Thanks to the awesome work of [stalniy](https://github.com/stalniy) and his isomorphic approach, you can use the packages `@casl/ability`, `@casl/angular`, `@casl/react`, `@casl/vue` and `@casl/aurelia`. It's pretty straight forward. 6 | 7 | ### Feathers client 8 | 9 | Since we defined the `ability` and `rules` in the `authentication:after/create` hook (see: [Getting Started](/getting-started.html#add-abilities-to-hooks-context)), we can use it on the client side. 10 | 11 | ## Angular 12 | 13 | ```bash 14 | npm install @casl/angular @casl/ability 15 | # or 16 | yarn add @casl/angular @casl/ability 17 | ``` 18 | 19 | You're interested how it works for `angular`? Sorry, I can't help with that, since I just use it with `Vue`. As a starting point, see [#Feathers client](#feathers-client) and [@casl/angular](https://casl.js.org/v6/en/package/casl-angular). That will get you closer to the goal. If you got a working example anyway, I would be curious, how it works. Please create a issue or pull request and let me know. 20 | 21 | ## React 22 | 23 | ```bash 24 | npm install @casl/react @casl/ability 25 | # or 26 | yarn add @casl/react @casl/ability 27 | ``` 28 | 29 | You're interested how it works for `react`? Sorry, I can't help with that, since I just use it with `Vue`. As a starting point, see [#Feathers client](#feathers-client) and [@casl/react](https://casl.js.org/v6/en/package/casl-react). That will get you closer to the goal. If you got a working example anyway, I would be curious, how it works. Please create a issue or pull request and let me know. 30 | 31 | ## FeathersVuex 32 | 33 | ### Installation 34 | 35 | ```bash 36 | npm install @casl/vue @casl/ability 37 | # or 38 | yarn add @casl/vue @casl/ability 39 | ``` 40 | 41 | `FeathersVuex` differs from the general implementation. It's based on the huge amount of sugar [@marshallswain](https://github.com/feathersjs-ecosystem/feathers-vuex) has spread on top of that. 42 | 43 | There are some things we want to ensure: 44 | 45 | - get rules on `authenticate` 46 | - delete rules on `logout` 47 | 48 | The best way to keep the rules, is in our `vuex`-store. So first, we add a custom `casl`-vuex-plugin. 49 | 50 | #### The vuex-plugin 51 | 52 | ```ts 53 | // src/store/vuex.plugin.casl.ts 54 | import { 55 | Ability, 56 | createAliasResolver, 57 | detectSubjectType as defaultDetector 58 | } from "@casl/ability"; 59 | import { BaseModel } from "@/src/store/feathers/client.js"; 60 | 61 | const detectSubjectType = (subject) => { 62 | if (typeof subject === "string") return subject; 63 | if (!(subject instanceof BaseModel)) return defaultDetector(subject); 64 | return subject.constructor.servicePath; 65 | }; 66 | 67 | const resolveAction = createAliasResolver({ 68 | update: "patch", // define the same rules for update & patch 69 | read: ["get", "find"], // use 'read' as a equivalent for 'get' & 'find' 70 | delete: "remove" // use 'delete' or 'remove' 71 | }); 72 | 73 | const ability = new Ability([], { detectSubjectType, resolveAction }); 74 | 75 | const caslPlugin = (store) => { 76 | store.registerModule("casl", { 77 | namespaced: true, 78 | state: { 79 | ability: ability, 80 | rules: [] 81 | }, 82 | mutations: { 83 | setRules(state, rules) { 84 | state.rules = rules; 85 | state.ability.update(rules); 86 | } 87 | } 88 | }); 89 | store.subscribeAction({ 90 | after: (action, state) => { 91 | if (action.type === "auth/responseHandler") { 92 | const { rules } = action.payload; 93 | if (!rules || !state.auth.user) { 94 | store.commit("casl/setRules", []); 95 | return; 96 | } 97 | 98 | store.commit("casl/setRules", rules); 99 | } else if (action.type === "auth/logout") { 100 | store.commit("casl/setRules", []); 101 | } 102 | } 103 | }); 104 | }; 105 | 106 | export { ability, caslPlugin }; 107 | ``` 108 | 109 | #### Insert the vuex-module 110 | 111 | ```ts 112 | // src/store/index.ts 113 | 114 | import { caslPlugin } from '@/store/vuex.plugin.casl'; // your previously defined file 115 | 116 | export const store = new Vuex.Store({ 117 | plugins: [ 118 | caslPlugin, 119 | ... 120 | ], 121 | ... 122 | }); 123 | ``` 124 | 125 | #### register the @casl/vue plugin 126 | 127 | Now it's more like conventional `@casl/vue` work and we're almost done. It's mostly like the [Getting started of @casl/vue](https://casl.js.org/v6/en/package/casl-vue#getting-started) 128 | 129 | ```ts 130 | // main.ts 131 | 132 | import Vue from "vue"; 133 | import { abilitiesPlugin } from "@casl/vue"; 134 | import { store } from "@/store"; 135 | 136 | Vue.use(abilitiesPlugin, store.state.casl.ability); 137 | ``` 138 | 139 | ### Just use it 140 | 141 | From here on, just follow the instructions at [@casl/vue](https://casl.js.org/v6/en/package/casl-vue#getting-started). For example: 142 | 143 | ```vue 144 | 152 | 153 | 162 | ``` 163 | 164 | ## Aurelia 165 | 166 | ```bash 167 | npm install @casl/aurelia @casl/ability 168 | # or 169 | yarn add @casl/aurelia @casl/ability 170 | ``` 171 | 172 | You're interested how it works for `aurelia`? Sorry, I can't help with that, since I just use it with `Vue`. As a starting point, see [#Feathers client](#feathers-client) and [@casl/aurelia](https://casl.js.org/v6/en/package/casl-aurelia). That will get you closer to the goal. If you got a working example anyway, I would be curious, how it works. Please create a issue or pull request and let me know. 173 | 174 | ## Others 175 | 176 | The listed examples above don't fit your needs? Sorry, I can't help with that, since I just use it with `Vue`. As a starting point, see [#Feathers client](#feathers-client). That will get you closer to the goal. If you got a working example anyway, I would be curious, how it works. Please create a issue or pull request and let me know. 177 | -------------------------------------------------------------------------------- /docs/cookbook.md: -------------------------------------------------------------------------------- 1 | # Cookbook 2 | 3 | ## Get availableFields for adapters 4 | 5 | ### feathers-sequelize 6 | 7 | ```ts 8 | availableFields(context) { 9 | const { rawAttributes } = context.service.Model; 10 | return Object.keys(rawAttributes); 11 | } 12 | ``` 13 | -------------------------------------------------------------------------------- /docs/gotchas.md: -------------------------------------------------------------------------------- 1 | # Gotchas 2 | 3 | Here is the place for common mistakes for defined rules and unexpected behavior. 4 | 5 | ## General 6 | 7 | ### Mind the order of your rules 8 | 9 | The following example results in an error, because all rules will be considered in the given order. Rules overwrite/specify the rules before. 10 | 11 | ```ts 12 | const ability = defineAbility((can, cannot) => { 13 | can("read", "users"); 14 | cannot("read", "users"); 15 | }); 16 | ``` 17 | 18 | ## Fields 19 | 20 | ### Conditional subset 21 | 22 | ```ts 23 | const user = { id: 1 }; 24 | 25 | const ability = defineAbility((can, cannot) => { 26 | can("read", "users", ["id", "name", "email"]); 27 | can("read", "users", ["password"], { id: user.id }); 28 | }); 29 | ``` 30 | 31 | #### What do you expect? 32 | 33 | Do you think it results in `{ $select: ["id", "name", "email", "password"] }` for the current user and `{ $select: ["id", "name", "email"] }` for all other users? 34 | 35 | #### Actual behavior: 36 | 37 | - current user: `{ $select: [] }` 38 | - all other users: `{ $select: ["id", "name", "email", "password"] }` 39 | 40 | #### correct definition: 41 | 42 | With the following configuration you only get `["id", "name", "email"]` for all other users and the complete user item for the current user. 43 | 44 | ```ts 45 | const user = { id: 1 }; 46 | 47 | const ability = defineAbility((can, cannot) => { 48 | can("read", "users", ["id", "name", "email"], { id: { $ne: 1 } }); 49 | can("read", "users", { id: user.id }); 50 | }); 51 | ``` 52 | 53 | ## You're not allowed to get on 'users' 54 | 55 | To prevent the error `You're not allowed to get on 'users'`, you need to define the abilities right after your `authenticate()` hook and before the `authorize()` hook for the `get` method of the user service. 56 | 57 | ```ts 58 | // src/services/users/users.hooks.js 59 | 60 | export default { 61 | before: { 62 | get: [ 63 | authenticate('jwt'), 64 | 65 | // Add this to set abilities, if a user exists 66 | context => { 67 | if (context.params.ability) { return context; } 68 | const { user } = context.params 69 | if (user) context.params.ability = defineAbilitiesFor(user) 70 | return context 71 | } 72 | 73 | authorize({ adapter: '@feathersjs/mongodb' }), 74 | ] 75 | 76 | // ... 77 | }, 78 | 79 | // ... 80 | }; 81 | ``` 82 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | hero: 5 | name: feathers-casl 6 | # text: Vite & Vue Powered Static Site Generator 7 | image: 8 | src: /img/logo.svg 9 | alt: feathers-casl 10 | tagline: Add access control with CASL to your feathers application. 11 | actions: 12 | - theme: brand 13 | text: Get Started 14 | link: /getting-started 15 | - theme: alt 16 | text: View on GitHub 17 | link: https://github.com/fratzinger/feathers-casl 18 | 19 | features: 20 | - icon: 🛡️ 21 | title: Decide who can do what 22 | details: Make permissions for create, read, update or delete (even separate multiple support) 23 | - icon: ❓ 24 | title: complex access control with fields & conditions 25 | details: Simple query syntax supports multiple, nested, and even recursive populates. 26 | - icon: 💬 27 | title: Built in channels support 28 | details: Channels aren't a after thought but a first class citizen for permissions. 29 | --- 30 | 31 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fratzinger/feathers-casl/41e32ac5d19190b5b6366df23a4815807191e2a8/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import config from '@feathers-community/eslint-config' 2 | 3 | export default config() 4 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "/" 3 | publish = "docs/.vitepress/dist" 4 | command = "pnpm docs:build" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feathers-casl", 3 | "version": "2.2.1", 4 | "description": "Add access control with CASL to your feathers application.", 5 | "author": "fratzinger", 6 | "homepage": "https://feathers-casl.netlify.app/", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/fratzinger/feathers-casl" 10 | }, 11 | "keywords": [ 12 | "feathers", 13 | "feathers.js", 14 | "feathers-plugin", 15 | "casl", 16 | "permissions", 17 | "authorization" 18 | ], 19 | "license": "MIT", 20 | "type": "module", 21 | "exports": { 22 | ".": { 23 | "types": "./dist/index.d.ts", 24 | "import": "./dist/index.mjs", 25 | "require": "./dist/index.cjs" 26 | } 27 | }, 28 | "main": "./dist/index.cjs", 29 | "module": "./dist/index.mjs", 30 | "types": "./dist/index.d.ts", 31 | "files": [ 32 | "CHANGELOG.md", 33 | "LICENSE", 34 | "README.md", 35 | "src/**", 36 | "dist/**" 37 | ], 38 | "engines": { 39 | "node": ">= 20.0.0" 40 | }, 41 | "packageManager": "pnpm@9.15.0", 42 | "scripts": { 43 | "build": "unbuild", 44 | "version": "npm run build", 45 | "release": "np", 46 | "vitest": "vitest", 47 | "test": "vitest run", 48 | "coverage": "vitest run --coverage", 49 | "lint": "eslint", 50 | "docs": "vitepress dev docs", 51 | "docs:build": "vitepress build docs" 52 | }, 53 | "peerDependencies": { 54 | "@casl/ability": "6.x", 55 | "@feathersjs/feathers": "^5.0.0" 56 | }, 57 | "dependencies": { 58 | "@feathersjs/errors": "^5.0.33", 59 | "@feathersjs/feathers": "^5.0.33", 60 | "@feathersjs/transport-commons": "^5.0.33", 61 | "feathers-hooks-common": "^8.2.1", 62 | "feathers-utils": "^7.0.0", 63 | "lodash": "^4.17.21" 64 | }, 65 | "devDependencies": { 66 | "@casl/ability": "^6.7.3", 67 | "@feathers-community/eslint-config": "^0.0.4", 68 | "@feathersjs/adapter-commons": "^5.0.33", 69 | "@feathersjs/authentication": "^5.0.33", 70 | "@feathersjs/authentication-local": "^5.0.33", 71 | "@feathersjs/configuration": "^5.0.33", 72 | "@feathersjs/express": "^5.0.33", 73 | "@feathersjs/knex": "^5.0.33", 74 | "@feathersjs/memory": "^5.0.33", 75 | "@feathersjs/mongodb": "^5.0.33", 76 | "@feathersjs/socketio": "^5.0.33", 77 | "@feathersjs/socketio-client": "^5.0.33", 78 | "@seald-io/nedb": "^4.0.4", 79 | "@tsconfig/node22": "^22.0.0", 80 | "@types/lodash": "^4.17.16", 81 | "@types/node": "^22.13.9", 82 | "@vitest/coverage-v8": "^3.0.7", 83 | "cors": "^2.8.5", 84 | "eslint": "^9.21.0", 85 | "feathers-fletching": "^2.0.3", 86 | "feathers-knex": "^8.0.1", 87 | "feathers-mongoose": "^8.5.1", 88 | "feathers-nedb": "^7.0.1", 89 | "feathers-objection": "^7.6.0", 90 | "feathers-sequelize": "^7.0.3", 91 | "get-port": "^7.1.0", 92 | "helmet": "^8.0.0", 93 | "knex": "^3.1.0", 94 | "mongodb": "^6.14.1", 95 | "mongodb-memory-server": "^10.1.4", 96 | "mongoose": "^8.12.1", 97 | "np": "^10.2.0", 98 | "objection": "^3.1.5", 99 | "prettier": "^3.5.3", 100 | "sequelize": "^6.37.6", 101 | "socket.io-client": "^4.8.1", 102 | "sqlite3": "^5.1.7", 103 | "type-fest": "^4.37.0", 104 | "typescript": "^5.8.2", 105 | "unbuild": "^3.5.0", 106 | "vite": "^6.2.0", 107 | "vitepress": "^1.6.3", 108 | "vitest": "^3.0.7" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/channels/channels.utils.ts: -------------------------------------------------------------------------------- 1 | import { getAvailableFields } from '../utils/index.js' 2 | 3 | import type { Ability, AnyAbility } from '@casl/ability' 4 | 5 | import type { Application, HookContext } from '@feathersjs/feathers' 6 | import type { RealTimeConnection } from '@feathersjs/transport-commons' 7 | 8 | import type { ChannelOptions, EventName, InitOptions } from '../types.js' 9 | 10 | export const makeChannelOptions = ( 11 | app: Application, 12 | options?: Partial, 13 | ): ChannelOptions => { 14 | options = options || {} 15 | return Object.assign({}, defaultOptions, getAppOptions(app), options) 16 | } 17 | 18 | const defaultOptions: Omit = { 19 | activated: true, 20 | channelOnError: ['authenticated'], 21 | 22 | ability: (app: Application, connection: RealTimeConnection): Ability => { 23 | return connection.ability 24 | }, 25 | modelName: (context) => context.path, 26 | restrictFields: true, 27 | availableFields: (context: HookContext): string[] | undefined => { 28 | const availableFields: string[] | ((context: HookContext) => string[]) = 29 | context.service.options?.casl?.availableFields 30 | return getAvailableFields(context, { availableFields }) 31 | }, 32 | useActionName: 'get', 33 | } 34 | 35 | export const makeDefaultOptions = ( 36 | options?: Partial, 37 | ): ChannelOptions => { 38 | return Object.assign({}, defaultOptions, options) 39 | } 40 | 41 | const getAppOptions = ( 42 | app: Application, 43 | ): ChannelOptions | Record => { 44 | const caslOptions: InitOptions = app?.get('casl') 45 | return caslOptions && caslOptions.channels ? caslOptions.channels : {} 46 | } 47 | 48 | export const getAbility = ( 49 | app: Application, 50 | data: Record, 51 | connection: RealTimeConnection, 52 | context: HookContext, 53 | options: Partial, 54 | ): undefined | AnyAbility => { 55 | if (options.ability) { 56 | return typeof options.ability === 'function' 57 | ? options.ability(app, connection, data, context) 58 | : options.ability 59 | } else { 60 | return connection.ability 61 | } 62 | } 63 | 64 | const eventNameMap = { 65 | create: 'created', 66 | update: 'updated', 67 | patch: 'patched', 68 | remove: 'removed', 69 | } satisfies Record 70 | 71 | export const getEventName = (method: string): EventName | undefined => 72 | (eventNameMap as any)[method] 73 | -------------------------------------------------------------------------------- /src/channels/getChannelsWithReadAbility.ts: -------------------------------------------------------------------------------- 1 | import _isEqual from 'lodash/isEqual.js' 2 | import _pick from 'lodash/pick.js' 3 | import _isEmpty from 'lodash/isEmpty.js' 4 | import { Channel } from '@feathersjs/transport-commons' 5 | import { subject } from '@casl/ability' 6 | 7 | import { 8 | makeChannelOptions, 9 | getAbility, 10 | getEventName, 11 | } from './channels.utils.js' 12 | 13 | import { getModelName, getAvailableFields } from '../utils/index.js' 14 | import { hasRestrictingFields } from '../utils/hasRestrictingFields.js' 15 | 16 | import type { RealTimeConnection } from '@feathersjs/transport-commons' 17 | 18 | interface ConnectionsPerField { 19 | fields: false | string[] 20 | connections: RealTimeConnection[] 21 | } 22 | 23 | import type { HookContext, Application } from '@feathersjs/feathers' 24 | import type { ChannelOptions, AnyData } from '../types.js' 25 | 26 | export const getChannelsWithReadAbility = ( 27 | app: Application, 28 | data: AnyData, 29 | context: HookContext, 30 | _options?: Partial, 31 | ): undefined | Channel | Channel[] => { 32 | if (!_options?.channels && !app.channels.length) { 33 | return undefined 34 | } 35 | 36 | const options = makeChannelOptions(app, _options) 37 | const { channelOnError, activated } = options 38 | const modelName = getModelName(options.modelName, context) 39 | 40 | if (!activated || !modelName) { 41 | return !channelOnError ? new Channel() : app.channel(channelOnError) 42 | } 43 | 44 | let channels = options.channels || app.channel(app.channels) 45 | 46 | if (!Array.isArray(channels)) { 47 | channels = [channels] 48 | } 49 | 50 | const dataToTest = subject(modelName, data) 51 | 52 | let method = 'get' 53 | 54 | if (typeof options.useActionName === 'string') { 55 | method = options.useActionName 56 | } else { 57 | const eventName = getEventName(context.method) 58 | if (eventName && options.useActionName[eventName]) { 59 | method = options.useActionName[eventName] as string 60 | } 61 | } 62 | 63 | let result: Channel[] = [] 64 | 65 | if (!options.restrictFields) { 66 | // return all fields for allowed 67 | 68 | result = channels.map((channel) => { 69 | return channel.filter((conn: any) => { 70 | const ability = getAbility(app, data, conn, context, options) 71 | const can = !!ability && ability.can(method, dataToTest) 72 | return can 73 | }) 74 | }) 75 | } else { 76 | // filter by restricted Fields 77 | const connectionsPerFields: ConnectionsPerField[] = [] 78 | 79 | for (let i = 0, n = channels.length; i < n; i++) { 80 | const channel = channels[i] 81 | const { connections } = channel 82 | 83 | for (let j = 0, o = connections.length; j < o; j++) { 84 | const connection = connections[j] 85 | const { ability } = connection 86 | if (!ability || !ability.can(method, dataToTest)) { 87 | // connection cannot read item -> don't send data 88 | continue 89 | } 90 | const availableFields = getAvailableFields(context, options) 91 | 92 | const fields = hasRestrictingFields(ability, method, dataToTest, { 93 | availableFields, 94 | }) 95 | // if fields is true or fields is empty array -> full restriction 96 | if (fields && (fields === true || fields.length === 0)) { 97 | continue 98 | } 99 | const connField = connectionsPerFields.find((x) => 100 | _isEqual(x.fields, fields), 101 | ) 102 | if (connField) { 103 | if (connField.connections.indexOf(connection) !== -1) { 104 | // connection is already in array -> skip 105 | continue 106 | } 107 | connField.connections.push(connection) 108 | } else { 109 | connectionsPerFields.push({ 110 | connections: [connection], 111 | fields: fields as string[] | false, 112 | }) 113 | } 114 | } 115 | } 116 | 117 | for (let i = 0, n = connectionsPerFields.length; i < n; i++) { 118 | const { fields, connections } = connectionsPerFields[i] 119 | const restrictedData = fields ? _pick(data, fields) : data 120 | if (!_isEmpty(restrictedData)) { 121 | result.push(new Channel(connections, restrictedData)) 122 | } 123 | } 124 | } 125 | 126 | return result.length === 1 ? result[0] : result 127 | } 128 | -------------------------------------------------------------------------------- /src/channels/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getChannelsWithReadAbility.js' 2 | export * from './channels.utils.js' 3 | -------------------------------------------------------------------------------- /src/hooks/authorize/authorize.hook.after.ts: -------------------------------------------------------------------------------- 1 | import { replaceItems } from 'feathers-hooks-common' 2 | import { subject } from '@casl/ability' 3 | import _pick from 'lodash/pick.js' 4 | import _isEmpty from 'lodash/isEmpty.js' 5 | 6 | import { shouldSkip, mergeArrays, getItemsIsArray } from 'feathers-utils' 7 | 8 | import { 9 | getPersistedConfig, 10 | getAbility, 11 | makeOptions, 12 | getConditionalSelect, 13 | refetchItems, 14 | HOOKNAME, 15 | } from './authorize.hook.utils.js' 16 | 17 | import { 18 | getAvailableFields, 19 | hasRestrictingFields, 20 | getModelName, 21 | } from '../../utils/index.js' 22 | 23 | import { Forbidden } from '@feathersjs/errors' 24 | 25 | import type { HookContext } from '@feathersjs/feathers' 26 | import type { 27 | AuthorizeHookOptions, 28 | HasRestrictingFieldsOptions, 29 | } from '../../types.js' 30 | import { getMethodName } from '../../utils/getMethodName.js' 31 | 32 | export const authorizeAfter = async ( 33 | context: H, 34 | options: AuthorizeHookOptions, 35 | ) => { 36 | if (shouldSkip(HOOKNAME, context, options) || !context.params) { 37 | return context 38 | } 39 | 40 | // eslint-disable-next-line prefer-const 41 | let { isArray, items } = getItemsIsArray(context, { from: 'result' }) 42 | if (!items.length) { 43 | return context 44 | } 45 | 46 | options = makeOptions(context.app, options) 47 | 48 | const modelName = getModelName(options.modelName, context) 49 | if (!modelName) { 50 | return context 51 | } 52 | 53 | const skipCheckConditions = getPersistedConfig( 54 | context, 55 | 'skipRestrictingRead.conditions', 56 | ) 57 | const skipCheckFields = getPersistedConfig( 58 | context, 59 | 'skipRestrictingRead.fields', 60 | ) 61 | 62 | if (skipCheckConditions && skipCheckFields) { 63 | return context 64 | } 65 | 66 | const { params } = context 67 | 68 | params.ability = await getAbility(context, options) 69 | if (!params.ability) { 70 | // Ignore internal or not authenticated requests 71 | return context 72 | } 73 | 74 | const { ability } = params 75 | 76 | const availableFields = getAvailableFields(context, options) 77 | 78 | const hasRestrictingFieldsOptions: HasRestrictingFieldsOptions = { 79 | availableFields: availableFields, 80 | } 81 | 82 | const getOrFind = isArray ? 'find' : 'get' 83 | 84 | const $select: string[] | undefined = params.query?.$select 85 | 86 | const method = getMethodName(context, options) 87 | 88 | if (method !== 'remove') { 89 | const $newSelect = getConditionalSelect( 90 | $select, 91 | ability, 92 | getOrFind, 93 | modelName, 94 | ) 95 | if ($newSelect) { 96 | const _items = await refetchItems(context) 97 | if (_items) { 98 | items = _items 99 | } 100 | } 101 | } 102 | 103 | const pickFieldsForItem = (item: Record) => { 104 | if ( 105 | !skipCheckConditions && 106 | !ability.can(getOrFind, subject(modelName, item)) 107 | ) { 108 | return undefined 109 | } 110 | 111 | let fields = hasRestrictingFields( 112 | ability, 113 | getOrFind, 114 | subject(modelName, item), 115 | hasRestrictingFieldsOptions, 116 | ) 117 | 118 | if (fields === true) { 119 | // full restriction 120 | return {} 121 | } else if (skipCheckFields || (!fields && !$select)) { 122 | // no restrictions 123 | return item 124 | } else if (fields && $select) { 125 | fields = mergeArrays(fields, $select, 'intersect') as string[] 126 | } else { 127 | fields = fields ? fields : $select 128 | } 129 | 130 | return _pick(item, fields) 131 | } 132 | 133 | let result 134 | if (isArray) { 135 | result = [] 136 | for (let i = 0, n = items.length; i < n; i++) { 137 | const item = pickFieldsForItem(items[i]) 138 | 139 | if (item) { 140 | result.push(item) 141 | } 142 | } 143 | } else { 144 | result = pickFieldsForItem(items[0]) 145 | if (method === 'get' && _isEmpty(result)) { 146 | if (options.actionOnForbidden) options.actionOnForbidden() 147 | throw new Forbidden(`You're not allowed to ${method} ${modelName}`) 148 | } 149 | } 150 | 151 | replaceItems(context, result) 152 | 153 | return context 154 | } 155 | -------------------------------------------------------------------------------- /src/hooks/authorize/authorize.hook.ts: -------------------------------------------------------------------------------- 1 | import { shouldSkip } from 'feathers-utils' 2 | 3 | import { HOOKNAME, makeOptions } from './authorize.hook.utils.js' 4 | import { authorizeAfter } from './authorize.hook.after.js' 5 | import { authorizeBefore } from './authorize.hook.before.js' 6 | 7 | import type { HookContext, NextFunction } from '@feathersjs/feathers' 8 | 9 | import type { AuthorizeHookOptions } from '../../types.js' 10 | 11 | export const authorize = 12 | ( 13 | _options?: Partial, 14 | ) => 15 | async (context: H, next?: NextFunction) => { 16 | if ( 17 | shouldSkip(HOOKNAME, context, _options) || 18 | !context.params || 19 | context.type === 'error' 20 | ) { 21 | return next ? await next() : context 22 | } 23 | 24 | const options = makeOptions(context.app, _options) 25 | 26 | // around hook 27 | if (next) { 28 | await authorizeBefore(context, options) 29 | await next() 30 | await authorizeAfter(context, options) 31 | return context 32 | } 33 | 34 | return context.type === 'before' 35 | ? await authorizeBefore(context, options) 36 | : await authorizeAfter(context, options) 37 | } 38 | -------------------------------------------------------------------------------- /src/hooks/authorize/authorize.hook.utils.ts: -------------------------------------------------------------------------------- 1 | import _get from 'lodash/get.js' 2 | import _set from 'lodash/set.js' 3 | 4 | import { Forbidden } from '@feathersjs/errors' 5 | 6 | import { 7 | getFieldsForConditions, 8 | getAvailableFields, 9 | } from '../../utils/index.js' 10 | import { makeDefaultBaseOptions } from '../common.js' 11 | 12 | import { getItemsIsArray, isMulti, markHookForSkip } from 'feathers-utils' 13 | 14 | import type { AnyAbility, ForcedSubject } from '@casl/ability' 15 | import type { Application, HookContext, Params } from '@feathersjs/feathers' 16 | 17 | import type { 18 | Adapter, 19 | AuthorizeHookOptions, 20 | AuthorizeHookOptionsExclusive, 21 | HookBaseOptions, 22 | InitOptions, 23 | Path, 24 | ThrowUnlessCanOptions, 25 | } from '../../types.js' 26 | import type { Promisable } from 'type-fest' 27 | import { getMethodName } from '../../utils/getMethodName' 28 | 29 | declare module '@feathersjs/feathers' { 30 | interface Params { 31 | ability?: AnyAbility 32 | } 33 | } 34 | 35 | export const HOOKNAME = 'authorize' 36 | 37 | export const makeOptions = ( 38 | app: A, 39 | options?: Partial, 40 | ): AuthorizeHookOptions => { 41 | options = options || {} 42 | return Object.assign( 43 | makeDefaultBaseOptions(), 44 | defaultOptions, 45 | getAppOptions(app), 46 | options, 47 | ) 48 | } 49 | 50 | const defaultOptions: AuthorizeHookOptionsExclusive = { 51 | adapter: undefined, 52 | availableFields: (context): string[] => { 53 | const availableFields: string[] | ((context: HookContext) => string[]) = 54 | context.service.options?.casl?.availableFields 55 | return getAvailableFields(context, { availableFields }) 56 | }, 57 | usePatchData: false, 58 | useUpdateData: false, 59 | } 60 | 61 | export const makeDefaultOptions = ( 62 | options?: Partial, 63 | ): AuthorizeHookOptions => { 64 | return Object.assign(makeDefaultBaseOptions(), defaultOptions, options) 65 | } 66 | 67 | const getAppOptions = ( 68 | app: Application, 69 | ): AuthorizeHookOptions | Record => { 70 | const caslOptions: InitOptions = app?.get('casl') 71 | return caslOptions && caslOptions.authorizeHook 72 | ? caslOptions.authorizeHook 73 | : {} 74 | } 75 | 76 | export const getAdapter = ( 77 | app: Application, 78 | options: Pick, 79 | ): Adapter => { 80 | if (options.adapter) { 81 | return options.adapter 82 | } 83 | const caslAppOptions = app?.get('casl') as InitOptions 84 | if (caslAppOptions?.defaultAdapter) { 85 | return caslAppOptions.defaultAdapter 86 | } 87 | return '@feathersjs/memory' 88 | } 89 | 90 | export const getAbility = ( 91 | context: HookContext, 92 | options?: Pick< 93 | HookBaseOptions, 94 | 'ability' | 'checkAbilityForInternal' | 'method' 95 | >, 96 | ): Promise => { 97 | const method = getMethodName(context, options) 98 | 99 | // if params.ability is set, return it over options.ability 100 | if (context?.params?.ability) { 101 | if (typeof context.params.ability === 'function') { 102 | const ability = context.params.ability(context) 103 | return Promise.resolve(ability) 104 | } else { 105 | return Promise.resolve(context.params.ability) 106 | } 107 | } 108 | 109 | const persistedAbility = getPersistedConfig(context, 'ability') 110 | 111 | if (persistedAbility) { 112 | if (typeof persistedAbility === 'function') { 113 | const ability = persistedAbility(context) 114 | return Promise.resolve(ability) 115 | } else { 116 | return Promise.resolve(persistedAbility) 117 | } 118 | } 119 | 120 | if (!options?.checkAbilityForInternal && !context.params?.provider) { 121 | return Promise.resolve(undefined) 122 | } 123 | 124 | if (options?.ability) { 125 | if (typeof options.ability === 'function') { 126 | const ability = options.ability(context) 127 | return Promise.resolve(ability) 128 | } else { 129 | return Promise.resolve(options.ability) 130 | } 131 | } 132 | 133 | throw new Forbidden(`You're not allowed to ${method} on '${context.path}'`) 134 | } 135 | 136 | export const throwUnlessCan = >( 137 | ability: AnyAbility, 138 | method: string, 139 | resource: string | T, 140 | modelName: string, 141 | options: Partial, 142 | ): boolean => { 143 | if (ability.cannot(method, resource)) { 144 | if (options.actionOnForbidden) options.actionOnForbidden() 145 | if (!options.skipThrow) { 146 | throw new Forbidden(`You are not allowed to ${method} ${modelName}`) 147 | } 148 | return false 149 | } 150 | return true 151 | } 152 | 153 | export const refetchItems = async ( 154 | context: HookContext, 155 | params?: Params, 156 | ): Promise => { 157 | if (!context.result) { 158 | return 159 | } 160 | const { items } = getItemsIsArray(context, { from: 'result' }) 161 | 162 | if (!items) { 163 | return 164 | } 165 | 166 | const idField = context.service.options?.id 167 | const ids = items.map((item) => item[idField]) 168 | 169 | params = Object.assign({}, params, { paginate: false }) 170 | 171 | markHookForSkip(HOOKNAME, 'all', { params } as any) 172 | delete params.ability 173 | 174 | const query = Object.assign({}, params.query, { [idField]: { $in: ids } }) 175 | params = Object.assign({}, params, { query }) 176 | 177 | return await context.service.find(params) 178 | } 179 | 180 | export const getConditionalSelect = ( 181 | $select: string[], 182 | ability: AnyAbility, 183 | method: string, 184 | modelName: string, 185 | ): undefined | string[] => { 186 | if (!$select?.length) { 187 | return undefined 188 | } 189 | const fields = getFieldsForConditions(ability, method, modelName) 190 | if (!fields.length) { 191 | return undefined 192 | } 193 | 194 | const fieldsToAdd = fields.filter((field) => !$select.includes(field)) 195 | if (!fieldsToAdd.length) { 196 | return undefined 197 | } 198 | return [...$select, ...fieldsToAdd] 199 | } 200 | 201 | export const checkMulti = ( 202 | context: HookContext, 203 | ability: AnyAbility, 204 | modelName: string, 205 | options?: Pick, 206 | ): boolean => { 207 | const method = getMethodName(context, options) 208 | const currentIsMulti = isMulti(context) 209 | if (!currentIsMulti) { 210 | return true 211 | } 212 | if ( 213 | (method === 'find' && ability.can(method, modelName)) || 214 | ability.can(`${method}-multi`, modelName) 215 | ) { 216 | return true 217 | } 218 | 219 | if (options?.actionOnForbidden) options.actionOnForbidden() 220 | throw new Forbidden(`You're not allowed to multi-${method} ${modelName}`) 221 | } 222 | 223 | export const setPersistedConfig = ( 224 | context: HookContext, 225 | key: Path, 226 | val: unknown, 227 | ): HookContext => { 228 | return _set(context, `params.casl.${key}`, val) 229 | } 230 | 231 | export function getPersistedConfig( 232 | context: HookContext, 233 | key: 'ability', 234 | ): 235 | | AnyAbility 236 | | ((context: HookContext) => Promisable) 237 | | undefined 238 | export function getPersistedConfig( 239 | context: HookContext, 240 | key: 'skipRestrictingRead.conditions', 241 | ): boolean 242 | export function getPersistedConfig( 243 | context: HookContext, 244 | key: 'skipRestrictingRead.fields', 245 | ): boolean 246 | export function getPersistedConfig( 247 | context: HookContext, 248 | key: 'madeBasicCheck', 249 | ): boolean 250 | 251 | export function getPersistedConfig(context: HookContext, key: Path): any { 252 | return _get(context, `params.casl.${key}`) 253 | } 254 | -------------------------------------------------------------------------------- /src/hooks/checkBasicPermission.hook.ts: -------------------------------------------------------------------------------- 1 | import { shouldSkip } from 'feathers-utils' 2 | 3 | import type { HookContext } from '@feathersjs/feathers' 4 | import type { CheckBasicPermissionHookOptions } from '../types.js' 5 | import { checkBasicPermissionUtil } from '../utils/index.js' 6 | 7 | const HOOKNAME = 'checkBasicPermission' 8 | 9 | export const checkBasicPermission = ( 10 | _options?: Partial, 11 | ): ((context: H) => Promise) => { 12 | return async (context: H): Promise => { 13 | if ( 14 | !_options?.notSkippable && 15 | (shouldSkip(HOOKNAME, context) || 16 | context.type !== 'before' || 17 | !context.params) 18 | ) { 19 | return context 20 | } 21 | 22 | return await checkBasicPermissionUtil(context, _options) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/hooks/common.ts: -------------------------------------------------------------------------------- 1 | import { subject } from '@casl/ability' 2 | import { getItemsIsArray } from 'feathers-utils' 3 | 4 | import { throwUnlessCan } from './authorize/authorize.hook.utils.js' 5 | 6 | import type { AnyAbility } from '@casl/ability' 7 | import type { HookContext } from '@feathersjs/feathers' 8 | import type { 9 | CheckBasicPermissionHookOptions, 10 | HookBaseOptions, 11 | ThrowUnlessCanOptions, 12 | } from '../types.js' 13 | import { getMethodName } from '../utils/getMethodName.js' 14 | 15 | const defaultOptions: HookBaseOptions = { 16 | ability: undefined, 17 | actionOnForbidden: undefined, 18 | checkMultiActions: false, 19 | checkAbilityForInternal: false, 20 | modelName: (context: Pick): string => { 21 | return context.path 22 | }, 23 | notSkippable: false, 24 | } 25 | 26 | export const makeDefaultBaseOptions = (): HookBaseOptions => { 27 | return Object.assign({}, defaultOptions) 28 | } 29 | 30 | export const checkCreatePerItem = ( 31 | context: HookContext, 32 | ability: AnyAbility, 33 | modelName: string, 34 | options: Partial< 35 | Pick 36 | > & 37 | Partial< 38 | Pick 39 | >, 40 | ): HookContext => { 41 | const method = getMethodName(context, options) 42 | if (method !== 'create' || !options.checkCreateForData) { 43 | return context 44 | } 45 | 46 | const checkCreateForData = 47 | typeof options.checkCreateForData === 'function' 48 | ? options.checkCreateForData(context) 49 | : true 50 | 51 | if (!checkCreateForData) { 52 | return context 53 | } 54 | 55 | // we have all information we need (maybe we need populated data?) 56 | const { items } = getItemsIsArray(context, { from: 'data' }) 57 | 58 | for (let i = 0, n = items.length; i < n; i++) { 59 | throwUnlessCan( 60 | ability, 61 | method, 62 | subject(modelName, items[i]), 63 | modelName, 64 | options, 65 | ) 66 | } 67 | 68 | return context 69 | } 70 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { authorize } from './authorize/authorize.hook.js' 2 | export { checkBasicPermission } from './checkBasicPermission.hook.js' 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './hooks/index.js' 2 | export * from './channels/index.js' 3 | export * from './utils/index.js' 4 | 5 | export { initialize as feathersCasl } from './initialize.js' 6 | 7 | export * from './types.js' 8 | -------------------------------------------------------------------------------- /src/initialize.ts: -------------------------------------------------------------------------------- 1 | import { makeDefaultOptions as makeDefaultAuthorizeHookOptions } from './hooks/authorize/authorize.hook.utils.js' 2 | 3 | import { makeDefaultOptions as makeDefaultChannelsOptions } from './channels/channels.utils.js' 4 | 5 | import type { Application } from '@feathersjs/feathers' 6 | import type { PartialDeep } from 'type-fest' 7 | import type { 8 | AuthorizeHookOptions, 9 | ChannelOptions, 10 | InitOptions, 11 | } from './types.js' 12 | 13 | export const initialize = ( 14 | options?: PartialDeep, 15 | ): ((app: Application) => void) => { 16 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 17 | //@ts-ignore 18 | if (options?.version) { 19 | // asserts that you call app.configure(casl({})) instead of app.configure(casl) 20 | throw new Error( 21 | "You passed 'feathers-casl' to app.configure() without a function. You probably wanted to call app.configure(casl({}))!", 22 | ) 23 | } 24 | options = { 25 | defaultAdapter: options?.defaultAdapter || '@feathersjs/memory', 26 | authorizeHook: makeDefaultAuthorizeHookOptions( 27 | options?.authorizeHook as undefined | Partial, 28 | ), 29 | channels: makeDefaultChannelsOptions( 30 | options?.channels as undefined | Partial, 31 | ), 32 | } 33 | return (app: Application): void => { 34 | if (app.get('casl')) { 35 | return 36 | } 37 | app.set('casl', options) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, Application } from '@feathersjs/feathers' 2 | import type { AnyAbility, AnyMongoAbility } from '@casl/ability' 3 | import '@feathersjs/transport-commons' 4 | import type { Channel, RealTimeConnection } from '@feathersjs/transport-commons' 5 | 6 | export type AnyData = Record 7 | 8 | export type Adapter = 9 | | '@feathersjs/memory' 10 | | '@feathersjs/knex' 11 | | '@feathersjs/mongodb' 12 | // | "feathers-mongoose" 13 | // | "feathers-nedb" 14 | // | "feathers-objection" 15 | | 'feathers-sequelize' 16 | 17 | export interface ServiceCaslOptions { 18 | availableFields: string[] 19 | } 20 | 21 | export interface CaslParams { 22 | ability?: A 23 | casl?: { 24 | ability: A | (() => A) 25 | } 26 | } 27 | 28 | export interface HookBaseOptions { 29 | ability: AnyAbility | ((context: H) => AnyAbility | Promise) 30 | actionOnForbidden: undefined | (() => void) 31 | checkAbilityForInternal: boolean 32 | checkMultiActions: boolean 33 | modelName: GetModelName 34 | notSkippable: boolean 35 | method?: string | ((context: H) => string) 36 | } 37 | 38 | export interface CheckBasicPermissionHookOptions< 39 | H extends HookContext = HookContext, 40 | > extends HookBaseOptions { 41 | checkCreateForData: boolean | ((context: H) => boolean) 42 | storeAbilityForAuthorize: boolean 43 | } 44 | 45 | export type CheckBasicPermissionUtilsOptions< 46 | H extends HookContext = HookContext, 47 | > = Omit, 'notSkippable'> 48 | 49 | export type CheckBasicPermissionHookOptionsExclusive< 50 | H extends HookContext = HookContext, 51 | > = Pick< 52 | CheckBasicPermissionHookOptions, 53 | Exclude 54 | > 55 | 56 | export type AvailableFieldsOption = 57 | | string[] 58 | | ((context: H) => string[] | undefined) 59 | | undefined 60 | 61 | export interface AuthorizeChannelCommonsOptions< 62 | H extends HookContext = HookContext, 63 | > { 64 | availableFields: AvailableFieldsOption 65 | } 66 | 67 | export interface AuthorizeHookOptions 68 | extends HookBaseOptions, 69 | AuthorizeChannelCommonsOptions { 70 | adapter: Adapter 71 | useUpdateData: boolean 72 | usePatchData: boolean 73 | } 74 | 75 | export type AuthorizeHookOptionsExclusive = 76 | Pick< 77 | AuthorizeHookOptions, 78 | Exclude, keyof HookBaseOptions> 79 | > 80 | 81 | export type GetModelName = 82 | | string 83 | | ((context: H) => string) 84 | 85 | export type EventName = 'created' | 'updated' | 'patched' | 'removed' 86 | 87 | export interface ChannelOptions extends AuthorizeChannelCommonsOptions { 88 | ability: 89 | | AnyAbility 90 | | (( 91 | app: Application, 92 | connection: RealTimeConnection, 93 | data: unknown, 94 | context: HookContext, 95 | ) => AnyAbility) 96 | /** Easy way to disable filtering, default: `false` */ 97 | activated: boolean 98 | /** Channel that's used when there occurs an error, default: `['authenticated']` */ 99 | channelOnError: string[] 100 | /** Prefiltered channels, default: `app.channel(app.channels)` */ 101 | channels?: Channel | Channel[] 102 | modelName: GetModelName 103 | restrictFields: boolean 104 | /** change action to use for events. For example: `'receive'`, default: `'get'` */ 105 | useActionName: string | { [e in EventName]?: string } 106 | } 107 | 108 | export interface GetConditionalQueryOptions { 109 | actionOnForbidden?(): void 110 | } 111 | 112 | export interface HasRestrictingFieldsOptions { 113 | availableFields: string[] | undefined 114 | } 115 | 116 | export interface InitOptions { 117 | defaultAdapter: Adapter 118 | authorizeHook: AuthorizeHookOptions 119 | channels: ChannelOptions 120 | } 121 | 122 | export interface GetMinimalFieldsOptions { 123 | availableFields?: string[] 124 | checkCan?: boolean 125 | } 126 | 127 | export type Path = string | Array 128 | 129 | export interface ThrowUnlessCanOptions 130 | extends Pick { 131 | skipThrow: boolean 132 | } 133 | 134 | export interface UtilCheckCanOptions extends ThrowUnlessCanOptions { 135 | checkGeneral?: boolean 136 | useConditionalSelect?: boolean 137 | } 138 | -------------------------------------------------------------------------------- /src/utils/checkBasicPermission.ts: -------------------------------------------------------------------------------- 1 | import { 2 | setPersistedConfig, 3 | checkMulti, 4 | getAbility, 5 | throwUnlessCan, 6 | } from '../hooks/authorize/authorize.hook.utils.js' 7 | 8 | import { checkCreatePerItem, makeDefaultBaseOptions } from '../hooks/common.js' 9 | 10 | import type { HookContext } from '@feathersjs/feathers' 11 | 12 | import type { 13 | CheckBasicPermissionUtilsOptions, 14 | CheckBasicPermissionHookOptionsExclusive, 15 | } from '../types.js' 16 | import { getMethodName } from './getMethodName.js' 17 | 18 | const defaultOptions: CheckBasicPermissionHookOptionsExclusive = { 19 | checkCreateForData: false, 20 | storeAbilityForAuthorize: false, 21 | } 22 | 23 | const makeOptions = ( 24 | options?: Partial, 25 | ): CheckBasicPermissionUtilsOptions => { 26 | options = options || {} 27 | return Object.assign(makeDefaultBaseOptions(), defaultOptions, options) 28 | } 29 | 30 | export const checkBasicPermissionUtil = async ( 31 | context: H, 32 | _options?: Partial, 33 | ): Promise => { 34 | let options = makeOptions(_options) 35 | 36 | const method = getMethodName(context, options) 37 | 38 | options = { 39 | ...options, 40 | method, 41 | } 42 | 43 | if (!options.modelName) { 44 | return context 45 | } 46 | 47 | const modelName = 48 | typeof options.modelName === 'string' 49 | ? options.modelName 50 | : options.modelName(context) 51 | 52 | if (!modelName) { 53 | return context 54 | } 55 | 56 | const ability = await getAbility(context, options) 57 | if (!ability) { 58 | // Ignore internal or not authenticated requests 59 | return context 60 | } 61 | 62 | if (options.checkMultiActions) { 63 | checkMulti(context, ability, modelName, options) 64 | } 65 | 66 | throwUnlessCan(ability, method, modelName, modelName, options) 67 | 68 | checkCreatePerItem(context, ability, modelName, options) 69 | 70 | if (options.storeAbilityForAuthorize) { 71 | setPersistedConfig(context, 'ability', ability) 72 | } 73 | 74 | setPersistedConfig(context, 'madeBasicCheck', true) 75 | 76 | return context 77 | } 78 | -------------------------------------------------------------------------------- /src/utils/checkCan.ts: -------------------------------------------------------------------------------- 1 | import { subject } from '@casl/ability' 2 | import { throwUnlessCan } from '../hooks/authorize/authorize.hook.utils.js' 3 | 4 | import { getFieldsForConditions } from './getFieldsForConditions.js' 5 | 6 | import type { AnyAbility } from '@casl/ability' 7 | import type { Id, Service } from '@feathersjs/feathers' 8 | import type { UtilCheckCanOptions } from '../types.js' 9 | 10 | const makeOptions = ( 11 | providedOptions?: Partial, 12 | ): UtilCheckCanOptions => { 13 | return { 14 | actionOnForbidden: () => {}, 15 | checkGeneral: true, 16 | skipThrow: false, 17 | useConditionalSelect: true, 18 | ...providedOptions, 19 | } 20 | } 21 | 22 | export const checkCan = async ( 23 | ability: AnyAbility, 24 | id: Id, 25 | method: string, 26 | modelName: string, 27 | service: Service, 28 | providedOptions?: Partial, 29 | ): Promise => { 30 | const options = makeOptions(providedOptions) 31 | if (options.checkGeneral) { 32 | const can = throwUnlessCan(ability, method, modelName, modelName, options) 33 | if (!can) { 34 | return false 35 | } 36 | } 37 | 38 | let params 39 | if (options.useConditionalSelect) { 40 | const $select = getFieldsForConditions(ability, method, modelName) 41 | params = { 42 | query: { $select }, 43 | } 44 | } 45 | 46 | //@ts-expect-error _get is not exposed 47 | const getMethod = service._get ? '_get' : 'get' 48 | 49 | // @ts-expect-error _get is not exposed 50 | const item = await service[getMethod](id, params) 51 | 52 | const can = throwUnlessCan( 53 | ability, 54 | method, 55 | subject(modelName, item), 56 | modelName, 57 | options, 58 | ) 59 | 60 | return can 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/convertRuleToQuery.ts: -------------------------------------------------------------------------------- 1 | import _isPlainObject from 'lodash/isPlainObject.js' 2 | 3 | import type { SubjectRawRule, MongoQuery, ClaimRawRule } from '@casl/ability' 4 | import type { Query } from '@feathersjs/feathers' 5 | import type { GetConditionalQueryOptions } from '../types.js' 6 | 7 | const invertedMap = { 8 | $gt: '$lte', 9 | $gte: '$lt', 10 | $lt: '$gte', 11 | $lte: '$gt', 12 | $in: '$nin', 13 | $nin: '$in', 14 | $ne: (prop: Record): unknown => { 15 | return prop['$ne'] 16 | }, 17 | } 18 | 19 | const supportedOperators = Object.keys(invertedMap) 20 | 21 | const invertedProp = ( 22 | prop: Record, 23 | name: string, 24 | ): Record | string | undefined => { 25 | // @ts-expect-error `name` maybe is not in `invertedMap` 26 | const map = invertedMap[name] 27 | if (typeof map === 'string') { 28 | return { [map]: prop[name] } 29 | } else if (typeof map === 'function') { 30 | return map(prop) 31 | } 32 | } 33 | 34 | export const convertRuleToQuery = ( 35 | rule: SubjectRawRule | ClaimRawRule, 36 | options?: GetConditionalQueryOptions, 37 | ): Query | undefined => { 38 | const { conditions, inverted } = rule 39 | if (!conditions) { 40 | if (inverted && options?.actionOnForbidden) { 41 | options.actionOnForbidden() 42 | } 43 | return undefined 44 | } 45 | if (inverted) { 46 | const newConditions = {} as Query 47 | for (const prop in conditions as Record) { 48 | if (_isPlainObject(conditions[prop])) { 49 | const obj: any = conditions[prop] 50 | for (const name in obj) { 51 | if (!supportedOperators.includes(name)) { 52 | console.error(`CASL: not supported property: ${name}`) 53 | continue 54 | } 55 | newConditions[prop] = invertedProp(obj, name) 56 | } 57 | } else { 58 | newConditions[prop] = { $ne: conditions[prop] } 59 | } 60 | } 61 | 62 | return newConditions 63 | } else { 64 | return conditions as Query 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/couldHaveRestrictingFields.ts: -------------------------------------------------------------------------------- 1 | import type { AnyAbility } from '@casl/ability' 2 | 3 | export function couldHaveRestrictingFields( 4 | ability: AnyAbility, 5 | action: string, 6 | subjectType: string, 7 | ): boolean { 8 | return ability.possibleRulesFor(action, subjectType).some((rule) => { 9 | return !!rule.fields 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/getAvailableFields.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext } from '@feathersjs/feathers' 2 | import type { AuthorizeChannelCommonsOptions } from '../types.js' 3 | 4 | export const getAvailableFields = ( 5 | context: HookContext, 6 | options?: Partial>, 7 | ): undefined | string[] => { 8 | return !options?.availableFields 9 | ? undefined 10 | : typeof options.availableFields === 'function' 11 | ? options.availableFields(context) 12 | : options.availableFields 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/getFieldsForConditions.ts: -------------------------------------------------------------------------------- 1 | import type { AnyAbility } from '@casl/ability' 2 | 3 | export const getFieldsForConditions = ( 4 | ability: AnyAbility, 5 | action: string, 6 | modelName: string, 7 | ): string[] => { 8 | const rules = ability.possibleRulesFor(action, modelName) 9 | const allFields: string[] = [] 10 | for (const rule of rules) { 11 | if (!rule.conditions) { 12 | continue 13 | } 14 | const fields = Object.keys(rule.conditions) 15 | fields.forEach((field) => { 16 | if (!allFields.includes(field)) { 17 | allFields.push(field) 18 | } 19 | }) 20 | } 21 | return allFields 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/getMethodName.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext } from '@feathersjs/feathers' 2 | 3 | export const getMethodName = ( 4 | context: HookContext, 5 | options?: { method?: string | ((context: HookContext) => string) }, 6 | ): string => { 7 | if (options?.method) { 8 | if (typeof options.method === 'function') { 9 | return options.method(context) 10 | } else { 11 | return options.method 12 | } 13 | } 14 | 15 | return context.method 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/getMinimalFields.ts: -------------------------------------------------------------------------------- 1 | import { detectSubjectType } from '@casl/ability' 2 | import { mergeArrays } from 'feathers-utils' 3 | 4 | import type { AnyAbility } from '@casl/ability' 5 | import type { GetMinimalFieldsOptions } from '../types.js' 6 | 7 | export const getMinimalFields = ( 8 | ability: AnyAbility, 9 | action: string, 10 | subject: Record, 11 | options: GetMinimalFieldsOptions, 12 | ): string[] => { 13 | if (options.checkCan && !ability.can(action, subject)) { 14 | return [] 15 | } 16 | const subjectType = detectSubjectType(subject) 17 | const rules = ability.possibleRulesFor(action, subjectType).filter((rule) => { 18 | const { fields } = rule 19 | const matched = rule.matchesConditions(subject) 20 | return fields && matched 21 | }) 22 | if (rules.length === 0) { 23 | return options.availableFields || [] 24 | } 25 | let fields: string[] | undefined 26 | if (options.availableFields) { 27 | fields = options.availableFields 28 | } else { 29 | fields = rules.find((x) => !x.inverted)?.fields 30 | if (!fields) { 31 | return [] 32 | } 33 | } 34 | 35 | rules.forEach((rule) => { 36 | if (rule.inverted) { 37 | fields = fields?.filter((x) => !rule.fields?.includes(x)) 38 | } else { 39 | fields = mergeArrays(fields, rule.fields, 'intersect') 40 | } 41 | }) 42 | return fields 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/getModelName.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext } from '@feathersjs/feathers' 2 | 3 | export const getModelName = ( 4 | modelName: string | ((context: HookContext) => string), 5 | context: HookContext, 6 | ): string => { 7 | if (modelName === undefined) { 8 | return context.path 9 | } 10 | if (typeof modelName === 'string') { 11 | return modelName 12 | } 13 | if (typeof modelName === 'function') { 14 | return modelName(context) 15 | } 16 | 17 | throw new Error("feathers-casl: 'modelName' is not a string or function") 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/hasRestrictingConditions.ts: -------------------------------------------------------------------------------- 1 | import type { AnyAbility } from '@casl/ability' 2 | 3 | type Rule = ReturnType[0] 4 | 5 | export const hasRestrictingConditions = ( 6 | ability: AnyAbility, 7 | action: string, 8 | modelName: string, 9 | ): Rule[] | false => { 10 | const rules = ability.possibleRulesFor(action, modelName) 11 | const hasConditions = rules.length === 0 || rules.some((x) => !!x.conditions) 12 | return hasConditions ? rules : false 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/hasRestrictingFields.ts: -------------------------------------------------------------------------------- 1 | import { permittedFieldsOf } from '@casl/ability/extra' 2 | import { getMinimalFields } from './getMinimalFields.js' 3 | 4 | import type { AnyAbility, Subject } from '@casl/ability' 5 | import type { PermittedFieldsOptions } from '@casl/ability/extra' 6 | import type { HasRestrictingFieldsOptions } from '../types.js' 7 | 8 | function areSameArray(arr1: T[], arr2: T[]): boolean { 9 | if (arr1.length != arr2.length) { 10 | return false 11 | } 12 | const arr1test = arr1.slice().sort() 13 | const arr2test = arr2.slice().sort() 14 | const result = !arr1test.some((val, idx) => val !== arr2test[idx]) 15 | return result 16 | } 17 | 18 | export const hasRestrictingFields = ( 19 | ability: AnyAbility, 20 | action: string, 21 | subject: Subject, 22 | options?: HasRestrictingFieldsOptions, 23 | ): boolean | string[] => { 24 | let fields: string[] 25 | if (typeof subject !== 'string') { 26 | fields = getMinimalFields( 27 | ability, 28 | action, 29 | subject as Record, 30 | { 31 | availableFields: options?.availableFields, 32 | checkCan: false, 33 | }, 34 | ) 35 | } else { 36 | const permittedFieldsOfOptions: PermittedFieldsOptions = { 37 | fieldsFrom: (rule) => { 38 | return rule.fields || options?.availableFields || [] 39 | }, 40 | } 41 | 42 | fields = permittedFieldsOf( 43 | ability, 44 | action, 45 | subject, 46 | permittedFieldsOfOptions, 47 | ) 48 | } 49 | 50 | if (fields.length === 0 && !options?.availableFields) { 51 | return false 52 | } 53 | 54 | if (fields.length > 0) { 55 | // check if fields is restricting at all or just complete array 56 | if ( 57 | options?.availableFields === fields || 58 | (options?.availableFields && 59 | areSameArray(fields, options?.availableFields)) 60 | ) { 61 | // arrays are the same -> no restrictions 62 | return false 63 | } else { 64 | return fields 65 | } 66 | } 67 | 68 | return true 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './checkBasicPermission.js' 2 | export * from './checkCan.js' 3 | export * from './convertRuleToQuery.js' 4 | export * from './couldHaveRestrictingFields.js' 5 | export * from './getAvailableFields.js' 6 | export * from './getModelName.js' 7 | export * from './hasRestrictingConditions.js' 8 | export * from './hasRestrictingFields.js' 9 | export * from './mergeQueryFromAbility.js' 10 | export * from './simplifyQuery.js' 11 | export * from './getFieldsForConditions.js' 12 | export * from './getMinimalFields.js' 13 | -------------------------------------------------------------------------------- /src/utils/mergeQueryFromAbility.ts: -------------------------------------------------------------------------------- 1 | import { rulesToQuery } from '@casl/ability/extra' 2 | import { mergeQuery } from 'feathers-utils' 3 | import _isEmpty from 'lodash/isEmpty.js' 4 | import { getAdapter } from '../hooks/authorize/authorize.hook.utils.js' 5 | import { convertRuleToQuery } from './convertRuleToQuery.js' 6 | import { hasRestrictingConditions } from './hasRestrictingConditions.js' 7 | import { simplifyQuery } from './simplifyQuery.js' 8 | 9 | import type { AnyAbility } from '@casl/ability' 10 | import type { Application, Query } from '@feathersjs/feathers' 11 | import type { AdapterBase } from '@feathersjs/adapter-commons' 12 | import type { Adapter, AuthorizeHookOptions } from '../types.js' 13 | 14 | // const adaptersFor$not: Adapter[] = ["feathers-nedb"]; 15 | const adaptersFor$not: Adapter[] = [] 16 | 17 | const adaptersFor$notAsArray: Adapter[] = [ 18 | 'feathers-sequelize', 19 | // "feathers-objection", 20 | ] 21 | 22 | const adaptersFor$nor: Adapter[] = [ 23 | '@feathersjs/memory', 24 | // "feathers-mongoose", 25 | '@feathersjs/mongodb', 26 | ] 27 | 28 | export const mergeQueryFromAbility = ( 29 | app: Application, 30 | ability: AnyAbility, 31 | method: string, 32 | modelName: string, 33 | originalQuery: Query, 34 | service: AdapterBase, 35 | options: Pick, 36 | ): Query => { 37 | if (!hasRestrictingConditions(ability, method, modelName)) { 38 | return originalQuery 39 | } 40 | 41 | const adapter = getAdapter(app, options) 42 | 43 | let query: Query | null 44 | if (adaptersFor$not.includes(adapter)) { 45 | // nedb 46 | query = rulesToQuery(ability, method, modelName, (rule) => { 47 | const { conditions } = rule 48 | return rule.inverted ? { $not: conditions } : conditions 49 | }) 50 | query = simplifyQuery(query) 51 | } else if (adaptersFor$notAsArray.includes(adapter)) { 52 | // objection, sequelize 53 | query = rulesToQuery(ability, method, modelName, (rule) => { 54 | const { conditions } = rule 55 | return rule.inverted ? { $not: [conditions] } : conditions 56 | }) 57 | query = simplifyQuery(query) 58 | } else if (adaptersFor$nor.includes(adapter)) { 59 | // memory, mongoose, mongodb 60 | query = rulesToQuery(ability, method, modelName, (rule) => { 61 | const { conditions } = rule 62 | return rule.inverted ? { $nor: [conditions] } : conditions 63 | }) 64 | query = simplifyQuery(query) 65 | } else { 66 | query = rulesToQuery(ability, method, modelName, (rule) => { 67 | const { conditions } = rule 68 | return rule.inverted ? convertRuleToQuery(rule) : conditions 69 | }) 70 | query = simplifyQuery(query) 71 | if (query?.$and) { 72 | const { $and } = query 73 | delete query.$and 74 | $and.forEach((q: any) => { 75 | query = mergeQuery(query as Query, q, { 76 | defaultHandle: 'intersect', 77 | useLogicalConjunction: true, 78 | }) 79 | }) 80 | } 81 | } 82 | 83 | if (_isEmpty(query)) { 84 | return originalQuery 85 | } 86 | 87 | if (!originalQuery) { 88 | return query 89 | } else { 90 | return mergeQuery(originalQuery, query, { 91 | defaultHandle: 'intersect', 92 | useLogicalConjunction: true, 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/simplifyQuery.ts: -------------------------------------------------------------------------------- 1 | import _isEqual from 'lodash/isEqual.js' 2 | import _cloneDeep from 'lodash/cloneDeep.js' 3 | 4 | import type { Query } from '@feathersjs/feathers' 5 | 6 | export const simplifyQuery = ( 7 | query: Q, 8 | replaceAnd = true, 9 | replaceOr = true, 10 | ): Q => { 11 | if (!query) { 12 | return query 13 | } 14 | if (!query.$and && !query.$or) { 15 | return query 16 | } 17 | let result = _cloneDeep(query) 18 | 19 | if (result.$and && !result.$and.length) { 20 | delete result.$and 21 | } 22 | 23 | if (result.$or && !result.$or.length) { 24 | delete result.$or 25 | } 26 | 27 | /*if (result.$and && result.$or) { 28 | const or = (result.$or.length > 1) ? { $or: result.$or } : result.$or[0]; 29 | result.$and.push(or); 30 | delete result.$or; 31 | }*/ 32 | 33 | if (result.$and) { 34 | const $and: any[] = [] 35 | result.$and.forEach((q: any) => { 36 | q = simplifyQuery(q, true, true) 37 | if ($and.some((x) => _isEqual(x, q))) return 38 | $and.push(q) 39 | }) 40 | if (replaceAnd && $and.length === 1 && Object.keys(result).length === 1) { 41 | result = $and[0] 42 | } else { 43 | result.$and = $and 44 | } 45 | } 46 | if (result.$or) { 47 | const $or: any[] = [] 48 | result.$or.forEach((q: any) => { 49 | q = simplifyQuery(q, true, true) 50 | if ($or.some((x) => _isEqual(x, q))) return 51 | $or.push(q) 52 | }) 53 | if (replaceOr && $or.length === 1 && Object.keys(result).length === 1) { 54 | result = $or[0] 55 | } else { 56 | result.$or = $or 57 | } 58 | } 59 | return result 60 | } 61 | -------------------------------------------------------------------------------- /test/app/options.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { feathers } from '@feathersjs/feathers' 3 | import { MemoryService } from '@feathersjs/memory' 4 | import { feathersCasl, authorize } from '../../src' 5 | import type { InitOptions } from '../../src' 6 | import { defineAbility } from '@casl/ability' 7 | 8 | const mockApp = () => { 9 | const app = feathers() 10 | app.use( 11 | 'users', 12 | new MemoryService({ 13 | multi: true, 14 | paginate: { 15 | default: 10, 16 | max: 50, 17 | }, 18 | }), 19 | ) 20 | const service = app.service('users') 21 | service.hooks({ 22 | before: { 23 | all: [ 24 | authorize({ 25 | availableFields: ['id', 'userId', 'hi', 'test', 'published'], 26 | }), 27 | ], 28 | }, 29 | after: { 30 | all: [ 31 | authorize({ 32 | availableFields: ['id', 'userId', 'hi', 'test', 'published'], 33 | }), 34 | ], 35 | }, 36 | }) 37 | return { 38 | app, 39 | service, 40 | } 41 | } 42 | 43 | interface CalledOptions { 44 | calledActionOnForbidden?: boolean 45 | calledAbility?: boolean 46 | calledModelName?: boolean 47 | } 48 | 49 | describe('app-options / service-options', function () { 50 | describe('authorize hook', function () { 51 | it('calls app options for authorize hook', async function () { 52 | const { app, service } = mockApp() 53 | let calledActionOnForbidden = false 54 | let calledAbility = false 55 | let calledModelName = false 56 | 57 | app.configure( 58 | feathersCasl({ 59 | authorizeHook: { 60 | actionOnForbidden: () => { 61 | calledActionOnForbidden = true 62 | }, 63 | checkMultiActions: true, 64 | ability: () => { 65 | calledAbility = true 66 | return defineAbility(() => {}) 67 | }, 68 | modelName: (): string => { 69 | calledModelName = true 70 | return 'Test' 71 | }, 72 | checkAbilityForInternal: true, 73 | }, 74 | channels: { 75 | activated: false, 76 | channelOnError: ['Test'], 77 | ability: () => { 78 | calledChannelAbility = true 79 | return defineAbility(() => {}) 80 | }, 81 | modelName: (): string => { 82 | calledChannelModelName = true 83 | return 'Test' 84 | }, 85 | restrictFields: false, 86 | }, 87 | }), 88 | ) 89 | 90 | const caslOptions: InitOptions = app.get('casl') 91 | 92 | assert.ok(caslOptions.authorizeHook, 'authorizeHook options is set') 93 | assert.ok(caslOptions.channels, 'channels options is set') 94 | 95 | await assert.rejects( 96 | service.find({ 97 | query: {}, 98 | }), 99 | (err: Error) => err.name === 'Forbidden', 100 | 'throws Forbidden for no ability', 101 | ) 102 | 103 | assert.ok(calledAbility, 'called ability function') 104 | assert.ok(calledActionOnForbidden, 'called actionOnForbidden function') 105 | assert.ok(calledModelName, 'called modelName function') 106 | 107 | //assert.ok(calledChannelAbility, "called ability function on channels"); 108 | //assert.ok(calledChannelModelName, "called modelName function on channels"); 109 | }) 110 | 111 | it('calls service options over app options', async function () { 112 | const app = feathers() 113 | app.use( 114 | 'users', 115 | new MemoryService({ 116 | multi: true, 117 | paginate: { 118 | default: 10, 119 | max: 50, 120 | }, 121 | }), 122 | ) 123 | const appCalled: CalledOptions = {} 124 | const serviceCalled: CalledOptions = {} 125 | const service = app.service('users') 126 | service.hooks({ 127 | before: { 128 | all: [ 129 | authorize({ 130 | availableFields: ['id', 'userId', 'hi', 'test', 'published'], 131 | actionOnForbidden: () => { 132 | serviceCalled.calledActionOnForbidden = true 133 | }, 134 | checkMultiActions: true, 135 | ability: () => { 136 | serviceCalled.calledAbility = true 137 | return defineAbility(() => {}) 138 | }, 139 | modelName: (): string => { 140 | serviceCalled.calledModelName = true 141 | return 'Test' 142 | }, 143 | }), 144 | ], 145 | }, 146 | }) 147 | 148 | app.configure( 149 | feathersCasl({ 150 | authorizeHook: { 151 | actionOnForbidden: () => { 152 | appCalled.calledActionOnForbidden = true 153 | }, 154 | checkAbilityForInternal: true, 155 | checkMultiActions: true, 156 | ability: () => { 157 | appCalled.calledAbility = true 158 | return defineAbility(() => {}) 159 | }, 160 | modelName: (): string => { 161 | appCalled.calledModelName = true 162 | return 'Test' 163 | }, 164 | }, 165 | }), 166 | ) 167 | 168 | await assert.rejects( 169 | service.find({ 170 | query: {}, 171 | }), 172 | (err: Error) => err.name === 'Forbidden', 173 | 'throws Forbidden for no ability', 174 | ) 175 | 176 | assert.ok( 177 | serviceCalled.calledAbility, 178 | 'called ability function from service options', 179 | ) 180 | assert.ok( 181 | serviceCalled.calledActionOnForbidden, 182 | 'called actionOnForbidden function from service options', 183 | ) 184 | assert.ok( 185 | serviceCalled.calledModelName, 186 | 'called modelName function from service options', 187 | ) 188 | 189 | assert.ok( 190 | !appCalled.calledAbility, 191 | 'not called ability function from app options', 192 | ) 193 | assert.ok( 194 | !appCalled.calledActionOnForbidden, 195 | 'not called actionOnForbidden function from app options', 196 | ) 197 | assert.ok( 198 | !appCalled.calledModelName, 199 | 'not called modelName function from app options', 200 | ) 201 | 202 | //assert.ok(calledChannelAbility, "called ability function on channels"); 203 | //assert.ok(calledChannelModelName, "called modelName function on channels"); 204 | }) 205 | }) 206 | 207 | describe('channels', function () { 208 | it.skip('test channel options', function () {}) 209 | }) 210 | }) 211 | -------------------------------------------------------------------------------- /test/channels/.mockServer/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "localhost", 3 | "paginate": { 4 | "default": 10, 5 | "max": 50 6 | }, 7 | "authentication": { 8 | "entity": "user", 9 | "service": "users", 10 | "secret": "knP4c3uAPU+/RmFBLYOVbvYXgNk=", 11 | "authStrategies": ["jwt", "local"], 12 | "jwtOptions": { 13 | "header": { 14 | "typ": "access" 15 | }, 16 | "audience": "https://yourdomain.com", 17 | "issuer": "feathers", 18 | "algorithm": "HS256", 19 | "expiresIn": "1d" 20 | }, 21 | "local": { 22 | "usernameField": "email", 23 | "passwordField": "password" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/channels/.mockServer/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import helmet from 'helmet' 3 | import cors from 'cors' 4 | 5 | import { feathers } from '@feathersjs/feathers' 6 | import type { Application as ExpressFeathers } from '@feathersjs/express' 7 | import express, { json, urlencoded, rest } from '@feathersjs/express' 8 | import socketio from '@feathersjs/socketio' 9 | import type { MemoryService } from '@feathersjs/memory' 10 | 11 | const __dirname = import.meta.dirname 12 | 13 | process.env['NODE_CONFIG_DIR'] = path.join(__dirname, 'config/') 14 | import configuration from '@feathersjs/configuration' 15 | 16 | import { feathersCasl } from '../../../src/index.js' 17 | 18 | interface MockServerOptions { 19 | channels: (app: Application) => void 20 | services: (app: Application) => void 21 | } 22 | 23 | type Application = ExpressFeathers<{ 24 | articles: MemoryService 25 | comments: MemoryService 26 | users: MemoryService 27 | }> 28 | 29 | export const mockServer = (options: MockServerOptions) => { 30 | const { channels, services } = options 31 | const app: Application = express(feathers()) 32 | 33 | // Load app configuration 34 | app.configure(configuration()) 35 | 36 | // Enable security, CORS, compression, favicon and body parsing 37 | app.use( 38 | helmet({ 39 | contentSecurityPolicy: false, 40 | }), 41 | ) 42 | app.use(cors()) 43 | app.use(json()) 44 | app.use(urlencoded({ extended: true })) 45 | 46 | // Set up Plugins and providers 47 | app.configure(rest()) 48 | app.configure(socketio()) 49 | 50 | app.set('authentication', { 51 | entity: 'user', 52 | service: 'users', 53 | secret: '123', 54 | authStrategies: ['jwt', 'local'], 55 | local: { 56 | usernameField: 'email', 57 | passwordField: 'password', 58 | }, 59 | }) 60 | 61 | app.configure(services) 62 | 63 | // Set up event channels (see channels.ts) 64 | app.configure(channels) 65 | 66 | app.hooks({}) 67 | 68 | const articles = app.service('articles') 69 | const comments = app.service('comments') 70 | const users = app.service('users') 71 | 72 | app.configure(feathersCasl()) 73 | return { 74 | app: app, 75 | articles, 76 | comments, 77 | users, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/channels/channels.utils.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { getEventName, makeDefaultOptions } from '../../src/index.js' 3 | 4 | describe('channels.utils.test.ts', function () { 5 | it('defaultOptions', function () { 6 | const options = makeDefaultOptions() 7 | 8 | assert.strictEqual(options.activated, true, 'is activated by default') 9 | assert.deepStrictEqual( 10 | options.channelOnError, 11 | ['authenticated'], 12 | "returns 'authenticated' by default", 13 | ) 14 | assert.strictEqual( 15 | options.restrictFields, 16 | true, 17 | 'restrict Fields by default', 18 | ) 19 | assert.strictEqual( 20 | options.useActionName, 21 | 'get', 22 | 'use native eventName by default', 23 | ) 24 | }) 25 | 26 | it('getEventName', function () { 27 | assert.strictEqual(getEventName('find'), undefined, 'no event for find') 28 | assert.strictEqual(getEventName('get'), undefined, 'no event for find') 29 | assert.strictEqual(getEventName('create'), 'created', 'no event for find') 30 | assert.strictEqual(getEventName('update'), 'updated', 'no event for find') 31 | assert.strictEqual(getEventName('patch'), 'patched', 'no event for find') 32 | assert.strictEqual(getEventName('remove'), 'removed', 'no event for find') 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/channels/custom-actions/channels.custom-actions.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import type { Application } from '@feathersjs/feathers' 3 | import { feathers } from '@feathersjs/feathers' 4 | import socketio from '@feathersjs/socketio-client' 5 | import type { Server } from 'node:http' 6 | import { io } from 'socket.io-client' 7 | 8 | import { mockServer } from '../.mockServer/index.js' 9 | import channels1 from './mockChannels.custom-actions.js' 10 | import services1 from './mockServices.custom-actions.js' 11 | import getPort from 'get-port' 12 | import { promiseTimeout } from '../../test-utils.js' 13 | 14 | describe('channels.custom-actions.test.ts', function () { 15 | let server: Server 16 | let app: Application 17 | 18 | const clients: Application[] = [] 19 | let users: Record[] = [ 20 | { id: 0, email: '1', password: '1' }, 21 | { id: 1, email: '2', password: '2' }, 22 | { id: 2, email: '3', password: '3' }, 23 | { id: 3, email: '4', password: '4' }, 24 | { id: 4, email: '5', password: '5' }, 25 | ] 26 | 27 | beforeAll(async function () { 28 | const mock = mockServer({ 29 | channels: channels1, 30 | services: services1, 31 | }) 32 | 33 | app = mock.app 34 | 35 | app = mock.app 36 | 37 | const port = await getPort() 38 | app.set('port', port) 39 | 40 | server = await app.listen(port) 41 | await new Promise((resolve) => { 42 | server.on('listening', resolve) 43 | }) 44 | 45 | users = await app.service('users').create(users) 46 | 47 | const promises = users.map(async (user) => { 48 | const socket = io(`http://localhost:${port}`) 49 | const client = feathers() 50 | client.configure(socketio(socket)) 51 | clients.push(client) 52 | await client.service('authentication').create({ 53 | strategy: 'local', 54 | email: user.email, 55 | password: user.email, 56 | }) 57 | }) 58 | 59 | const socket = io(`http://localhost:${port}`) 60 | const client = feathers() 61 | client.configure(socketio(socket)) 62 | clients.push(client) 63 | 64 | await Promise.all(promises) 65 | }) 66 | 67 | afterAll(async function () { 68 | server.close() 69 | }) 70 | 71 | const checkClient = async ( 72 | servicePath: string, 73 | methodName: string, 74 | event: string, 75 | expectedPerClient: unknown, 76 | i: number, 77 | ) => { 78 | assert.ok( 79 | Object.prototype.hasOwnProperty.call(expectedPerClient, i), 80 | `client${i} has expected value`, 81 | ) 82 | const expected = expectedPerClient[i] 83 | const fulFill = new Promise((resolve) => { 84 | clients[i].service(servicePath).on(event, (result) => { 85 | if (expected) { 86 | assert.deepStrictEqual( 87 | result, 88 | expected, 89 | `'client${i}:${servicePath}:${methodName}': result is expected`, 90 | ) 91 | } 92 | resolve(result) 93 | }) 94 | }) 95 | 96 | if (expected) { 97 | await assert.doesNotReject( 98 | promiseTimeout( 99 | 100, 100 | fulFill, 101 | `'client${i}:${servicePath}:${methodName}': timeout`, 102 | ).finally(() => { 103 | clients[i].service(servicePath).removeAllListeners(event) 104 | }), 105 | `'client${i}:${servicePath}:${methodName}': receives message`, 106 | ) 107 | } else { 108 | await assert.rejects( 109 | promiseTimeout( 110 | 80, 111 | fulFill, 112 | `'client${i}:${servicePath}:${methodName}': timeout`, 113 | ).finally(() => { 114 | clients[i].service(servicePath).removeAllListeners(event) 115 | }), 116 | () => true, 117 | `'client${i}:${servicePath}:${methodName}': does not receive message`, 118 | ) 119 | } 120 | } 121 | 122 | it('users receive events', async function () { 123 | const services = ['articles', 'comments'] 124 | 125 | for (let i = 0, n = services.length; i < n; i++) { 126 | const servicePath = services[i] 127 | 128 | const methods = { 129 | create: { 130 | params: [{ id: 0, published: true, test: true, userId: 4 }], 131 | event: 'created', 132 | expectedPerClient: { 133 | 0: { id: 0, published: true, test: true, userId: 4 }, 134 | 1: 135 | servicePath === 'articles' 136 | ? { id: 0, published: true, test: true, userId: 4 } 137 | : false, 138 | 2: false, 139 | 3: { id: 0, published: true, test: true, userId: 4 }, 140 | 4: servicePath === 'articles' ? false : { id: 0 }, 141 | 5: false, 142 | }, 143 | }, 144 | update: { 145 | params: [0, { test: false, userId: 4 }], 146 | event: 'updated', 147 | expectedPerClient: { 148 | 0: { id: 0, test: false, userId: 4 }, 149 | 1: servicePath === 'comments' ? { test: false } : false, 150 | 2: false, 151 | 3: { id: 0, test: false, userId: 4 }, 152 | 4: false, 153 | 5: false, 154 | }, 155 | }, 156 | patch: { 157 | params: [0, { test: true, userId: 1, title: 'test' }], 158 | event: 'patched', 159 | expectedPerClient: { 160 | 0: { id: 0, test: true, userId: 1, title: 'test' }, 161 | 1: 162 | servicePath === 'comments' 163 | ? { id: 0, test: true, userId: 1, title: 'test' } 164 | : false, 165 | 2: false, 166 | 3: { id: 0, test: true, userId: 1, title: 'test' }, 167 | 4: false, 168 | 5: false, 169 | }, 170 | }, 171 | remove: { 172 | params: [0], 173 | event: 'removed', 174 | expectedPerClient: { 175 | 0: { id: 0, test: true, userId: 1, title: 'test' }, 176 | 1: 177 | servicePath === 'articles' 178 | ? { id: 0, test: true, userId: 1, title: 'test' } 179 | : { id: 0 }, 180 | 2: false, 181 | 3: { id: 0, test: true, userId: 1, title: 'test' }, 182 | 4: false, 183 | 5: false, 184 | }, 185 | }, 186 | } 187 | 188 | const methodNames = Object.keys(methods) 189 | for (let j = 0, o = methodNames.length; j < o; j++) { 190 | const methodName = methodNames[j] 191 | const method = methods[methodName] 192 | const service = app.service(servicePath) 193 | const { event, params, expectedPerClient } = method 194 | 195 | const promises = clients.map((client, i) => 196 | checkClient(servicePath, methodName, event, expectedPerClient, i), 197 | ) 198 | 199 | service[methodName](...params) 200 | 201 | await Promise.all(promises) 202 | } 203 | } 204 | }) 205 | }) 206 | -------------------------------------------------------------------------------- /test/channels/custom-actions/mockChannels.custom-actions.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import '@feathersjs/transport-commons' 3 | import type { 4 | HookContext, 5 | Params, 6 | RealTimeConnection, 7 | } from '@feathersjs/feathers' 8 | 9 | import { 10 | getChannelsWithReadAbility, 11 | makeChannelOptions, 12 | } from '../../../src/index.js' 13 | import type { Application } from '@feathersjs/express' 14 | 15 | export default function (app: Application): void { 16 | if (typeof app.channel !== 'function') { 17 | return 18 | } 19 | 20 | app.on('connection', (connection: RealTimeConnection): void => { 21 | app.channel('anonymous').join(connection) 22 | }) 23 | 24 | app.on('login', (authResult: any, params: Params): void => { 25 | const { connection } = params 26 | if (connection) { 27 | if (authResult.ability) { 28 | connection.ability = authResult.ability 29 | connection.rules = authResult.rules 30 | } 31 | 32 | app.channel('anonymous').leave(connection) 33 | app.channel('authenticated').join(connection) 34 | } 35 | }) 36 | 37 | const caslOptions = makeChannelOptions(app, { 38 | useActionName: { 39 | created: 'receive-created', 40 | patched: 'receive-patched', 41 | removed: 'receive-removed', 42 | updated: 'receive-updated', 43 | }, 44 | }) 45 | 46 | const fields = caslOptions.availableFields({ 47 | service: app.service('users'), 48 | }) 49 | 50 | assert.deepStrictEqual( 51 | fields, 52 | ['id', 'email', 'password'], 53 | 'gets availableFields from service correctly', 54 | ) 55 | 56 | app.publish((data: unknown, context: HookContext) => { 57 | const result = getChannelsWithReadAbility( 58 | app, 59 | data as Record, 60 | context, 61 | caslOptions, 62 | ) 63 | 64 | // e.g. to publish all service events to all authenticated users use 65 | return result 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /test/channels/custom-actions/mockServices.custom-actions.ts: -------------------------------------------------------------------------------- 1 | import type { Application, HookContext } from '@feathersjs/feathers' 2 | import { AuthenticationService, JWTStrategy } from '@feathersjs/authentication' 3 | import { LocalStrategy } from '@feathersjs/authentication-local' 4 | 5 | import { MemoryService } from '@feathersjs/memory' 6 | import hashPassword from '@feathersjs/authentication-local/lib/hooks/hash-password.js' 7 | import protect from '@feathersjs/authentication-local/lib/hooks/protect.js' 8 | import type { ServiceCaslOptions } from '../../../src/index.js' 9 | import { createAliasResolver, defineAbility } from '@casl/ability' 10 | 11 | const resolveAction = createAliasResolver({ 12 | update: 'patch', 13 | read: ['get', 'find'], 14 | delete: 'remove', 15 | }) 16 | 17 | declare module '@feathersjs/memory' { 18 | interface MemoryServiceOptions { 19 | casl?: ServiceCaslOptions 20 | } 21 | } 22 | 23 | const defineAbilitiesFor = (user) => { 24 | return defineAbility( 25 | (can) => { 26 | if (user.id === 0) { 27 | can('manage', 'all') 28 | } else if (user.id === 1) { 29 | can('receive-created', 'articles', { published: true }) 30 | can('receive-updated', 'comments', ['test']) 31 | can('receive-patched', 'articles', { test: false }) 32 | can('receive-patched', 'comments') 33 | can('receive-removed', 'articles', { userId: 1 }) 34 | can('receive-removed', 'comments', ['id'], { userId: 1 }) 35 | can('read', 'comments', ['id', 'title'], { userId: user.id }) 36 | } else if (user.id === 2) { 37 | can('receive', 'all') 38 | can('receive-created', 'comments', { userId: user.id }) 39 | } else if (user.id === 3) { 40 | can( 41 | [ 42 | 'receive-created', 43 | 'receive-updated', 44 | 'receive-patched', 45 | 'receive-removed', 46 | ], 47 | 'all', 48 | ) 49 | } else if (user.id === 4) { 50 | can('receive-created', 'comments', ['id'], { userId: user.id }) 51 | } 52 | }, 53 | { resolveAction }, 54 | ) 55 | } 56 | 57 | export default function (app: Application): void { 58 | //#region authentication 59 | const authentication = new AuthenticationService(app) 60 | 61 | authentication.register('jwt', new JWTStrategy()) 62 | authentication.register('local', new LocalStrategy()) 63 | 64 | app.use('/authentication', authentication) 65 | 66 | const authService = app.service('authentication') 67 | authService.hooks({ 68 | after: { 69 | all: [], 70 | create: [ 71 | (context: HookContext): HookContext => { 72 | const { user } = context.result 73 | if (!user) return context 74 | const ability = defineAbilitiesFor(user) 75 | context.result.ability = ability 76 | context.result.rules = ability.rules 77 | 78 | return context 79 | }, 80 | ], 81 | remove: [], 82 | }, 83 | }) 84 | 85 | //#endregion 86 | 87 | //#region articles 88 | 89 | app.use( 90 | 'articles', 91 | new MemoryService({ 92 | multi: true, 93 | casl: { 94 | availableFields: ['id', 'test', 'published', 'test'], 95 | }, 96 | //paginate: 97 | }), 98 | ) 99 | 100 | //#endregion 101 | 102 | //#region comments 103 | 104 | app.use( 105 | 'comments', 106 | new MemoryService({ 107 | multi: true, 108 | casl: { 109 | availableFields: ['id', 'title', 'userId', 'test'], 110 | }, 111 | //paginate: 112 | }), 113 | ) 114 | 115 | //#endregion 116 | 117 | //#region users 118 | app.use( 119 | 'users', 120 | new MemoryService({ 121 | multi: true, 122 | casl: { 123 | availableFields: ['id', 'email', 'password'], 124 | }, 125 | //paginate: 126 | }), 127 | ) 128 | 129 | const users = app.service('users') 130 | 131 | users.hooks({ 132 | before: { 133 | all: [], 134 | find: [], 135 | get: [], 136 | create: [hashPassword('password')], 137 | update: [hashPassword('password')], 138 | patch: [hashPassword('password')], 139 | remove: [], 140 | }, 141 | after: { 142 | all: [ 143 | // Make sure the password field is never sent to the client 144 | // Always must be the last hook 145 | protect('password'), 146 | ], 147 | }, 148 | }) 149 | //#endregion 150 | } 151 | -------------------------------------------------------------------------------- /test/channels/defaultSettings/mockChannels.default.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import '@feathersjs/transport-commons' 3 | import type { 4 | HookContext, 5 | Params, 6 | RealTimeConnection, 7 | } from '@feathersjs/feathers' 8 | 9 | import { 10 | getChannelsWithReadAbility, 11 | makeChannelOptions, 12 | } from '../../../src/index.js' 13 | import type { Application } from '@feathersjs/express' 14 | 15 | export default function (app: Application): void { 16 | if (typeof app.channel !== 'function') { 17 | return 18 | } 19 | 20 | app.on('connection', (connection: RealTimeConnection): void => { 21 | app.channel('anonymous').join(connection) 22 | }) 23 | 24 | app.on('login', (authResult: any, { connection }: Params): void => { 25 | if (connection) { 26 | if (authResult.ability) { 27 | connection.ability = authResult.ability 28 | connection.rules = authResult.rules 29 | } 30 | 31 | app.channel('anonymous').leave(connection) 32 | app.channel('authenticated').join(connection) 33 | } 34 | }) 35 | 36 | const caslOptions = makeChannelOptions(app) 37 | 38 | const fields = caslOptions.availableFields({ 39 | service: app.service('users'), 40 | }) 41 | 42 | assert.deepStrictEqual( 43 | fields, 44 | undefined, 45 | 'gets availableFields from service correctly', 46 | ) 47 | 48 | app.publish((data: unknown, context: HookContext) => { 49 | const result = getChannelsWithReadAbility( 50 | app, 51 | data as Record, 52 | context, 53 | caslOptions, 54 | ) 55 | 56 | // e.g. to publish all service events to all authenticated users use 57 | return result 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /test/channels/defaultSettings/mockServices.default.ts: -------------------------------------------------------------------------------- 1 | import type { Application, HookContext } from '@feathersjs/feathers' 2 | import { AuthenticationService, JWTStrategy } from '@feathersjs/authentication' 3 | import { LocalStrategy } from '@feathersjs/authentication-local' 4 | 5 | import { MemoryService } from '@feathersjs/memory' 6 | import hashPassword from '@feathersjs/authentication-local/lib/hooks/hash-password.js' 7 | import protect from '@feathersjs/authentication-local/lib/hooks/protect.js' 8 | import { defineAbility, createAliasResolver } from '@casl/ability' 9 | 10 | const resolveAction = createAliasResolver({ 11 | update: 'patch', 12 | read: ['get', 'find'], 13 | delete: 'remove', 14 | }) 15 | 16 | const defineAbilitiesFor = (user) => { 17 | return defineAbility( 18 | (can) => { 19 | if (user.id === 0) { 20 | can('manage', 'all') 21 | } else if (user.id === 1) { 22 | can('read', 'articles', { published: true }) 23 | can('read', 'comments', ['id', 'title'], { userId: user.id }) 24 | } else if (user.id === 2) { 25 | can('read', 'comments', { userId: user.id }) 26 | } 27 | }, 28 | { resolveAction }, 29 | ) 30 | } 31 | 32 | export default function (app: Application): void { 33 | //#region authentication 34 | const authentication = new AuthenticationService(app) 35 | 36 | authentication.register('jwt', new JWTStrategy()) 37 | authentication.register('local', new LocalStrategy()) 38 | 39 | app.use('/authentication', authentication) 40 | 41 | const authService = app.service('authentication') 42 | authService.hooks({ 43 | after: { 44 | all: [], 45 | create: [ 46 | (context: HookContext): HookContext => { 47 | const { user } = context.result 48 | if (!user) return context 49 | const ability = defineAbilitiesFor(user) 50 | context.result.ability = ability 51 | context.result.rules = ability.rules 52 | 53 | return context 54 | }, 55 | ], 56 | remove: [], 57 | }, 58 | }) 59 | 60 | //#endregion 61 | 62 | //#region articles 63 | 64 | app.use( 65 | 'articles', 66 | new MemoryService({ 67 | multi: true, 68 | //paginate: 69 | }), 70 | ) 71 | 72 | //#endregion 73 | 74 | //#region comments 75 | 76 | app.use( 77 | 'comments', 78 | new MemoryService({ 79 | multi: true, 80 | //paginate: 81 | }), 82 | ) 83 | 84 | //#endregion 85 | 86 | //#region users 87 | app.use( 88 | 'users', 89 | new MemoryService({ 90 | multi: true, 91 | //paginate: 92 | }), 93 | ) 94 | 95 | const users = app.service('users') 96 | 97 | users.hooks({ 98 | before: { 99 | all: [], 100 | find: [], 101 | get: [], 102 | create: [hashPassword('password')], 103 | update: [hashPassword('password')], 104 | patch: [hashPassword('password')], 105 | remove: [], 106 | }, 107 | after: { 108 | all: [ 109 | // Make sure the password field is never sent to the client 110 | // Always must be the last hook 111 | protect('password'), 112 | ], 113 | }, 114 | }) 115 | //#endregion 116 | } 117 | -------------------------------------------------------------------------------- /test/channels/receive/channels.receive.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import type { Application } from '@feathersjs/feathers' 3 | import { feathers } from '@feathersjs/feathers' 4 | import socketio from '@feathersjs/socketio-client' 5 | import type { Server } from 'node:http' 6 | import { io } from 'socket.io-client' 7 | 8 | import { mockServer } from '../.mockServer/index.js' 9 | import channels1 from './mockChannels.receive.js' 10 | import services1 from './mockServices.receive.js' 11 | import getPort from 'get-port' 12 | import { promiseTimeout } from '../../test-utils.js' 13 | 14 | describe('channels.receive.test.ts', function () { 15 | let server: Server 16 | let app: Application 17 | 18 | const clients: Application[] = [] 19 | let users: Record[] = [ 20 | { id: 0, email: '1', password: '1' }, 21 | { id: 1, email: '2', password: '2' }, 22 | { id: 2, email: '3', password: '3' }, 23 | { id: 3, email: '4', password: '4' }, 24 | { id: 4, email: '5', password: '5' }, 25 | ] 26 | 27 | beforeAll(async function () { 28 | const mock = mockServer({ 29 | channels: channels1, 30 | services: services1, 31 | }) 32 | 33 | app = mock.app 34 | 35 | app = mock.app 36 | 37 | const port = await getPort() 38 | app.set('port', port) 39 | 40 | server = await app.listen(port) 41 | 42 | users = await app.service('users').create(users) 43 | 44 | const promises = users.map(async (user) => { 45 | const socket = io(`http://localhost:${port}`) 46 | const client = feathers() 47 | client.configure(socketio(socket)) 48 | clients.push(client) 49 | 50 | await client.service('authentication').create({ 51 | strategy: 'local', 52 | email: user.email, 53 | password: user.email, 54 | }) 55 | }) 56 | 57 | const socket = io(`http://localhost:${port}`) 58 | const client = feathers() 59 | client.configure(socketio(socket)) 60 | clients.push(client) 61 | 62 | await Promise.all(promises) 63 | }) 64 | 65 | afterAll(async function () { 66 | server.close() 67 | }) 68 | 69 | const checkClient = async ( 70 | servicePath: string, 71 | methodName: string, 72 | event: string, 73 | expectedPerClient: unknown, 74 | i: number, 75 | ) => { 76 | assert.ok( 77 | Object.prototype.hasOwnProperty.call(expectedPerClient, i), 78 | `client${i} has expected value`, 79 | ) 80 | const expected = expectedPerClient[i] 81 | const fulFill = new Promise((resolve) => { 82 | clients[i].service(servicePath).on(event, (result) => { 83 | if (expected) { 84 | assert.deepStrictEqual( 85 | result, 86 | expected, 87 | `'client${i}:${servicePath}:${methodName}': result is expected`, 88 | ) 89 | } 90 | resolve(result) 91 | }) 92 | }) 93 | 94 | if (expected) { 95 | await assert.doesNotReject( 96 | promiseTimeout( 97 | 100, 98 | fulFill, 99 | `'client${i}:${servicePath}:${methodName}': does not receive message`, 100 | ).finally(() => { 101 | clients[i].service(servicePath).removeAllListeners(event) 102 | }), 103 | `'client${i}:${servicePath}:${methodName}': receives message`, 104 | ) 105 | } else { 106 | await assert.rejects( 107 | promiseTimeout( 108 | 100, 109 | fulFill, 110 | `'client${i}:${servicePath}:${methodName}': does not receive message`, 111 | ).finally(() => { 112 | clients[i].service(servicePath).removeAllListeners(event) 113 | }), 114 | () => true, 115 | `'client${i}:${servicePath}:${methodName}': does not receive message`, 116 | ) 117 | } 118 | } 119 | 120 | it('users receive events', async function () { 121 | const services = ['articles', 'comments'] 122 | 123 | for (let i = 0, n = services.length; i < n; i++) { 124 | const servicePath = services[i] 125 | 126 | const methods = { 127 | create: { 128 | params: [{ id: 0, test: true, userId: 4 }], 129 | event: 'created', 130 | expectedPerClient: { 131 | 0: { id: 0, test: true, userId: 4 }, 132 | 1: false, 133 | 2: false, 134 | 3: { id: 0, test: true, userId: 4 }, 135 | 4: servicePath === 'articles' ? false : { id: 0 }, 136 | 5: false, 137 | }, 138 | }, 139 | update: { 140 | params: [0, { test: false, userId: 4 }], 141 | event: 'updated', 142 | expectedPerClient: { 143 | 0: { id: 0, test: false, userId: 4 }, 144 | 1: false, 145 | 2: false, 146 | 3: { id: 0, test: false, userId: 4 }, 147 | 4: servicePath === 'articles' ? false : { id: 0 }, 148 | 5: false, 149 | }, 150 | }, 151 | patch: { 152 | params: [0, { test: true, userId: 1, title: 'test' }], 153 | event: 'patched', 154 | expectedPerClient: { 155 | 0: { id: 0, test: true, userId: 1, title: 'test' }, 156 | 1: false, 157 | 2: false, 158 | 3: { id: 0, test: true, userId: 1, title: 'test' }, 159 | 4: false, 160 | 5: false, 161 | }, 162 | }, 163 | remove: { 164 | params: [0], 165 | event: 'removed', 166 | expectedPerClient: { 167 | 0: { id: 0, test: true, userId: 1, title: 'test' }, 168 | 1: false, 169 | 2: false, 170 | 3: { id: 0, test: true, userId: 1, title: 'test' }, 171 | 4: false, 172 | 5: false, 173 | }, 174 | }, 175 | } 176 | 177 | const methodNames = Object.keys(methods) 178 | for (let j = 0, o = methodNames.length; j < o; j++) { 179 | const methodName = methodNames[j] 180 | const method = methods[methodName] 181 | const service = app.service(servicePath) 182 | const { event, params, expectedPerClient } = method 183 | 184 | const promises = clients.map((client, i) => 185 | checkClient(servicePath, methodName, event, expectedPerClient, i), 186 | ) 187 | 188 | service[methodName](...params) 189 | 190 | await Promise.all(promises) 191 | } 192 | } 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /test/channels/receive/mockChannels.receive.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import '@feathersjs/transport-commons' 3 | import type { 4 | HookContext, 5 | Params, 6 | RealTimeConnection, 7 | } from '@feathersjs/feathers' 8 | 9 | import { 10 | getChannelsWithReadAbility, 11 | makeChannelOptions, 12 | } from '../../../src/index.js' 13 | import type { Application } from '@feathersjs/express' 14 | 15 | export default function (app: Application): void { 16 | if (typeof app.channel !== 'function') { 17 | return 18 | } 19 | 20 | app.on('connection', (connection: RealTimeConnection): void => { 21 | app.channel('anonymous').join(connection) 22 | }) 23 | 24 | app.on('login', (authResult: any, { connection }: Params): void => { 25 | if (connection) { 26 | if (authResult.ability) { 27 | connection.ability = authResult.ability 28 | connection.rules = authResult.rules 29 | } 30 | 31 | app.channel('anonymous').leave(connection) 32 | app.channel('authenticated').join(connection) 33 | } 34 | }) 35 | 36 | const caslOptions = makeChannelOptions(app, { 37 | useActionName: 'receive', 38 | }) 39 | 40 | const fields = caslOptions.availableFields({ 41 | service: app.service('users'), 42 | }) 43 | 44 | assert.deepStrictEqual( 45 | fields, 46 | ['id', 'email', 'password'], 47 | 'gets availableFields from service correctly', 48 | ) 49 | 50 | app.publish((data: unknown, context: HookContext) => { 51 | const result = getChannelsWithReadAbility( 52 | app, 53 | data as Record, 54 | context, 55 | caslOptions, 56 | ) 57 | 58 | // e.g. to publish all service events to all authenticated users use 59 | return result 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /test/channels/receive/mockServices.receive.ts: -------------------------------------------------------------------------------- 1 | import type { Application, HookContext } from '@feathersjs/feathers' 2 | import { AuthenticationService, JWTStrategy } from '@feathersjs/authentication' 3 | import { LocalStrategy } from '@feathersjs/authentication-local' 4 | 5 | import { MemoryService } from '@feathersjs/memory' 6 | import hashPassword from '@feathersjs/authentication-local/lib/hooks/hash-password.js' 7 | import protect from '@feathersjs/authentication-local/lib/hooks/protect.js' 8 | import { createAliasResolver, defineAbility } from '@casl/ability' 9 | 10 | const resolveAction = createAliasResolver({ 11 | update: 'patch', 12 | read: ['get', 'find'], 13 | delete: 'remove', 14 | }) 15 | 16 | const defineAbilitiesFor = (user) => { 17 | return defineAbility( 18 | (can) => { 19 | if (user.id === 0) { 20 | can('manage', 'all') 21 | } else if (user.id === 1) { 22 | can('read', 'articles', { published: true }) 23 | can('read', 'comments', ['id', 'title'], { userId: user.id }) 24 | } else if (user.id === 2) { 25 | can('read', 'comments', { userId: user.id }) 26 | } else if (user.id === 3) { 27 | can('receive', 'all') 28 | } else if (user.id === 4) { 29 | can('receive', 'comments', ['id'], { userId: user.id }) 30 | } 31 | }, 32 | { resolveAction }, 33 | ) 34 | } 35 | 36 | export default function (app: Application): void { 37 | //#region authentication 38 | const authentication = new AuthenticationService(app) 39 | 40 | authentication.register('jwt', new JWTStrategy()) 41 | authentication.register('local', new LocalStrategy()) 42 | 43 | app.use('/authentication', authentication) 44 | 45 | const authService = app.service('authentication') 46 | authService.hooks({ 47 | after: { 48 | all: [], 49 | create: [ 50 | (context: HookContext): HookContext => { 51 | const { user } = context.result 52 | if (!user) return context 53 | const ability = defineAbilitiesFor(user) 54 | context.result.ability = ability 55 | context.result.rules = ability.rules 56 | 57 | return context 58 | }, 59 | ], 60 | remove: [], 61 | }, 62 | }) 63 | 64 | //#endregion 65 | 66 | //#region articles 67 | 68 | app.use( 69 | 'articles', 70 | new MemoryService({ 71 | multi: true, 72 | casl: { 73 | availableFields: ['id', 'test', 'published', 'test'], 74 | }, 75 | //paginate: 76 | }), 77 | ) 78 | 79 | //#endregion 80 | 81 | //#region comments 82 | 83 | app.use( 84 | 'comments', 85 | new MemoryService({ 86 | multi: true, 87 | casl: { 88 | availableFields: ['id', 'title', 'userId', 'test'], 89 | }, 90 | //paginate: 91 | }), 92 | ) 93 | 94 | //#endregion 95 | 96 | //#region users 97 | app.use( 98 | 'users', 99 | new MemoryService({ 100 | multi: true, 101 | casl: { 102 | availableFields: ['id', 'email', 'password'], 103 | }, 104 | //paginate: 105 | }), 106 | ) 107 | 108 | const users = app.service('users') 109 | 110 | users.hooks({ 111 | before: { 112 | all: [], 113 | create: [hashPassword('password')], 114 | update: [hashPassword('password')], 115 | patch: [hashPassword('password')], 116 | remove: [], 117 | }, 118 | after: { 119 | all: [ 120 | // Make sure the password field is never sent to the client 121 | // Always must be the last hook 122 | protect('password'), 123 | ], 124 | }, 125 | }) 126 | //#endregion 127 | } 128 | -------------------------------------------------------------------------------- /test/channels/withAvailableFields/mockChannels.availableFields.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import '@feathersjs/transport-commons' 3 | import type { 4 | HookContext, 5 | Params, 6 | RealTimeConnection, 7 | } from '@feathersjs/feathers' 8 | 9 | import { 10 | getChannelsWithReadAbility, 11 | makeChannelOptions, 12 | } from '../../../src/index.js' 13 | import type { Application } from '@feathersjs/express' 14 | 15 | export default function (app: Application): void { 16 | if (typeof app.channel !== 'function') { 17 | return 18 | } 19 | 20 | app.on('connection', (connection: RealTimeConnection): void => { 21 | app.channel('anonymous').join(connection) 22 | }) 23 | 24 | app.on('login', (authResult: any, { connection }: Params): void => { 25 | if (connection) { 26 | if (authResult.ability) { 27 | connection.ability = authResult.ability 28 | connection.rules = authResult.rules 29 | } 30 | 31 | app.channel('anonymous').leave(connection) 32 | app.channel('authenticated').join(connection) 33 | } 34 | }) 35 | 36 | const caslOptions = makeChannelOptions(app) 37 | 38 | const fields = caslOptions.availableFields({ 39 | service: app.service('users'), 40 | }) 41 | 42 | assert.deepStrictEqual( 43 | fields, 44 | ['id', 'email', 'password'], 45 | 'gets availableFields from service correctly', 46 | ) 47 | 48 | app.publish((data: unknown, context: HookContext) => { 49 | const result = getChannelsWithReadAbility( 50 | app, 51 | data as Record, 52 | context, 53 | caslOptions, 54 | ) 55 | 56 | // e.g. to publish all service events to all authenticated users use 57 | return result 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /test/channels/withAvailableFields/mockServices.availableFields.ts: -------------------------------------------------------------------------------- 1 | import type { Application, HookContext } from '@feathersjs/feathers' 2 | import { AuthenticationService, JWTStrategy } from '@feathersjs/authentication' 3 | import { LocalStrategy } from '@feathersjs/authentication-local' 4 | 5 | import type { ServiceCaslOptions } from '../../../src/index.js' 6 | import { MemoryService } from '@feathersjs/memory' 7 | import hashPassword from '@feathersjs/authentication-local/lib/hooks/hash-password.js' 8 | import protect from '@feathersjs/authentication-local/lib/hooks/protect.js' 9 | import { createAliasResolver, defineAbility } from '@casl/ability' 10 | 11 | const resolveAction = createAliasResolver({ 12 | update: 'patch', 13 | read: ['get', 'find'], 14 | delete: 'remove', 15 | }) 16 | 17 | declare module '@feathersjs/Memory' { 18 | interface MemoryServiceOptions { 19 | casl?: ServiceCaslOptions 20 | } 21 | } 22 | 23 | const defineAbilitiesFor = (user) => { 24 | return defineAbility( 25 | (can) => { 26 | if (user.id === 0) { 27 | can('manage', 'all') 28 | } else if (user.id === 1) { 29 | can('read', 'articles', { published: true }) 30 | can('read', 'comments', ['id', 'title'], { userId: user.id }) 31 | } else if (user.id === 2) { 32 | can('read', 'comments', { userId: user.id }) 33 | } 34 | }, 35 | { resolveAction }, 36 | ) 37 | } 38 | 39 | export default function (app: Application): void { 40 | //#region authentication 41 | const authentication = new AuthenticationService(app) 42 | 43 | authentication.register('jwt', new JWTStrategy()) 44 | authentication.register('local', new LocalStrategy()) 45 | 46 | app.use('/authentication', authentication) 47 | 48 | const authService = app.service('authentication') 49 | authService.hooks({ 50 | after: { 51 | all: [], 52 | create: [ 53 | (context: HookContext): HookContext => { 54 | const { user } = context.result 55 | if (!user) return context 56 | const ability = defineAbilitiesFor(user) 57 | context.result.ability = ability 58 | context.result.rules = ability.rules 59 | 60 | return context 61 | }, 62 | ], 63 | remove: [], 64 | }, 65 | }) 66 | 67 | //#endregion 68 | 69 | //#region articles 70 | 71 | app.use( 72 | 'articles', 73 | new MemoryService({ 74 | multi: true, 75 | casl: { 76 | availableFields: ['id', 'test', 'published', 'test'], 77 | }, 78 | //paginate: 79 | }), 80 | ) 81 | 82 | //#endregion 83 | 84 | //#region comments 85 | 86 | app.use( 87 | 'comments', 88 | new MemoryService({ 89 | multi: true, 90 | casl: { 91 | availableFields: ['id', 'title', 'userId', 'test'], 92 | }, 93 | //paginate: 94 | }), 95 | ) 96 | 97 | //#endregion 98 | 99 | //#region users 100 | app.use( 101 | 'users', 102 | new MemoryService({ 103 | multi: true, 104 | casl: { 105 | availableFields: ['id', 'email', 'password'], 106 | }, 107 | //paginate: 108 | }), 109 | ) 110 | 111 | const users = app.service('users') 112 | 113 | users.hooks({ 114 | before: { 115 | all: [], 116 | find: [], 117 | get: [], 118 | create: [hashPassword('password')], 119 | update: [hashPassword('password')], 120 | patch: [hashPassword('password')], 121 | remove: [], 122 | }, 123 | after: { 124 | all: [ 125 | // Make sure the password field is never sent to the client 126 | // Always must be the last hook 127 | protect('password'), 128 | ], 129 | }, 130 | }) 131 | //#endregion 132 | } 133 | -------------------------------------------------------------------------------- /test/hooks/authorize.users.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { defineAbility } from '@casl/ability' 3 | import { Sequelize, Op, DataTypes } from 'sequelize' 4 | import { feathers } from '@feathersjs/feathers' 5 | import { SequelizeService } from 'feathers-sequelize' 6 | import { authorize } from '../../src' 7 | import path from 'node:path' 8 | import { resolveAction } from '../test-utils' 9 | 10 | describe('authorize.users.test.ts', function () { 11 | function mockAbility(user) { 12 | const ability = defineAbility( 13 | (can, cannot) => { 14 | can('manage', 'all', { companyId: 1 }) 15 | can('manage', 'all', { companyId: 2 }) 16 | cannot(['create', 'update', 'patch', 'remove'], 'users', { roleId: 0 }) 17 | cannot('remove', 'users', { id: user.id }) 18 | cannot(['update', 'patch'], 'users', ['roleId'], { id: user.id }) 19 | cannot(['update', 'patch'], 'users', ['companyId']) 20 | }, 21 | { 22 | resolveAction, 23 | }, 24 | ) 25 | return ability 26 | } 27 | 28 | async function mockApp(hook) { 29 | const __dirname = import.meta.dirname 30 | 31 | const sequelize = new Sequelize('sequelize', '', '', { 32 | dialect: 'sqlite', 33 | storage: path.join(__dirname, '../.data/db2.sqlite'), 34 | logging: false, 35 | }) 36 | 37 | const UserModel = sequelize.define('users', { 38 | companyId: { 39 | type: DataTypes.INTEGER, 40 | }, 41 | name: { 42 | type: DataTypes.STRING, 43 | }, 44 | roleId: { 45 | type: DataTypes.INTEGER, 46 | }, 47 | }) 48 | 49 | await sequelize.sync() 50 | 51 | const app = feathers() 52 | 53 | app.use( 54 | '/users', 55 | new SequelizeService({ 56 | Model: UserModel, 57 | multi: true, 58 | operatorMap: { 59 | $not: Op.not, 60 | }, 61 | operators: ['$not'], 62 | }), 63 | ) 64 | 65 | const service = app.service('users') 66 | 67 | service.hooks({ 68 | before: { 69 | all: [ 70 | authorize({ 71 | adapter: 'feathers-sequelize', 72 | }), 73 | hook, 74 | ], 75 | find: [], 76 | get: [], 77 | create: [], 78 | update: [], 79 | patch: [], 80 | remove: [], 81 | }, 82 | after: { 83 | all: [ 84 | authorize({ 85 | adapter: 'feathers-sequelize', 86 | }), 87 | ], 88 | find: [], 89 | get: [], 90 | create: [], 91 | update: [], 92 | patch: [], 93 | remove: [], 94 | }, 95 | }) 96 | 97 | return { service } 98 | } 99 | 100 | it('user can update user', async function () { 101 | let hadAbility = false 102 | const { service } = await mockApp((context) => { 103 | if (!context.params.ability) { 104 | return context 105 | } 106 | hadAbility = true 107 | }) 108 | const admin = await service.create({ 109 | name: 'user1', 110 | roleId: 1, 111 | companyId: 1, 112 | }) 113 | const user2 = await service.create({ 114 | name: 'user2', 115 | roleId: 2, 116 | companyId: 1, 117 | }) 118 | const ability = mockAbility(admin) 119 | 120 | const user2Patched: Record = await service.patch( 121 | user2.id, 122 | { roleId: 3 }, 123 | { ability }, 124 | ) 125 | assert.deepStrictEqual(user2Patched.roleId, 3) 126 | assert.ok(hadAbility) 127 | }) 128 | }) 129 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/@feathersjs/knex.test.ts: -------------------------------------------------------------------------------- 1 | import { knex } from 'knex' 2 | import makeTests from '../makeTests/index.js' 3 | import { KnexService } from '@feathersjs/knex' 4 | import { getItemsIsArray } from 'feathers-utils' 5 | import type { HookContext } from '@feathersjs/feathers' 6 | import type { Adapter, ServiceCaslOptions } from '../../../../../src/index.js' 7 | 8 | declare module '@feathersjs/knex' { 9 | interface KnexAdapterOptions { 10 | casl: ServiceCaslOptions 11 | } 12 | } 13 | 14 | const db = knex({ 15 | client: 'sqlite3', 16 | debug: false, 17 | connection: { 18 | filename: ':memory:', 19 | }, 20 | useNullAsDefault: true, 21 | }) 22 | 23 | // Create the schema 24 | db.schema.createTable('messages', (table) => { 25 | table.increments('id') 26 | table.string('text') 27 | }) 28 | 29 | const makeService = () => { 30 | return new KnexService({ 31 | Model: db, 32 | name: 'tests', 33 | multi: true, 34 | casl: { 35 | availableFields: [ 36 | 'id', 37 | 'userId', 38 | 'hi', 39 | 'test', 40 | 'published', 41 | 'supersecret', 42 | 'hidden', 43 | ], 44 | }, 45 | paginate: { 46 | default: 10, 47 | max: 50, 48 | }, 49 | }) 50 | } 51 | 52 | const boolFields = ['test', 'published', 'supersecret', 'hidden'] 53 | 54 | const afterHooks = [ 55 | (context: HookContext) => { 56 | const { items, isArray } = getItemsIsArray(context) 57 | 58 | const result = items 59 | 60 | result.forEach((item, i) => { 61 | const keys = Object.keys(item) 62 | keys.forEach((key) => { 63 | if (item[key] === null) { 64 | delete item[key] 65 | return 66 | } 67 | if (boolFields.includes(key)) { 68 | item[key] = !!item[key] 69 | } 70 | }) 71 | 72 | result[i] = { ...item } 73 | }) 74 | 75 | context.result = isArray ? result : result[0] 76 | }, 77 | ] 78 | 79 | const adapter: Adapter = '@feathersjs/knex' 80 | 81 | makeTests( 82 | adapter, 83 | makeService, 84 | async () => { 85 | await db.schema.dropTableIfExists('tests') 86 | await db.schema.createTable('tests', (table) => { 87 | table.increments('id') 88 | table.integer('userId') 89 | table.string('hi') 90 | table.boolean('test') 91 | table.boolean('published') 92 | table.boolean('supersecret') 93 | table.boolean('hidden') 94 | }) 95 | }, 96 | { adapter }, 97 | { afterHooks }, 98 | ) 99 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/@feathersjs/memory.around.test.ts: -------------------------------------------------------------------------------- 1 | import { MemoryService } from '@feathersjs/memory' 2 | import { filterArray } from 'feathers-utils' 3 | import type { Adapter, ServiceCaslOptions } from '../../../../../src/index.js' 4 | import makeTests from '../makeTests/index.js' 5 | 6 | declare module '@feathersjs/memory' { 7 | interface MemoryServiceOptions { 8 | casl?: ServiceCaslOptions 9 | } 10 | } 11 | 12 | const makeService = () => { 13 | const service = new MemoryService({ 14 | multi: true, 15 | filters: { 16 | ...filterArray('$nor'), 17 | }, 18 | operators: ['$nor'], 19 | casl: { 20 | availableFields: [ 21 | 'id', 22 | 'userId', 23 | 'hi', 24 | 'test', 25 | 'published', 26 | 'supersecret', 27 | 'hidden', 28 | ], 29 | }, 30 | paginate: { 31 | default: 10, 32 | max: 50, 33 | }, 34 | }) 35 | 36 | return service 37 | } 38 | 39 | const adapter: Adapter = '@feathersjs/memory' 40 | 41 | makeTests( 42 | '@feathersjs/memory:around', 43 | makeService, 44 | async (app, service) => { 45 | await service._remove(null) 46 | }, 47 | { adapter: adapter }, 48 | { around: true }, 49 | ) 50 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/@feathersjs/memory.test.ts: -------------------------------------------------------------------------------- 1 | import { MemoryService } from '@feathersjs/memory' 2 | import { filterArray } from 'feathers-utils' 3 | import type { Adapter, ServiceCaslOptions } from '../../../../../src/index.js' 4 | import makeTests from '../makeTests/index.js' 5 | 6 | declare module '@feathersjs/memory' { 7 | interface MemoryServiceOptions { 8 | casl?: ServiceCaslOptions 9 | } 10 | } 11 | 12 | const makeService = () => { 13 | const service = new MemoryService({ 14 | multi: true, 15 | filters: { 16 | ...filterArray('$nor'), 17 | }, 18 | operators: ['$nor'], 19 | casl: { 20 | availableFields: [ 21 | 'id', 22 | 'userId', 23 | 'hi', 24 | 'test', 25 | 'published', 26 | 'supersecret', 27 | 'hidden', 28 | ], 29 | }, 30 | paginate: { 31 | default: 10, 32 | max: 50, 33 | }, 34 | }) 35 | 36 | return service 37 | } 38 | 39 | const adapter: Adapter = '@feathersjs/memory' 40 | 41 | makeTests( 42 | adapter, 43 | makeService, 44 | async (app, service) => { 45 | await service.remove(null) 46 | }, 47 | { adapter: adapter }, 48 | ) 49 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/@feathersjs/mongodb.test.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from 'mongodb-memory-server' 2 | import { MongoClient } from 'mongodb' 3 | import { MongoDBService } from '@feathersjs/mongodb' 4 | 5 | import makeTests from '../makeTests/index.js' 6 | import type { Adapter, ServiceCaslOptions } from '../../../../../src/index.js' 7 | import { filterArray } from 'feathers-utils' 8 | 9 | let Model 10 | 11 | declare module '@feathersjs/mongodb' { 12 | interface MongoDBAdapterOptions { 13 | casl: ServiceCaslOptions 14 | } 15 | } 16 | 17 | const makeService = () => { 18 | return new MongoDBService({ 19 | Model, 20 | multi: true, 21 | operators: ['$nor'], 22 | filters: { 23 | ...filterArray('$nor'), 24 | }, 25 | casl: { 26 | availableFields: [ 27 | 'id', 28 | 'userId', 29 | 'hi', 30 | 'test', 31 | 'published', 32 | 'supersecret', 33 | 'hidden', 34 | ], 35 | }, 36 | paginate: { 37 | default: 10, 38 | max: 50, 39 | }, 40 | }) 41 | } 42 | 43 | beforeAll(async function () { 44 | const server = await MongoMemoryServer.create() 45 | const uri = server.getUri() 46 | 47 | const client = await MongoClient.connect(uri) 48 | Model = client.db('tests').collection('tests') 49 | }) 50 | 51 | const adapter: Adapter = '@feathersjs/mongodb' 52 | 53 | makeTests( 54 | adapter, 55 | makeService, 56 | async (app, service) => { 57 | await service.remove(null) 58 | }, 59 | { adapter }, 60 | ) 61 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/feathers-mongoose.test_.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from 'mongodb-memory-server' 2 | import mongoose from 'mongoose' 3 | import { Service } from 'feathers-mongoose' 4 | mongoose.Promise = global.Promise 5 | 6 | import makeTests from './makeTests/index.js' 7 | import { getItemsIsArray } from 'feathers-utils' 8 | import type { ServiceCaslOptions } from '../../../../src/index.js' 9 | import type { HookContext } from '@feathersjs/feathers' 10 | 11 | let Model 12 | 13 | declare module 'feathers-mongoose' { 14 | interface MongooseServiceOptions { 15 | casl: ServiceCaslOptions 16 | } 17 | } 18 | 19 | const makeService = () => { 20 | return new Service({ 21 | Model, 22 | multi: true, 23 | lean: true, 24 | whitelist: ['$nor'], 25 | casl: { 26 | availableFields: [ 27 | 'id', 28 | 'userId', 29 | 'hi', 30 | 'test', 31 | 'published', 32 | 'supersecret', 33 | 'hidden', 34 | ], 35 | }, 36 | paginate: { 37 | default: 10, 38 | max: 50, 39 | }, 40 | }) 41 | } 42 | 43 | const afterHooks = [ 44 | (context: HookContext) => { 45 | const { items } = getItemsIsArray(context) 46 | 47 | items.forEach((item) => { 48 | delete item.__v 49 | }) 50 | }, 51 | ] 52 | 53 | makeTests( 54 | 'feathers-mongoose', 55 | makeService, 56 | async (app, service) => { 57 | await service.remove(null) 58 | }, 59 | { adapter: 'feathers-mongoose' }, 60 | afterHooks, 61 | async () => { 62 | const server = await MongoMemoryServer.create() 63 | const uri = server.getUri() 64 | 65 | const client = await mongoose.connect(uri) 66 | 67 | const { Schema } = client 68 | const schema = new Schema( 69 | { 70 | userId: { type: Number }, 71 | hi: { type: String }, 72 | test: { type: Boolean }, 73 | published: { type: Boolean }, 74 | supersecret: { type: Boolean }, 75 | hidden: { type: Boolean }, 76 | }, 77 | { 78 | timestamps: false, 79 | }, 80 | ) 81 | 82 | if (client.modelNames().includes('tests')) { 83 | client.deleteModel('tests') 84 | } 85 | Model = client.model('tests', schema) 86 | }, 87 | ) 88 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/feathers-nedb.test_.ts: -------------------------------------------------------------------------------- 1 | import NeDB from '@seald-io/nedb' 2 | import { Service } from 'feathers-nedb' 3 | import makeTests from './makeTests/index.js' 4 | import path from 'node:path' 5 | import type { ServiceCaslOptions } from '../../../../src/index.js' 6 | 7 | const __dirname = import.meta.dirname 8 | 9 | // Create a NeDB instance 10 | const Model = new NeDB({ 11 | filename: path.join(__dirname, '../../../.data/tests.db'), 12 | autoload: true, 13 | }) 14 | 15 | declare module 'feathers-nedb' { 16 | interface NedbServiceOptions { 17 | casl: ServiceCaslOptions 18 | } 19 | } 20 | 21 | const makeService = () => { 22 | return new Service({ 23 | Model, 24 | multi: true, 25 | whitelist: ['$not', '$and'], 26 | casl: { 27 | availableFields: [ 28 | 'id', 29 | 'userId', 30 | 'hi', 31 | 'test', 32 | 'published', 33 | 'supersecret', 34 | 'hidden', 35 | ], 36 | }, 37 | paginate: { 38 | default: 10, 39 | max: 50, 40 | }, 41 | }) 42 | } 43 | 44 | makeTests( 45 | 'feathers-nedb', 46 | makeService, 47 | async (app, service) => { 48 | await service.remove(null) 49 | }, 50 | { adapter: 'feathers-nedb' }, 51 | ) 52 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/feathers-objection.test_.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'objection' 2 | import makeTests from './makeTests' 3 | import { Service } from 'feathers-objection' 4 | import { getItemsIsArray } from 'feathers-utils' 5 | import knex from 'knex' 6 | import path from 'node:path' 7 | import type { ServiceCaslOptions } from '../../../../src' 8 | import type { HookContext } from '@feathersjs/feathers' 9 | 10 | const __dirname = import.meta.dirname 11 | 12 | const db = knex({ 13 | client: 'sqlite3', 14 | debug: false, 15 | connection: { 16 | filename: path.join(__dirname, '../../../.data/db.sqlite'), 17 | }, 18 | useNullAsDefault: true, 19 | }) 20 | 21 | Model.knex(db) 22 | 23 | declare module 'feathers-objection' { 24 | interface ObjectionServiceOptions { 25 | casl: ServiceCaslOptions 26 | } 27 | } 28 | 29 | class TestModel extends Model { 30 | static get tableName() { 31 | return 'tests' 32 | } 33 | 34 | static jsonSchema = { 35 | type: 'object', 36 | properties: { 37 | userId: { type: ['integer', 'null'] }, 38 | hi: { type: ['string', 'null'] }, 39 | test: { type: ['boolean', 'null'] }, 40 | published: { type: ['boolean', 'null'] }, 41 | supersecret: { type: ['boolean', 'null'] }, 42 | hidden: { type: ['boolean', 'null'] }, 43 | }, 44 | } 45 | } 46 | 47 | const makeService = () => { 48 | return new Service({ 49 | model: TestModel, 50 | multi: true, 51 | casl: { 52 | availableFields: [ 53 | 'id', 54 | 'userId', 55 | 'hi', 56 | 'test', 57 | 'published', 58 | 'supersecret', 59 | 'hidden', 60 | ], 61 | }, 62 | paginate: { 63 | default: 10, 64 | max: 50, 65 | }, 66 | // filters: { 67 | // // @ts-ignore 68 | // ...filterArray("$and") 69 | // } 70 | }) 71 | } 72 | 73 | const boolFields = ['test', 'published', 'supersecret', 'hidden'] 74 | 75 | const afterHooks = [ 76 | (context: HookContext) => { 77 | const { items, isArray } = getItemsIsArray(context) 78 | 79 | const result = items 80 | 81 | result.forEach((item, i) => { 82 | const keys = Object.keys(item) 83 | keys.forEach((key) => { 84 | if (item[key] === null) { 85 | delete item[key] 86 | return 87 | } 88 | if (boolFields.includes(key)) { 89 | item[key] = !!item[key] 90 | } 91 | }) 92 | 93 | result[i] = { ...item } 94 | }) 95 | 96 | context.result = isArray ? result : result[0] 97 | }, 98 | ] 99 | 100 | makeTests( 101 | 'feathers-objection', 102 | makeService, 103 | async () => { 104 | await db.schema.dropTableIfExists('tests') 105 | await db.schema.createTable('tests', (table) => { 106 | table.increments('id') 107 | table.integer('userId') 108 | table.string('hi') 109 | table.boolean('test') 110 | table.boolean('published') 111 | table.boolean('supersecret') 112 | table.boolean('hidden') 113 | }) 114 | }, 115 | { adapter: 'feathers-objection' }, 116 | afterHooks, 117 | ) 118 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/feathers-sequelize.test.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, DataTypes, Op } from 'sequelize' 2 | import makeTests from './makeTests/index.js' 3 | import { SequelizeService } from 'feathers-sequelize' 4 | import { getItemsIsArray, filterArray } from 'feathers-utils' 5 | import type { ServiceCaslOptions } from '../../../../src/index.js' 6 | import type { HookContext } from '@feathersjs/feathers' 7 | 8 | const sequelize = new Sequelize('sqlite::memory:', { 9 | logging: false, 10 | }) 11 | 12 | const Model = sequelize.define( 13 | 'tests', 14 | { 15 | userId: { 16 | type: DataTypes.INTEGER, 17 | }, 18 | hi: { 19 | type: DataTypes.STRING, 20 | }, 21 | test: { 22 | type: DataTypes.BOOLEAN, 23 | }, 24 | published: { 25 | type: DataTypes.BOOLEAN, 26 | }, 27 | supersecret: { 28 | type: DataTypes.BOOLEAN, 29 | }, 30 | hidden: { 31 | type: DataTypes.BOOLEAN, 32 | }, 33 | }, 34 | { 35 | timestamps: false, 36 | }, 37 | ) 38 | 39 | declare module 'feathers-sequelize' { 40 | interface SequelizeAdapterOptions { 41 | casl?: ServiceCaslOptions 42 | } 43 | } 44 | 45 | const makeService = () => { 46 | return new SequelizeService({ 47 | Model, 48 | multi: true, 49 | operatorMap: { 50 | $not: Op.not, 51 | }, 52 | filters: { 53 | ...filterArray('$not'), 54 | }, 55 | operators: ['$not'], 56 | casl: { 57 | availableFields: [ 58 | 'id', 59 | 'userId', 60 | 'hi', 61 | 'test', 62 | 'published', 63 | 'supersecret', 64 | 'hidden', 65 | ], 66 | }, 67 | paginate: { 68 | default: 10, 69 | max: 50, 70 | }, 71 | }) 72 | } 73 | 74 | const afterHooks = [ 75 | (context: HookContext) => { 76 | const { Model } = context.service 77 | const fields = Model.fieldRawAttributesMap 78 | const { items } = getItemsIsArray(context) 79 | 80 | items.forEach((item) => { 81 | const keys = Object.keys(item) 82 | keys.forEach((key) => { 83 | const field = fields[key] 84 | if (item[key] === null) { 85 | delete item[key] 86 | return 87 | } 88 | 89 | if (field.type instanceof DataTypes.BOOLEAN) { 90 | item[key] = !!item[key] 91 | } 92 | }) 93 | }) 94 | }, 95 | ] 96 | 97 | makeTests( 98 | 'feathers-sequelize', 99 | makeService, 100 | async () => { 101 | await Model.sync({ force: true }) 102 | }, 103 | { adapter: 'feathers-sequelize' }, 104 | { afterHooks }, 105 | ) 106 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/makeTests/_makeTests.types.ts: -------------------------------------------------------------------------------- 1 | export type MakeTestsOptions = { 2 | around?: boolean 3 | afterHooks?: unknown[] 4 | actionBefore?: () => Promise | void 5 | } 6 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/makeTests/create-multi.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { feathers } from '@feathersjs/feathers' 3 | import { defineAbility } from '@casl/ability' 4 | import _sortBy from 'lodash/sortBy.js' 5 | 6 | import type { Application } from '@feathersjs/feathers' 7 | 8 | import { authorize } from '../../../../../src/index.js' 9 | import type { Adapter, AuthorizeHookOptions } from '../../../../../src/index.js' 10 | import { resolveAction } from '../../../../test-utils.js' 11 | import type { MakeTestsOptions } from './_makeTests.types.js' 12 | 13 | export default ( 14 | adapterName: Adapter | string, 15 | makeService: () => any, 16 | clean: (app, service) => Promise, 17 | authorizeHookOptions: Partial, 18 | { around, afterHooks }: MakeTestsOptions = { around: false, afterHooks: [] }, 19 | ): void => { 20 | let app: Application 21 | let service 22 | let id 23 | 24 | // const itSkip = (adapterToTest: string | string[]) => { 25 | // const condition = 26 | // typeof adapterToTest === "string" 27 | // ? adapterName === adapterToTest 28 | // : adapterToTest.includes(adapterName); 29 | // return condition ? it.skip : it; 30 | // }; 31 | 32 | describe(`${adapterName}: beforeAndAfter - create:multi`, function () { 33 | beforeEach(async function () { 34 | app = feathers() 35 | app.use('tests', makeService()) 36 | service = app.service('tests') 37 | 38 | id = service.options.id 39 | 40 | const options = Object.assign( 41 | { 42 | availableFields: [ 43 | id, 44 | 'userId', 45 | 'hi', 46 | 'test', 47 | 'published', 48 | 'supersecret', 49 | 'hidden', 50 | ], 51 | }, 52 | authorizeHookOptions, 53 | ) 54 | 55 | afterHooks = Array.isArray(afterHooks) 56 | ? afterHooks 57 | : afterHooks 58 | ? [afterHooks] 59 | : [] 60 | 61 | if (around) { 62 | service.hooks({ 63 | around: { 64 | all: [authorize(options)], 65 | }, 66 | before: {}, 67 | after: { 68 | all: afterHooks, 69 | }, 70 | }) 71 | } else { 72 | service.hooks({ 73 | before: { 74 | all: [authorize(options)], 75 | }, 76 | after: { 77 | all: [...afterHooks, authorize(options)], 78 | }, 79 | }) 80 | } 81 | 82 | await clean(app, service) 83 | }) 84 | 85 | it('can create multiple items and returns empty array', async function () { 86 | const allItems = (await service.find({ paginate: false })) as unknown[] 87 | assert.strictEqual(allItems.length, 0, 'has no items before') 88 | 89 | const itemsArr = [ 90 | { test: true, hi: '1', userId: 1 }, 91 | { test: true, hi: '2', userId: 1 }, 92 | { test: true, hi: '3', userId: 1 }, 93 | ] 94 | const items = await service.create(itemsArr, { 95 | ability: defineAbility( 96 | (can) => { 97 | can('create', 'tests', { userId: 1 }) 98 | }, 99 | { resolveAction }, 100 | ), 101 | }) 102 | 103 | assert.strictEqual(items.length, 0, 'array is empty') 104 | }) 105 | 106 | it('can create multiple items and returns all items', async function () { 107 | const readMethods = ['read', 'find'] 108 | for (const read of readMethods) { 109 | await clean(app, service) 110 | const allItems = (await service.find({ paginate: false })) as unknown[] 111 | assert.strictEqual( 112 | allItems.length, 113 | 0, 114 | `has no items before for read: '${read}'`, 115 | ) 116 | const itemsArr = [ 117 | { test: true, hi: '1', userId: 1 }, 118 | { test: true, hi: '2', userId: 1 }, 119 | { test: true, hi: '3', userId: 1 }, 120 | ] 121 | const items = await service.create(itemsArr, { 122 | ability: defineAbility( 123 | (can) => { 124 | can('create', 'tests', { userId: 1 }) 125 | can(read, 'tests') 126 | }, 127 | { resolveAction }, 128 | ), 129 | }) 130 | 131 | const expectedItems = (await service.find({ 132 | paginate: false, 133 | })) as Record[] 134 | 135 | assert.deepStrictEqual( 136 | _sortBy(items, id), 137 | _sortBy(expectedItems, id), 138 | `created items for read: '${read}'`, 139 | ) 140 | } 141 | }) 142 | 143 | it("rejects if one item can't be created", async function () { 144 | const itemsArr = [ 145 | { test: true, hi: '1', userId: 1 }, 146 | { test: true, hi: '2', userId: 2 }, 147 | { test: true, hi: '3', userId: 1 }, 148 | ] 149 | const promise = service.create(itemsArr, { 150 | ability: defineAbility( 151 | (can) => { 152 | can('create', 'tests', { userId: 1 }) 153 | }, 154 | { resolveAction }, 155 | ), 156 | }) 157 | 158 | await assert.rejects( 159 | promise, 160 | (err: Error) => err.name === 'Forbidden', 161 | 'rejects because different userId', 162 | ) 163 | }) 164 | 165 | it('picks properties for fields for multiple created data', async function () { 166 | const itemsArr = [ 167 | { test: true, hi: '1', userId: 1 }, 168 | { test: true, hi: '2', userId: 2 }, 169 | { test: true, hi: '3', userId: 1 }, 170 | ] 171 | const items = await service.create(itemsArr, { 172 | ability: defineAbility( 173 | (can) => { 174 | can('create', 'tests') 175 | can('read', 'tests') 176 | can('read', 'tests', [id], { userId: 2 }) 177 | can('read', 'tests', [id, 'userId'], { hi: '3' }) 178 | }, 179 | { resolveAction }, 180 | ), 181 | }) 182 | 183 | const expected = [ 184 | { [id]: items[0][id], test: true, hi: '1', userId: 1 }, 185 | { [id]: items[1][id] }, 186 | { [id]: items[2][id], userId: 1 }, 187 | ] 188 | 189 | assert.deepStrictEqual(items, expected, 'filtered properties') 190 | }) 191 | }) 192 | } 193 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/makeTests/create.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { feathers } from '@feathersjs/feathers' 3 | import { defineAbility } from '@casl/ability' 4 | 5 | import type { Application } from '@feathersjs/feathers' 6 | 7 | import { authorize } from '../../../../../src/index.js' 8 | import type { Adapter, AuthorizeHookOptions } from '../../../../../src/index.js' 9 | import { resolveAction } from '../../../../test-utils.js' 10 | import type { MakeTestsOptions } from './_makeTests.types.js' 11 | 12 | export default ( 13 | adapterName: Adapter | string, 14 | makeService: () => any, 15 | clean: (app, service) => Promise, 16 | authorizeHookOptions: Partial, 17 | { around, afterHooks }: MakeTestsOptions = { around: false, afterHooks: [] }, 18 | ): void => { 19 | let app: Application 20 | let service 21 | let id 22 | 23 | // const itSkip = (adapterToTest: string | string[]) => { 24 | // const condition = 25 | // typeof adapterToTest === "string" 26 | // ? adapterName === adapterToTest 27 | // : adapterToTest.includes(adapterName); 28 | // return condition ? it.skip : it; 29 | // }; 30 | 31 | describe(`${adapterName}: beforeAndAfter - create:single`, function () { 32 | beforeEach(async function () { 33 | app = feathers() 34 | app.use('tests', makeService()) 35 | service = app.service('tests') 36 | 37 | id = service.options.id 38 | 39 | const options = Object.assign( 40 | { 41 | availableFields: [ 42 | id, 43 | 'userId', 44 | 'hi', 45 | 'test', 46 | 'published', 47 | 'supersecret', 48 | 'hidden', 49 | ], 50 | }, 51 | authorizeHookOptions, 52 | ) 53 | 54 | afterHooks = Array.isArray(afterHooks) 55 | ? afterHooks 56 | : afterHooks 57 | ? [afterHooks] 58 | : [] 59 | 60 | if (around) { 61 | service.hooks({ 62 | around: { 63 | all: [authorize(options)], 64 | }, 65 | after: { 66 | all: afterHooks, 67 | }, 68 | }) 69 | } else { 70 | service.hooks({ 71 | before: { 72 | all: [authorize(options)], 73 | }, 74 | after: { 75 | all: [...afterHooks, authorize(options)], 76 | }, 77 | }) 78 | } 79 | 80 | await clean(app, service) 81 | }) 82 | 83 | it("can create one item and return 'undefined' for not allowed read", async function () { 84 | const allItems = (await service.find({ paginate: false })) as unknown[] 85 | assert.strictEqual(allItems.length, 0, 'has no items before') 86 | const item = await service.create( 87 | { test: true, userId: 1 }, 88 | { 89 | ability: defineAbility( 90 | (can) => { 91 | can('create', 'tests', { userId: 1 }) 92 | }, 93 | { resolveAction }, 94 | ), 95 | }, 96 | ) 97 | 98 | assert.deepStrictEqual(item, undefined, 'created item is undefined') 99 | 100 | const [realItem] = await service.find({ paginate: false }) 101 | assert.deepStrictEqual( 102 | realItem, 103 | { [id]: realItem[id], test: true, userId: 1 }, 104 | 'created item', 105 | ) 106 | }) 107 | 108 | it('can create one item and return all properties', async function () { 109 | const allItems = (await service.find({ paginate: false })) as unknown[] 110 | assert.strictEqual(allItems.length, 0, 'has no items before') 111 | const item = await service.create( 112 | { test: true, userId: 1 }, 113 | { 114 | ability: defineAbility( 115 | (can) => { 116 | can('create', 'tests', { userId: 1 }) 117 | can('read', 'tests') 118 | }, 119 | { resolveAction }, 120 | ), 121 | }, 122 | ) 123 | 124 | assert.deepStrictEqual( 125 | item, 126 | { [id]: item[id], test: true, userId: 1 }, 127 | 'created item with all properties', 128 | ) 129 | }) 130 | 131 | it("can't create not allowed item", async function () { 132 | const promise = service.create( 133 | { test: true, userId: 1 }, 134 | { 135 | ability: defineAbility( 136 | (can) => { 137 | can('create', 'tests', { userId: 2 }) 138 | }, 139 | { resolveAction }, 140 | ), 141 | }, 142 | ) 143 | 144 | await assert.rejects( 145 | promise, 146 | (err: Error) => err.name === 'Forbidden', 147 | 'rejects', 148 | ) 149 | }) 150 | 151 | it('can create one item and just returns id', async function () { 152 | const item = await service.create( 153 | { test: true, userId: 1 }, 154 | { 155 | ability: defineAbility( 156 | (can) => { 157 | can('create', 'tests', { userId: 1 }) 158 | can('read', 'tests', [id], { userId: 1 }) 159 | }, 160 | { resolveAction }, 161 | ), 162 | }, 163 | ) 164 | 165 | assert.deepStrictEqual(item, { [id]: item[id] }, 'just returns with id') 166 | }) 167 | 168 | it('throws if cannot create item but passes with other item', async function () { 169 | await assert.rejects( 170 | service.create( 171 | { test: true, userId: 1 }, 172 | { 173 | ability: defineAbility( 174 | (can, cannot) => { 175 | can('create', 'tests') 176 | cannot('create', 'tests', { userId: 1 }) 177 | }, 178 | { resolveAction }, 179 | ), 180 | }, 181 | ), 182 | (err: Error) => err.name === 'Forbidden', 183 | 'cannot create item', 184 | ) 185 | 186 | await assert.doesNotReject( 187 | service.create( 188 | { test: true, userId: 2 }, 189 | { 190 | ability: defineAbility( 191 | (can, cannot) => { 192 | can('create', 'tests') 193 | cannot('create', 'tests', { userId: 1 }) 194 | }, 195 | { resolveAction }, 196 | ), 197 | }, 198 | ), 199 | "can create 'userId:2'", 200 | ) 201 | }) 202 | 203 | it("creates item and returns empty object for not overlapping '$select' and 'restricting fields'", async function () { 204 | let item = { test: true, userId: 1, supersecret: true, hidden: true } 205 | const result = await service.create(item, { 206 | query: { $select: [id, 'supersecret', 'hidden'] }, 207 | ability: defineAbility( 208 | (can) => { 209 | can('read', 'tests', ['test', 'userId']) 210 | can('create', 'tests') 211 | }, 212 | { resolveAction }, 213 | ), 214 | }) 215 | ;[item] = await service.find({ paginate: false }) 216 | assert.deepStrictEqual( 217 | result, 218 | {}, 219 | 'returned item is empty because of $select and restricting fields', 220 | ) 221 | 222 | const itemInDb = await service.get(item[id]) 223 | assert.deepStrictEqual(itemInDb, item, 'item in db is complete') 224 | }) 225 | }) 226 | } 227 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/makeTests/get.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { feathers } from '@feathersjs/feathers' 3 | import { defineAbility } from '@casl/ability' 4 | 5 | import type { Application } from '@feathersjs/feathers' 6 | 7 | import { authorize } from '../../../../../src/index.js' 8 | import type { Adapter, AuthorizeHookOptions } from '../../../../../src/index.js' 9 | import { resolveAction } from '../../../../test-utils.js' 10 | import type { MakeTestsOptions } from './_makeTests.types.js' 11 | 12 | export default ( 13 | adapterName: Adapter | string, 14 | makeService: () => any, 15 | clean: (app, service) => Promise, 16 | authorizeHookOptions: Partial, 17 | { around, afterHooks }: MakeTestsOptions = { around: false, afterHooks: [] }, 18 | ): void => { 19 | let app: Application 20 | let service 21 | let id 22 | 23 | // const itSkip = (adapterToTest: string | string[]) => { 24 | // const condition = 25 | // typeof adapterToTest === "string" 26 | // ? adapterName === adapterToTest 27 | // : adapterToTest.includes(adapterName); 28 | // return condition ? it.skip : it; 29 | // }; 30 | 31 | describe(`${adapterName}: beforeAndAfter - get`, function () { 32 | beforeEach(async function () { 33 | app = feathers() 34 | app.use('tests', makeService()) 35 | service = app.service('tests') 36 | 37 | id = service.options.id 38 | 39 | const options = Object.assign( 40 | { 41 | availableFields: [ 42 | id, 43 | 'userId', 44 | 'hi', 45 | 'test', 46 | 'published', 47 | 'supersecret', 48 | 'hidden', 49 | ], 50 | }, 51 | authorizeHookOptions, 52 | ) 53 | 54 | afterHooks = Array.isArray(afterHooks) 55 | ? afterHooks 56 | : afterHooks 57 | ? [afterHooks] 58 | : [] 59 | 60 | if (around) { 61 | service.hooks({ 62 | around: { 63 | all: [authorize(options)], 64 | }, 65 | after: { 66 | all: afterHooks, 67 | }, 68 | }) 69 | } else { 70 | service.hooks({ 71 | before: { 72 | all: [authorize(options)], 73 | }, 74 | after: { 75 | all: [...afterHooks, authorize(options)], 76 | }, 77 | }) 78 | } 79 | 80 | await clean(app, service) 81 | }) 82 | 83 | it('returns full item', async function () { 84 | const readMethods = ['read', 'get'] 85 | for (const read of readMethods) { 86 | const item = await service.create({ test: true, userId: 1 }) 87 | assert.ok(item[id] !== undefined, `item has id for read: '${read}'`) 88 | const returnedItem = await service.get(item[id], { 89 | ability: defineAbility( 90 | (can) => { 91 | can(read, 'tests', { userId: 1 }) 92 | }, 93 | { resolveAction }, 94 | ), 95 | }) 96 | assert.deepStrictEqual( 97 | returnedItem, 98 | item, 99 | `'create' and 'get' item are the same for read: '${read}'`, 100 | ) 101 | } 102 | }) 103 | 104 | it('returns subset of fields', async function () { 105 | const item = await service.create({ test: true, userId: 1 }) 106 | assert.ok(item[id] !== undefined, 'item has id') 107 | const returnedItem = await service.get(item[id], { 108 | ability: defineAbility( 109 | (can) => { 110 | can('read', 'tests', [id], { userId: 1 }) 111 | }, 112 | { resolveAction }, 113 | ), 114 | }) 115 | assert.deepStrictEqual( 116 | returnedItem, 117 | { [id]: item[id] }, 118 | "'get' returns only [id]", 119 | ) 120 | }) 121 | 122 | it('returns restricted subset of fields with $select', async function () { 123 | const item = await service.create({ 124 | test: true, 125 | userId: 1, 126 | published: true, 127 | }) 128 | assert.ok(item[id] !== undefined, 'item has id') 129 | const returnedItem = await service.get(item[id], { 130 | ability: defineAbility( 131 | (can) => { 132 | can('read', 'tests', [id], { userId: 1 }) 133 | }, 134 | { resolveAction }, 135 | ), 136 | query: { 137 | $select: [id, 'userId'], 138 | }, 139 | }) 140 | assert.deepStrictEqual( 141 | returnedItem, 142 | { [id]: item[id] }, 143 | "'get' returns only [id]", 144 | ) 145 | }) 146 | 147 | it.skip('returns subset of fields with inverted fields', async function () {}) 148 | 149 | it("throws 'NotFound' for not 'can'", async function () { 150 | const item = await service.create({ test: true, userId: 1 }) 151 | assert.ok(item[id] !== undefined, 'item has id') 152 | const returnedItem = service.get(item[id], { 153 | ability: defineAbility( 154 | (can) => { 155 | can('read', 'tests', { userId: 2 }) 156 | }, 157 | { resolveAction }, 158 | ), 159 | }) 160 | // rejects with 'NotFound' because it's handled by feathers itself 161 | // the rejection comes not from `feathers-casl` before/after-hook but from the adapter call 162 | // the requesting user should not have the knowledge, that the item exist at all 163 | await assert.rejects( 164 | returnedItem, 165 | (err: Error) => err.name === 'NotFound', 166 | 'rejects for id not allowed', 167 | ) 168 | }) 169 | 170 | it("throws 'NotFound' for explicit 'cannot'", async function () { 171 | const item = await service.create({ test: true, userId: 1 }) 172 | assert.ok(item[id] !== undefined, 'item has id') 173 | const returnedItem = service.get(item[id], { 174 | ability: defineAbility( 175 | (can, cannot) => { 176 | can('read', 'tests') 177 | cannot('read', 'tests', { userId: 1 }) 178 | }, 179 | { resolveAction }, 180 | ), 181 | }) 182 | // rejects with 'NotFound' because it's handled by feathers itself 183 | // the rejection comes not from `feathers-casl` before/after-hook but from the adapter call 184 | // the requesting user should not have the knowledge, that the item exist at all 185 | await assert.rejects( 186 | returnedItem, 187 | (err: Error) => err.name === 'NotFound', 188 | 'rejects for id not allowed', 189 | ) 190 | }) 191 | 192 | it("throws if $select and restricted fields don't overlap", async function () { 193 | const item = await service.create({ 194 | test: true, 195 | userId: 1, 196 | supersecret: true, 197 | hidden: true, 198 | }) 199 | assert.ok(item[id] !== undefined, 'item has id') 200 | 201 | const promise = service.get(item[id], { 202 | query: { $select: [id, 'supersecret', 'hidden'] }, 203 | ability: defineAbility( 204 | (can) => { 205 | can('read', 'tests', ['test', 'userId']) 206 | }, 207 | { resolveAction }, 208 | ), 209 | }) 210 | // rejects with 'Forbidden' which is handled by the after-hook 211 | // the requesting user potentially can get the item, but he cannot get these fields 212 | // maybe it should not throw, because that is an indication for hackers, that the data exists 213 | // default behavior with `$select: ['nonExistent']` is: `{[id]: ${id} }` 214 | await assert.rejects( 215 | promise, 216 | (err: Error) => err.name === 'Forbidden', 217 | 'rejects for id not allowed', 218 | ) 219 | }) 220 | }) 221 | } 222 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/makeTests/index.ts: -------------------------------------------------------------------------------- 1 | import makeFindTests from './find.js' 2 | import makeGetTests from './get.js' 3 | import makeCreateTests from './create.js' 4 | import makeCreateMultiTests from './create-multi.js' 5 | import makeUpdateTests from './update.js' 6 | import makeUpdateDataTests from './update-data.js' 7 | import makePatchTests from './patch.js' 8 | import makePatchDataTests from './patch-data.js' 9 | import makePatchMultiTests from './patch-multi.js' 10 | import makeRemoveTests from './remove.js' 11 | import makeRemoveMultiTests from './remove-multi.js' 12 | import type { Adapter, AuthorizeHookOptions } from '../../../../../src/index.js' 13 | import type { MakeTestsOptions } from './_makeTests.types.js' 14 | 15 | export default async function ( 16 | name: Adapter | string, 17 | makeService: () => unknown, 18 | clean: (app, service) => Promise, 19 | authorizeHookOptions: Partial, 20 | makeTestsOptions: MakeTestsOptions = { 21 | around: false, 22 | afterHooks: [], 23 | actionBefore: () => {}, 24 | }, 25 | ): Promise { 26 | describe(`authorize-hook '${name}'`, function () { 27 | if (makeTestsOptions.actionBefore) { 28 | beforeAll(makeTestsOptions.actionBefore) 29 | } 30 | makeFindTests( 31 | name, 32 | makeService, 33 | clean, 34 | authorizeHookOptions, 35 | makeTestsOptions, 36 | ) 37 | makeGetTests( 38 | name, 39 | makeService, 40 | clean, 41 | authorizeHookOptions, 42 | makeTestsOptions, 43 | ) 44 | makeCreateTests( 45 | name, 46 | makeService, 47 | clean, 48 | authorizeHookOptions, 49 | makeTestsOptions, 50 | ) 51 | makeCreateMultiTests( 52 | name, 53 | makeService, 54 | clean, 55 | authorizeHookOptions, 56 | makeTestsOptions, 57 | ) 58 | makeUpdateTests( 59 | name, 60 | makeService, 61 | clean, 62 | authorizeHookOptions, 63 | makeTestsOptions, 64 | ) 65 | makeUpdateDataTests( 66 | name, 67 | makeService, 68 | clean, 69 | Object.assign({ useUpdateData: true }, authorizeHookOptions), 70 | makeTestsOptions, 71 | ) 72 | makePatchTests( 73 | name, 74 | makeService, 75 | clean, 76 | authorizeHookOptions, 77 | makeTestsOptions, 78 | ) 79 | makePatchDataTests( 80 | name, 81 | makeService, 82 | clean, 83 | Object.assign({ usePatchData: true }, authorizeHookOptions), 84 | makeTestsOptions, 85 | ) 86 | makePatchMultiTests( 87 | name, 88 | makeService, 89 | clean, 90 | authorizeHookOptions, 91 | makeTestsOptions, 92 | ) 93 | makeRemoveTests( 94 | name, 95 | makeService, 96 | clean, 97 | authorizeHookOptions, 98 | makeTestsOptions, 99 | ) 100 | makeRemoveMultiTests( 101 | name, 102 | makeService, 103 | clean, 104 | authorizeHookOptions, 105 | makeTestsOptions, 106 | ) 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/makeTests/patch-data.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { feathers } from '@feathersjs/feathers' 3 | import { defineAbility } from '@casl/ability' 4 | 5 | import type { Application } from '@feathersjs/feathers' 6 | 7 | import { authorize } from '../../../../../src/index.js' 8 | import type { Adapter, AuthorizeHookOptions } from '../../../../../src/index.js' 9 | import { resolveAction } from '../../../../test-utils.js' 10 | import type { MakeTestsOptions } from './_makeTests.types.js' 11 | 12 | export default ( 13 | adapterName: Adapter | string, 14 | makeService: () => any, 15 | clean: (app, service) => Promise, 16 | authorizeHookOptions: Partial, 17 | { around, afterHooks }: MakeTestsOptions = { around: false, afterHooks: [] }, 18 | ): void => { 19 | let app: Application 20 | let service 21 | let id 22 | 23 | describe(`${adapterName}: beforeAndAfter - patch-data`, function () { 24 | beforeEach(async function () { 25 | app = feathers() 26 | app.use('tests', makeService()) 27 | service = app.service('tests') 28 | 29 | id = service.options.id 30 | 31 | const options = Object.assign( 32 | { 33 | availableFields: [ 34 | id, 35 | 'userId', 36 | 'hi', 37 | 'test', 38 | 'published', 39 | 'supersecret', 40 | 'hidden', 41 | ], 42 | }, 43 | authorizeHookOptions, 44 | ) 45 | 46 | afterHooks = Array.isArray(afterHooks) 47 | ? afterHooks 48 | : afterHooks 49 | ? [afterHooks] 50 | : [] 51 | 52 | if (around) { 53 | service.hooks({ 54 | around: { 55 | all: [authorize(options)], 56 | }, 57 | after: { 58 | all: afterHooks, 59 | }, 60 | }) 61 | } else { 62 | service.hooks({ 63 | before: { 64 | all: [authorize(options)], 65 | }, 66 | after: { 67 | all: [...afterHooks, authorize(options)], 68 | }, 69 | }) 70 | } 71 | 72 | await clean(app, service) 73 | }) 74 | 75 | it("passes with general 'patch-data' rule", async function () { 76 | const readMethod = ['read', 'get'] 77 | 78 | for (const read of readMethod) { 79 | await clean(app, service) 80 | const item = await service.create({ test: true, userId: 1 }) 81 | const result = await service.patch( 82 | item[id], 83 | { test: false }, 84 | { 85 | ability: defineAbility( 86 | (can) => { 87 | can('patch', 'tests') 88 | can('patch-data', 'tests') 89 | can(read, 'tests') 90 | }, 91 | { resolveAction }, 92 | ), 93 | }, 94 | ) 95 | assert.deepStrictEqual(result, { 96 | [id]: item[id], 97 | test: false, 98 | userId: 1, 99 | }) 100 | } 101 | }) 102 | 103 | it("fails with no 'patch-data' rule", async function () { 104 | const readMethod = ['read', 'get'] 105 | 106 | for (const read of readMethod) { 107 | await clean(app, service) 108 | const item = await service.create({ test: true, userId: 1 }) 109 | let rejected = false 110 | try { 111 | await service.patch( 112 | item[id], 113 | { test: false }, 114 | { 115 | ability: defineAbility( 116 | (can) => { 117 | can('patch', 'tests') 118 | can(read, 'tests') 119 | }, 120 | { resolveAction }, 121 | ), 122 | }, 123 | ) 124 | } catch { 125 | rejected = true 126 | } 127 | assert.ok(rejected, 'rejected') 128 | } 129 | }) 130 | 131 | it("basic cannot 'patch-data'", async function () { 132 | const readMethod = ['read', 'get'] 133 | 134 | for (const read of readMethod) { 135 | await clean(app, service) 136 | const item = await service.create({ test: true, userId: 1 }) 137 | let rejected = false 138 | try { 139 | await service.patch( 140 | item[id], 141 | { test: false }, 142 | { 143 | ability: defineAbility( 144 | (can, cannot) => { 145 | can('patch', 'tests') 146 | can('patch-data', 'tests') 147 | cannot('patch-data', 'tests', { test: false }) 148 | can(read, 'tests') 149 | }, 150 | { resolveAction }, 151 | ), 152 | }, 153 | ) 154 | } catch { 155 | rejected = true 156 | } 157 | assert.ok(rejected, 'rejected') 158 | } 159 | }) 160 | 161 | it("basic can 'patch-data' with fail", async function () { 162 | const readMethod = ['read', 'get'] 163 | 164 | for (const read of readMethod) { 165 | await clean(app, service) 166 | const item = await service.create({ test: true, userId: 1 }) 167 | try { 168 | await service.patch( 169 | item[id], 170 | { test: false }, 171 | { 172 | ability: defineAbility( 173 | (can) => { 174 | can('patch', 'tests') 175 | can('patch-data', 'tests') 176 | can('patch-data', 'tests', { test: true }) 177 | can(read, 'tests') 178 | }, 179 | { resolveAction }, 180 | ), 181 | }, 182 | ) 183 | assert.fail('should not get here') 184 | } catch (err) { 185 | assert.ok(err, 'should get here') 186 | } 187 | } 188 | }) 189 | 190 | it("basic can 'patch-data'", async function () { 191 | const readMethod = ['read', 'get'] 192 | 193 | for (const read of readMethod) { 194 | await clean(app, service) 195 | const item = await service.create({ test: true, userId: 1 }) 196 | const patchedItem = await service.patch( 197 | item[id], 198 | { test: false }, 199 | { 200 | ability: defineAbility( 201 | (can) => { 202 | can('patch', 'tests') 203 | can('patch-data', 'tests') 204 | can('patch-data', 'tests', { test: false }) 205 | can(read, 'tests') 206 | }, 207 | { resolveAction }, 208 | ), 209 | }, 210 | ) 211 | 212 | assert.deepStrictEqual( 213 | patchedItem, 214 | { [id]: item[id], test: false, userId: 1 }, 215 | `patched item correctly for read: '${read}'`, 216 | ) 217 | } 218 | }) 219 | }) 220 | } 221 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/makeTests/remove.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { feathers } from '@feathersjs/feathers' 3 | import { defineAbility } from '@casl/ability' 4 | 5 | import type { Application } from '@feathersjs/feathers' 6 | 7 | import { authorize } from '../../../../../src/index.js' 8 | import type { Adapter, AuthorizeHookOptions } from '../../../../../src/index.js' 9 | import { resolveAction } from '../../../../test-utils.js' 10 | import type { MakeTestsOptions } from './_makeTests.types.js' 11 | 12 | export default ( 13 | adapterName: Adapter | string, 14 | makeService: () => any, 15 | clean: (app, service) => Promise, 16 | authorizeHookOptions: Partial, 17 | { around, afterHooks }: MakeTestsOptions = { around: false, afterHooks: [] }, 18 | ): void => { 19 | let app: Application 20 | let service 21 | let id 22 | 23 | // const itSkip = (adapterToTest: string | string[]) => { 24 | // const condition = 25 | // typeof adapterToTest === "string" 26 | // ? adapterName === adapterToTest 27 | // : adapterToTest.includes(adapterName); 28 | // return condition ? it.skip : it; 29 | // }; 30 | 31 | describe(`${adapterName}: beforeAndAfter - remove:single`, function () { 32 | beforeEach(async function () { 33 | app = feathers() 34 | app.use('tests', makeService()) 35 | service = app.service('tests') 36 | 37 | id = service.options.id 38 | 39 | const options = Object.assign( 40 | { 41 | availableFields: [ 42 | id, 43 | 'userId', 44 | 'hi', 45 | 'test', 46 | 'published', 47 | 'supersecret', 48 | 'hidden', 49 | ], 50 | }, 51 | authorizeHookOptions, 52 | ) 53 | 54 | afterHooks = Array.isArray(afterHooks) 55 | ? afterHooks 56 | : afterHooks 57 | ? [afterHooks] 58 | : [] 59 | 60 | if (around) { 61 | service.hooks({ 62 | around: { 63 | all: [authorize(options)], 64 | }, 65 | after: { 66 | all: afterHooks, 67 | }, 68 | }) 69 | } else { 70 | service.hooks({ 71 | before: { 72 | all: [authorize(options)], 73 | }, 74 | after: { 75 | all: [...afterHooks, authorize(options)], 76 | }, 77 | }) 78 | } 79 | 80 | await clean(app, service) 81 | }) 82 | 83 | it("can remove one item and return 'undefined' for not allowed read", async function () { 84 | const item = await service.create({ test: true, userId: 1 }) 85 | 86 | const updatedItem = await service.remove(item[id], { 87 | ability: defineAbility( 88 | (can) => { 89 | can('remove', 'tests') 90 | }, 91 | { resolveAction }, 92 | ), 93 | }) 94 | 95 | assert.deepStrictEqual( 96 | updatedItem, 97 | undefined, 98 | 'removed item is undefined', 99 | ) 100 | 101 | const realItems = (await service.find({ paginate: false })) as unknown[] 102 | assert.strictEqual(realItems.length, 0, 'no existent items') 103 | }) 104 | 105 | it('can remove one item and returns complete item', async function () { 106 | const readMethods = ['read', 'get'] 107 | 108 | for (const read of readMethods) { 109 | await clean(app, service) 110 | const item = await service.create({ test: true, userId: 1 }) 111 | const removedItem = await service.remove(item[id], { 112 | ability: defineAbility( 113 | (can) => { 114 | can('remove', 'tests') 115 | can(read, 'tests') 116 | }, 117 | { resolveAction }, 118 | ), 119 | }) 120 | 121 | assert.deepStrictEqual( 122 | removedItem, 123 | { [id]: item[id], test: true, userId: 1 }, 124 | `removed item correctly for read: '${read}'`, 125 | ) 126 | 127 | const realItems = (await service.find({ 128 | paginate: false, 129 | })) as unknown[] 130 | assert.strictEqual(realItems.length, 0, 'no existent items') 131 | } 132 | }) 133 | 134 | it('throws if cannot remove item', async function () { 135 | const item = await service.create({ test: true, userId: 1 }) 136 | 137 | const promise = service.remove(item[id], { 138 | ability: defineAbility( 139 | (can, cannot) => { 140 | can('remove', 'tests') 141 | cannot('remove', 'tests', { userId: 1 }) 142 | }, 143 | { resolveAction }, 144 | ), 145 | }) 146 | 147 | await assert.rejects( 148 | promise, 149 | (err: Error) => err.name === 'NotFound', 150 | 'cannot remove item', 151 | ) 152 | }) 153 | 154 | it("removes item and returns empty object for not overlapping '$select' and 'restricting fields'", async function () { 155 | let item = { test: true, userId: 1, supersecret: true, hidden: true } 156 | item = await service.create(item) 157 | 158 | const result = await service.remove(item[id], { 159 | query: { $select: [id, 'supersecret', 'hidden'] }, 160 | ability: defineAbility( 161 | (can) => { 162 | can('read', 'tests', ['test', 'userId']) 163 | can(['create', 'remove'], 'tests') 164 | }, 165 | { resolveAction }, 166 | ), 167 | }) 168 | assert.deepStrictEqual( 169 | result, 170 | {}, 171 | 'returned item is empty because of $select and restricting fields', 172 | ) 173 | await assert.rejects( 174 | service.get(item[id]), 175 | (err: Error) => err.name === 'NotFound', 176 | 'item was deleted', 177 | ) 178 | }) 179 | }) 180 | } 181 | -------------------------------------------------------------------------------- /test/hooks/authorize/adapters/makeTests/update-data.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { feathers } from '@feathersjs/feathers' 3 | import { defineAbility } from '@casl/ability' 4 | 5 | import type { Application } from '@feathersjs/feathers' 6 | 7 | import { authorize } from '../../../../../src/index.js' 8 | import type { Adapter, AuthorizeHookOptions } from '../../../../../src/index.js' 9 | import { resolveAction } from '../../../../test-utils.js' 10 | import type { MakeTestsOptions } from './_makeTests.types.js' 11 | 12 | export default ( 13 | adapterName: Adapter | string, 14 | makeService: () => any, 15 | clean: (app, service) => Promise, 16 | authorizeHookOptions: Partial, 17 | { around, afterHooks }: MakeTestsOptions = { around: false, afterHooks: [] }, 18 | ): void => { 19 | let app: Application 20 | let service 21 | let id 22 | 23 | describe(`${adapterName}: beforeAndAfter - update-data`, function () { 24 | beforeEach(async function () { 25 | app = feathers() 26 | app.use('tests', makeService()) 27 | service = app.service('tests') 28 | 29 | id = service.options.id 30 | 31 | const options = Object.assign( 32 | { 33 | availableFields: [ 34 | id, 35 | 'userId', 36 | 'hi', 37 | 'test', 38 | 'published', 39 | 'supersecret', 40 | 'hidden', 41 | ], 42 | }, 43 | authorizeHookOptions, 44 | ) 45 | 46 | afterHooks = Array.isArray(afterHooks) 47 | ? afterHooks 48 | : afterHooks 49 | ? [afterHooks] 50 | : [] 51 | 52 | if (around) { 53 | service.hooks({ 54 | around: { 55 | all: [authorize(options)], 56 | }, 57 | after: { 58 | all: afterHooks, 59 | }, 60 | }) 61 | } else { 62 | service.hooks({ 63 | before: { 64 | all: [authorize(options)], 65 | }, 66 | after: { 67 | all: [...afterHooks, authorize(options)], 68 | }, 69 | }) 70 | } 71 | 72 | await clean(app, service) 73 | }) 74 | 75 | it("passes with general 'update-data' rule", async function () { 76 | const readMethod = ['read', 'get'] 77 | 78 | for (const read of readMethod) { 79 | await clean(app, service) 80 | const item = await service.create({ test: true, userId: 1 }) 81 | const result = await service.update( 82 | item[id], 83 | { [id]: item[id], test: false, userId: 1 }, 84 | { 85 | ability: defineAbility( 86 | (can) => { 87 | can('update', 'tests') 88 | can('update-data', 'tests') 89 | can(read, 'tests') 90 | }, 91 | { resolveAction }, 92 | ), 93 | }, 94 | ) 95 | assert.deepStrictEqual(result, { 96 | [id]: item[id], 97 | test: false, 98 | userId: 1, 99 | }) 100 | } 101 | }) 102 | 103 | it("fails with no 'update-data' rule", async function () { 104 | const readMethod = ['read', 'get'] 105 | 106 | for (const read of readMethod) { 107 | await clean(app, service) 108 | const item = await service.create({ test: true, userId: 1 }) 109 | let rejected = false 110 | try { 111 | await service.update( 112 | item[id], 113 | { [id]: item[id], test: false, userId: 1 }, 114 | { 115 | ability: defineAbility( 116 | (can) => { 117 | can('update', 'tests') 118 | can(read, 'tests') 119 | }, 120 | { resolveAction }, 121 | ), 122 | }, 123 | ) 124 | } catch { 125 | rejected = true 126 | } 127 | assert.ok(rejected, 'rejected') 128 | } 129 | }) 130 | 131 | it("basic cannot 'update-data'", async function () { 132 | const readMethod = ['read', 'get'] 133 | 134 | for (const read of readMethod) { 135 | await clean(app, service) 136 | const item = await service.create({ test: true, userId: 1 }) 137 | let rejected = false 138 | try { 139 | await service.update( 140 | item[id], 141 | { test: false, userId: 1 }, 142 | { 143 | ability: defineAbility( 144 | (can, cannot) => { 145 | can('update', 'tests') 146 | can('update-data', 'tests') 147 | cannot('update-data', 'tests', { test: false }) 148 | can(read, 'tests') 149 | }, 150 | { resolveAction }, 151 | ), 152 | }, 153 | ) 154 | } catch { 155 | rejected = true 156 | } 157 | assert.ok(rejected, 'rejected') 158 | } 159 | }) 160 | 161 | it("basic can 'update-data' with fail", async function () { 162 | const readMethod = ['read', 'get'] 163 | 164 | for (const read of readMethod) { 165 | await clean(app, service) 166 | const item = await service.create({ test: true, userId: 1 }) 167 | try { 168 | await service.update( 169 | item[id], 170 | { test: false, userId: 1 }, 171 | { 172 | ability: defineAbility( 173 | (can) => { 174 | can('update', 'tests') 175 | can('update-data', 'tests') 176 | can('update-data', 'tests', { test: true }) 177 | can(read, 'tests') 178 | }, 179 | { resolveAction }, 180 | ), 181 | }, 182 | ) 183 | assert.fail('should not get here') 184 | } catch (err) { 185 | assert.ok(err, 'should get here') 186 | } 187 | } 188 | }) 189 | 190 | it("basic can 'update-data'", async function () { 191 | const readMethod = ['read', 'get'] 192 | 193 | for (const read of readMethod) { 194 | await clean(app, service) 195 | const item = await service.create({ test: true, userId: 1 }) 196 | const UpdatedItem = await service.update( 197 | item[id], 198 | { test: false, userId: 1 }, 199 | { 200 | ability: defineAbility( 201 | (can) => { 202 | can('update', 'tests') 203 | can('update-data', 'tests') 204 | can('update-data', 'tests', { test: false }) 205 | can(read, 'tests') 206 | }, 207 | { resolveAction }, 208 | ), 209 | }, 210 | ) 211 | 212 | assert.deepStrictEqual( 213 | UpdatedItem, 214 | { [id]: item[id], test: false, userId: 1 }, 215 | `updated item correctly for read: '${read}'`, 216 | ) 217 | } 218 | }) 219 | }) 220 | } 221 | -------------------------------------------------------------------------------- /test/hooks/authorize/authorize.options.method.test.ts: -------------------------------------------------------------------------------- 1 | import { MemoryService } from '@feathersjs/memory' 2 | import { filterArray } from 'feathers-utils' 3 | import { authorize, type ServiceCaslOptions } from '../../../src' 4 | import { feathers } from '@feathersjs/feathers' 5 | import { defineAbility } from '@casl/ability' 6 | import { resolveAction } from '../../test-utils' 7 | 8 | declare module '@feathersjs/memory' { 9 | interface MemoryServiceOptions { 10 | casl?: ServiceCaslOptions 11 | } 12 | } 13 | 14 | class CustomService extends MemoryService { 15 | sum(data: any, params: any) { 16 | return this.find(params) 17 | } 18 | } 19 | 20 | describe('authorize.options.method', () => { 21 | it('should work', async () => { 22 | const app = feathers<{ 23 | tests: CustomService 24 | }>() 25 | 26 | const id = 'id' 27 | 28 | app.use( 29 | 'tests', 30 | new CustomService({ 31 | id, 32 | multi: true, 33 | startId: 1, 34 | filters: { 35 | ...filterArray('$nor'), 36 | }, 37 | operators: ['$nor'], 38 | casl: { 39 | availableFields: [ 40 | 'id', 41 | 'userId', 42 | 'hi', 43 | 'test', 44 | 'published', 45 | 'supersecret', 46 | 'hidden', 47 | ], 48 | }, 49 | paginate: { 50 | default: 10, 51 | max: 50, 52 | }, 53 | }), 54 | { 55 | methods: ['find', 'get', 'create', 'update', 'patch', 'remove', 'sum'], 56 | }, 57 | ) 58 | 59 | const hook = authorize({ 60 | method: 'find', 61 | adapter: '@feathersjs/memory', 62 | }) 63 | 64 | const service = app.service('tests') 65 | 66 | service.hooks({ 67 | before: { 68 | sum: [hook], 69 | }, 70 | after: { 71 | sum: [hook], 72 | }, 73 | }) 74 | 75 | const item1 = await service.create({ test: true, userId: 1 }) 76 | await service.create({ test: true, userId: 2 }) 77 | await service.create({ test: true, userId: 3 }) 78 | const items = await service.find({ paginate: false }) 79 | assert.strictEqual(items.length, 3, 'has three items') 80 | 81 | const returnedItems = (await service.sum(null, { 82 | ability: defineAbility( 83 | (can) => { 84 | can('read', 'tests', { userId: 1 }) 85 | }, 86 | { resolveAction }, 87 | ), 88 | paginate: false, 89 | })) as any as any[] 90 | 91 | assert.deepStrictEqual( 92 | returnedItems, 93 | [{ [id]: item1[id], test: true, userId: 1 }], 94 | 'just returned one item', 95 | ) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { 3 | feathersCasl, 4 | authorize, 5 | checkBasicPermission, 6 | getChannelsWithReadAbility, 7 | checkBasicPermissionUtil, 8 | checkCan, 9 | makeChannelOptions, 10 | } from '../src/index.js' 11 | import { feathers } from '@feathersjs/feathers' 12 | 13 | import type { ServiceCaslOptions } from '../src/index.js' 14 | 15 | declare module '@feathersjs/feathers' { 16 | interface Application { 17 | casl: ServiceCaslOptions 18 | } 19 | } 20 | 21 | describe('index', function () { 22 | it('default is initialize', function () { 23 | const app = feathers() 24 | assert.ok(!app.get('casl'), 'casl is not set') 25 | feathersCasl()(app) 26 | assert.ok(app.get('casl'), 'casl is set') 27 | }) 28 | 29 | it('destructured exports', function () { 30 | assert.ok(authorize, 'authorize is ok') 31 | assert.ok(checkBasicPermission, 'checkBasicPermission is ok') 32 | assert.ok(getChannelsWithReadAbility, 'getChannelsWithReadAbility is ok') 33 | assert.ok(checkCan) 34 | assert.ok(checkBasicPermissionUtil) 35 | assert.ok(makeChannelOptions) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /test/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { createAliasResolver } from '@casl/ability' 2 | 3 | export const promiseTimeout = function ( 4 | ms: number, 5 | promise: Promise, 6 | rejectMessage?: string, 7 | ): Promise { 8 | // Create a promise that rejects in milliseconds 9 | const timeout = new Promise((resolve, reject) => { 10 | const id = setTimeout(() => { 11 | clearTimeout(id) 12 | reject(rejectMessage || 'timeout') 13 | }, ms) 14 | }) 15 | 16 | // Returns a race between our timeout and the passed in promise 17 | return Promise.race([promise, timeout]) 18 | } 19 | 20 | export const resolveAction = createAliasResolver({ 21 | update: 'patch', 22 | read: ['get', 'find'], 23 | delete: 'remove', 24 | }) 25 | -------------------------------------------------------------------------------- /test/utils/checkCan.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import type { Application } from '@feathersjs/feathers' 3 | import { feathers } from '@feathersjs/feathers' 4 | import { MemoryService } from '@feathersjs/memory' 5 | import { defineAbility } from '@casl/ability' 6 | 7 | import { checkCan } from '../../src/index.js' 8 | 9 | describe('utils - checkCan', function () { 10 | let app: Application, service, service2 11 | beforeAll(async function () { 12 | app = feathers() 13 | app.use('tests', new MemoryService({ multi: true })) 14 | service = app.service('tests') 15 | await service.create([ 16 | { id: 0, test: true, published: true }, 17 | { id: 1, test: false, published: true, hi: 1 }, 18 | { id: 2, test: null, published: false }, 19 | ]) 20 | 21 | app.use('another-tests', new MemoryService({ multi: true })) 22 | service2 = app.service('another-tests') 23 | await service2.create([ 24 | { id: 0, test: true, published: true }, 25 | { id: 1, test: false, published: true, hi: 1 }, 26 | { id: 2, test: null, published: false }, 27 | ]) 28 | }) 29 | 30 | it("general 'checkCan'", async function () { 31 | const ability = defineAbility((can, cannot) => { 32 | can('get', 'tests') 33 | can('update', 'tests', { published: true }) 34 | cannot('patch', 'tests') 35 | can('remove', 'tests', { test: true }) 36 | }) 37 | await assert.doesNotReject( 38 | () => checkCan(ability, 0, 'get', 'tests', service), 39 | "'get:0' does not reject", 40 | ) 41 | await assert.doesNotReject( 42 | () => checkCan(ability, 0, 'update', 'tests', service), 43 | "'update:0' does not reject", 44 | ) 45 | await assert.doesNotReject( 46 | () => checkCan(ability, 0, 'remove', 'tests', service), 47 | "'update:0' does not reject", 48 | ) 49 | await assert.rejects( 50 | () => checkCan(ability, 1, 'remove', 'tests', service), 51 | "'remove:1' rejects", 52 | ) 53 | await assert.rejects( 54 | () => checkCan(ability, 2, 'update', 'tests', service), 55 | "'update:2' rejects", 56 | ) 57 | await assert.rejects( 58 | () => checkCan(ability, 0, 'patch', 'tests', service), 59 | "'patch:0' rejects", 60 | ) 61 | await assert.rejects( 62 | () => checkCan(ability, 0, 'update', 'another-tests', service), 63 | "'patch:0' rejects", 64 | ) 65 | }) 66 | 67 | it("'checkCan' with skipThrow", async function () { 68 | const ability = defineAbility((can, cannot) => { 69 | can('get', 'tests') 70 | can('update', 'tests', { published: true }) 71 | cannot('patch', 'tests') 72 | can('remove', 'tests', { test: true }) 73 | }) 74 | let can = await checkCan(ability, 0, 'get', 'tests', service, { 75 | skipThrow: true, 76 | }) 77 | assert.strictEqual(can, true, "'get:0' returns true") 78 | can = await checkCan(ability, 0, 'update', 'tests', service, { 79 | skipThrow: true, 80 | }) 81 | assert.strictEqual(can, true, "'update:0' returns true") 82 | can = await checkCan(ability, 0, 'remove', 'tests', service, { 83 | skipThrow: true, 84 | }) 85 | assert.strictEqual(can, true, "'update:0' returns true") 86 | can = await checkCan(ability, 1, 'remove', 'tests', service, { 87 | skipThrow: true, 88 | }) 89 | assert.strictEqual(can, false, "'remove:1' returns false") 90 | can = await checkCan(ability, 2, 'update', 'tests', service, { 91 | skipThrow: true, 92 | }) 93 | assert.strictEqual(can, false, "'update:2' returns false") 94 | can = await checkCan(ability, 0, 'patch', 'tests', service, { 95 | skipThrow: true, 96 | }) 97 | assert.strictEqual(can, false, "'patch:0' returns false") 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /test/utils/convertRuleToQuery.test.ts: -------------------------------------------------------------------------------- 1 | import { defineAbility } from '@casl/ability' 2 | import assert from 'node:assert' 3 | import { convertRuleToQuery } from '../../src/index.js' 4 | 5 | describe('utils - convertRuleToQuery', function () { 6 | it('', function () { 7 | const ability = defineAbility((can, cannot) => { 8 | can('create', 'tests', { id: 1, test: true }) 9 | 10 | can('create', 'tests', { id: 1 }) 11 | can('create', 'tests', { id: { $gt: 1 } }) 12 | can('create', 'tests', { id: { $gte: 1 } }) 13 | can('create', 'tests', { id: { $lt: 1 } }) 14 | can('create', 'tests', { id: { $lte: 1 } }) 15 | can('create', 'tests', { id: { $in: [1] } }) 16 | can('create', 'tests', { id: { $nin: [1] } }) 17 | can('create', 'tests', { id: { $ne: 1 } }) 18 | 19 | cannot('create', 'tests', { id: 1 }) 20 | cannot('create', 'tests', { id: { $gt: 1 } }) 21 | cannot('create', 'tests', { id: { $gte: 1 } }) 22 | cannot('create', 'tests', { id: { $lt: 1 } }) 23 | cannot('create', 'tests', { id: { $lte: 1 } }) 24 | cannot('create', 'tests', { id: { $in: [1] } }) 25 | cannot('create', 'tests', { id: { $nin: [1] } }) 26 | cannot('create', 'tests', { id: { $ne: 1 } }) 27 | 28 | cannot('create', 'tests') 29 | can('create', 'tests') 30 | 31 | can('create', 'tests', { $sort: { id: 1 } }) 32 | cannot('create', 'tests', { $sort: { id: 1 } }) 33 | cannot('create', 'tests', { id: { $sort: 1 } }) 34 | }) 35 | const expected = [ 36 | { id: 1, test: true }, 37 | 38 | { id: 1 }, 39 | { id: { $gt: 1 } }, 40 | { id: { $gte: 1 } }, 41 | { id: { $lt: 1 } }, 42 | { id: { $lte: 1 } }, 43 | { id: { $in: [1] } }, 44 | { id: { $nin: [1] } }, 45 | { id: { $ne: 1 } }, 46 | 47 | { id: { $ne: 1 } }, 48 | { id: { $lte: 1 } }, 49 | { id: { $lt: 1 } }, 50 | { id: { $gte: 1 } }, 51 | { id: { $gt: 1 } }, 52 | { id: { $nin: [1] } }, 53 | { id: { $in: [1] } }, 54 | { id: 1 }, 55 | 56 | undefined, 57 | undefined, 58 | 59 | { $sort: { id: 1 } }, 60 | {}, 61 | {}, 62 | ] 63 | const { rules } = ability 64 | 65 | rules.forEach((rule, i) => { 66 | assert.deepStrictEqual( 67 | convertRuleToQuery(rule), 68 | expected[i], 69 | `${i}: expected result for rule is: '${JSON.stringify(expected[i])}'`, 70 | ) 71 | }) 72 | }) 73 | 74 | it('calls actionOnForbidden', function () { 75 | let actionOnForbiddenCalled = false 76 | const [rule] = defineAbility((can, cannot) => { 77 | cannot('create', 'tests') 78 | }).rules 79 | convertRuleToQuery(rule, { 80 | actionOnForbidden: () => { 81 | actionOnForbiddenCalled = true 82 | }, 83 | }) 84 | 85 | assert.ok(actionOnForbiddenCalled) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /test/utils/getFieldsForConditions.test.ts: -------------------------------------------------------------------------------- 1 | import { defineAbility } from '@casl/ability' 2 | import assert from 'node:assert' 3 | import { getFieldsForConditions } from '../../src/index.js' 4 | 5 | describe('utils - getFieldsForConditions', function () { 6 | it('returns empty array for no conditions', function () { 7 | const ability = defineAbility(() => {}) 8 | const fields = getFieldsForConditions(ability, 'find', 'tests') 9 | assert.deepStrictEqual(fields, [], 'empty fields') 10 | }) 11 | 12 | it('returns fields for correct method', function () { 13 | const ability = defineAbility((can, cannot) => { 14 | can('find', 'tests1', { special: undefined }) 15 | can('find', 'tests1', { userId: 1, test: false }) 16 | can('find', 'tests1', { userId: 3, hi: 'no' }) 17 | cannot('find', 'tests1', { testsId: null }) 18 | can('get', 'tests1', { id: 1 }) 19 | can('find', 'tests2', { commentId: 1 }) 20 | }) 21 | const fieldsTests1 = getFieldsForConditions(ability, 'find', 'tests1') 22 | assert.deepStrictEqual( 23 | fieldsTests1.sort(), 24 | ['special', 'userId', 'test', 'hi', 'testsId'].sort(), 25 | 'found right fields for tests1', 26 | ) 27 | const fieldsTests2 = getFieldsForConditions(ability, 'find', 'tests2') 28 | assert.deepStrictEqual( 29 | fieldsTests2.sort(), 30 | ['commentId'].sort(), 31 | 'found right fields for tests2', 32 | ) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/utils/getMinimalFields.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | 3 | import { defineAbility, subject } from '@casl/ability' 4 | 5 | import { getMinimalFields } from '../../src/index.js' 6 | 7 | const methods = ['find', 'get', 'create', 'update', 'patch', 'remove'] 8 | 9 | describe('utils - getMinimalFields', function () { 10 | it('returns full array for manage all', function () { 11 | const ability = defineAbility((can) => { 12 | can('manage', 'all') 13 | }) 14 | const availableFields = ['id', 'test'] 15 | for (const method of methods) { 16 | const record: Record = { id: 1, test: true } 17 | const fields = getMinimalFields( 18 | ability, 19 | method, 20 | subject('tests', record), 21 | { availableFields }, 22 | ) 23 | assert.deepStrictEqual( 24 | fields, 25 | ['id', 'test'], 26 | `full array for method '${method}'`, 27 | ) 28 | } 29 | }) 30 | 31 | it('returns subset of array', function () { 32 | const ability = defineAbility((can) => { 33 | can('manage', 'all', ['id']) 34 | }) 35 | const availableFields = ['id', 'test'] 36 | for (const method of methods) { 37 | const record: Record = { id: 1, test: true } 38 | const fields = getMinimalFields( 39 | ability, 40 | method, 41 | subject('tests', record), 42 | { availableFields }, 43 | ) 44 | assert.deepStrictEqual( 45 | fields, 46 | ['id'], 47 | `subset of array for method '${method}'`, 48 | ) 49 | } 50 | }) 51 | 52 | it('returns subset of array with availableFields: undefined', function () { 53 | const ability = defineAbility((can) => { 54 | can('manage', 'all', ['id']) 55 | }) 56 | const availableFields = undefined 57 | for (const method of methods) { 58 | const record: Record = { id: 1, test: true } 59 | const fields = getMinimalFields( 60 | ability, 61 | method, 62 | subject('tests', record), 63 | { availableFields }, 64 | ) 65 | assert.deepStrictEqual( 66 | fields, 67 | ['id'], 68 | `subset of array for method '${method}'`, 69 | ) 70 | } 71 | }) 72 | 73 | it('returns empty array with availableFields: []', function () { 74 | const ability = defineAbility((can) => { 75 | can('manage', 'all', ['id']) 76 | }) 77 | const availableFields = [] 78 | for (const method of methods) { 79 | const record: Record = { id: 1, test: true } 80 | const fields = getMinimalFields( 81 | ability, 82 | method, 83 | subject('tests', record), 84 | { availableFields }, 85 | ) 86 | assert.deepStrictEqual(fields, [], `empty array for method '${method}'`) 87 | } 88 | }) 89 | 90 | it('returns empty array when not allowed', function () { 91 | const ability = defineAbility((can, cannot) => { 92 | cannot('manage', 'all') 93 | }) 94 | 95 | for (const method of methods) { 96 | const record: Record = { id: 1, test: true } 97 | const fields = getMinimalFields( 98 | ability, 99 | method, 100 | subject('tests', record), 101 | { checkCan: true }, 102 | ) 103 | assert.deepStrictEqual(fields, [], `empty array for method '${method}'`) 104 | } 105 | }) 106 | 107 | it('returns subset of array for more complex rules', function () { 108 | const ability = defineAbility((can, cannot) => { 109 | can('manage', 'tests', ['id', 'name', 'email'], { id: { $ne: 1 } }) 110 | can('manage', 'tests', { id: 1 }) 111 | cannot('manage', 'tests', ['supersecret']) 112 | }) 113 | const availableFields = ['id', 'name', 'email', 'supersecret', 'password'] 114 | interface Pair { 115 | input: Record 116 | expected: string[] 117 | } 118 | const pairs: Pair[] = [ 119 | { 120 | input: { id: 1 }, 121 | expected: ['id', 'name', 'email', 'password'], 122 | }, 123 | { 124 | input: {}, 125 | expected: ['id', 'name', 'email'], 126 | }, 127 | { 128 | input: { id: 2 }, 129 | expected: ['id', 'name', 'email'], 130 | }, 131 | ] 132 | for (const [index, pair] of pairs.entries()) { 133 | for (const method of methods) { 134 | const fields = getMinimalFields( 135 | ability, 136 | method, 137 | subject('tests', pair.input), 138 | { availableFields }, 139 | ) 140 | assert.deepStrictEqual( 141 | fields, 142 | pair.expected, 143 | `result for input '${index}' and method '${method}' is correct`, 144 | ) 145 | } 146 | } 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /test/utils/getModelName.test.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext } from '@feathersjs/feathers' 2 | import assert from 'node:assert' 3 | 4 | import { getModelName } from '../../src/index.js' 5 | 6 | describe('utils - getModelName', function () { 7 | it('as undefined', function () { 8 | const modelName = undefined 9 | const context = { path: 'Test2' } as unknown as HookContext 10 | assert.strictEqual( 11 | getModelName(modelName, context), 12 | context.path, 13 | 'returns context.path', 14 | ) 15 | }) 16 | 17 | it('as string', function () { 18 | const modelName = 'Test1' 19 | const context = { path: 'Test2' } as unknown as HookContext 20 | assert.strictEqual( 21 | getModelName(modelName, context), 22 | modelName, 23 | 'just returns modelName', 24 | ) 25 | }) 26 | 27 | it('as function', function () { 28 | const context = { 29 | path: 'Test2', 30 | method: 'Test3', 31 | } as unknown as HookContext 32 | assert.strictEqual( 33 | getModelName((c) => c.method, context), 34 | context.method, 35 | 'returns custom modelName', 36 | ) 37 | }) 38 | 39 | it('throws for other types', function () { 40 | const vals = [null, [], {}, 1, true, false] 41 | 42 | vals.forEach((val) => { 43 | const context = { 44 | path: 'Test2', 45 | method: 'Test3', 46 | } as unknown as HookContext 47 | assert.throws( 48 | //@ts-expect-error val is not string, function or undefined 49 | () => getModelName(val, context), 50 | `throws on val: '${val}''`, 51 | ) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /test/utils/hasRestrictingFields.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | 3 | import { defineAbility, subject } from '@casl/ability' 4 | 5 | import { hasRestrictingFields } from '../../src/index.js' 6 | 7 | const methods = ['find', 'get', 'create', 'update', 'patch', 'remove'] 8 | 9 | describe('utils - hasRestrictingFields', function () { 10 | it('returns false for full array', function () { 11 | const ability = defineAbility((can) => { 12 | can('manage', 'all') 13 | }) 14 | const obj = subject('tests', { 15 | id: 1, 16 | test: true, 17 | }) 18 | const availableFields = ['id', 'test'] 19 | methods.forEach((method) => { 20 | const result = hasRestrictingFields(ability, method, obj, { 21 | availableFields, 22 | }) 23 | assert.strictEqual(result, false, `false for method '${method}'`) 24 | }) 25 | }) 26 | 27 | it('returns true for empty array', function () { 28 | const ability = defineAbility((can) => { 29 | can('manage', 'all', ['id']) 30 | can('manage', 'all', ['test']) 31 | }) 32 | 33 | const obj = subject('tests', { 34 | id: 1, 35 | test: true, 36 | }) 37 | 38 | const availableFields = ['id', 'test'] 39 | methods.forEach((method) => { 40 | const result = hasRestrictingFields(ability, method, obj, { 41 | availableFields, 42 | }) 43 | assert.strictEqual(result, true, `true for method '${method}'`) 44 | }) 45 | }) 46 | 47 | it('returns subset array', function () { 48 | const ability = defineAbility((can) => { 49 | can('manage', 'all', ['id']) 50 | }) 51 | 52 | const obj = subject('tests', { 53 | id: 1, 54 | test: true, 55 | }) 56 | 57 | const availableFields = ['id', 'test'] 58 | methods.forEach((method) => { 59 | const result = hasRestrictingFields(ability, method, obj, { 60 | availableFields, 61 | }) 62 | assert.deepStrictEqual(result, ['id'], `is subset for method '${method}'`) 63 | }) 64 | }) 65 | 66 | it('returns subset array with condition', function () { 67 | const ability = defineAbility((can) => { 68 | can('manage', 'all', ['id'], { id: 1 }) 69 | }) 70 | 71 | const obj = subject('tests', { 72 | id: 1, 73 | test: true, 74 | supersecret: true, 75 | }) 76 | 77 | const availableFields = ['id', 'test', 'supersecret'] 78 | methods.forEach((method) => { 79 | const result = hasRestrictingFields(ability, method, obj, { 80 | availableFields, 81 | }) 82 | assert.deepStrictEqual(result, ['id'], `is subset for method '${method}'`) 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /test/utils/simplifyQuery.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { simplifyQuery } from '../../src/index.js' 3 | 4 | describe('utils - simplifyQuery', function () { 5 | it('makes $and simpler', function () { 6 | const result = simplifyQuery({ 7 | $and: [{ id: 1 }, { id: 1 }, { id: 1 }], 8 | }) 9 | assert.deepStrictEqual(result, { id: 1 }) 10 | }) 11 | 12 | it('makes $or simpler', function () { 13 | const result = simplifyQuery({ 14 | $or: [{ id: 1 }, { id: 1 }, { id: 1 }], 15 | }) 16 | assert.deepStrictEqual(result, { id: 1 }) 17 | }) 18 | 19 | it('makes $and and $or simpler', function () { 20 | const result = simplifyQuery({ 21 | $and: [{ id: 1 }, { id: 1 }, { id: 1 }], 22 | $or: [{ id: 1 }, { id: 1 }, { id: 1 }], 23 | }) 24 | assert.deepStrictEqual(result, { $and: [{ id: 1 }], $or: [{ id: 1 }] }) 25 | }) 26 | 27 | it('removes empty $and', function () { 28 | const result = simplifyQuery({ 29 | $and: [], 30 | id: 1, 31 | }) 32 | assert.deepStrictEqual(result, { id: 1 }) 33 | }) 34 | 35 | it('removes empty $or', function () { 36 | const result = simplifyQuery({ 37 | $or: [], 38 | id: 1, 39 | }) 40 | assert.deepStrictEqual(result, { id: 1 }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "types": ["vitest/globals"] 6 | }, 7 | "include": ["src/**/*", "test/**/*", "vite.config.ts", "build.config.ts", "docs/.vitepress/**/*"], 8 | "exclude": ["node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | coverage: { 7 | provider: 'v8', 8 | reporter: ['text', 'lcov'], 9 | include: ['src/**/*.{js,ts}'], 10 | exclude: ['**/*.test.{js,ts}', 'src/types.ts'], 11 | thresholds: { 12 | lines: 90, 13 | functions: 90, 14 | branches: 90, 15 | statements: 90, 16 | }, 17 | }, 18 | }, 19 | }) 20 | --------------------------------------------------------------------------------