├── .cz-config.js ├── .eslintignore ├── .eslintrc.js ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md ├── stale.yml └── workflows │ ├── main.yaml │ ├── release.yaml │ └── sync-docs.yaml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── prepare-commit-msg ├── .mocharc.json ├── .npmrc ├── .nycrc ├── .prettierignore ├── .prettierrc ├── .sonarcloud.properties ├── .vscode ├── settings.json └── tasks.json ├── .yo-rc.json ├── CHANGELOG.md ├── DEVELOPING.md ├── LICENSE ├── README.md ├── catalog-info.yaml ├── commitlint.config.js ├── docs └── README.md ├── mkdocs.yml ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── acceptance │ │ └── README.md │ ├── integration │ │ └── README.md │ └── unit │ │ ├── README.md │ │ ├── authorization-action.provider.unit.ts │ │ ├── authorization-metadata.provider.unit.ts │ │ ├── casbin-authorization-action.provider.unit.ts │ │ └── data │ │ ├── index.ts │ │ ├── mock-model.ts │ │ ├── mock-policy.ts │ │ ├── mock-user.ts │ │ └── permission.enum.ts ├── component.ts ├── controllers │ └── README.md ├── decorators │ ├── README.md │ ├── authorize.decorator.ts │ ├── index.ts │ └── spec-preprocessor.ts ├── enhancer │ └── spec-description-enhancer.ts ├── error-keys.ts ├── index.ts ├── keys.ts ├── mixins │ └── README.md ├── policy.csv ├── providers │ ├── README.md │ ├── authorization-action.provider.ts │ ├── authorization-metadata.provider.ts │ ├── casbin-authorization-action.provider.ts │ ├── casbin-enforcer-config.provider.ts │ ├── index.ts │ └── user-permissions.provider.ts ├── release_notes │ ├── mymarkdown.ejs │ ├── post-processing.js │ └── release-notes.js ├── repositories │ └── README.md └── types.ts └── tsconfig.json /.cz-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | types: [ 3 | {value: 'feat', name: 'feat: A new feature'}, 4 | {value: 'fix', name: 'fix: A bug fix'}, 5 | {value: 'docs', name: 'docs: Documentation only changes'}, 6 | { 7 | value: 'style', 8 | name: 'style: Changes that do not affect the meaning of the code\n (white-space, formatting, missing semi-colons, etc)', 9 | }, 10 | { 11 | value: 'refactor', 12 | name: 'refactor: A code change that neither fixes a bug nor adds a feature', 13 | }, 14 | { 15 | value: 'perf', 16 | name: 'perf: A code change that improves performance', 17 | }, 18 | {value: 'test', name: 'test: Adding missing tests'}, 19 | { 20 | value: 'chore', 21 | name: 'chore: Changes to the build process or auxiliary tools\n and libraries such as documentation generation', 22 | }, 23 | {value: 'revert', name: 'revert: Reverting a commit'}, 24 | {value: 'WIP', name: 'WIP: Work in progress'}, 25 | ], 26 | 27 | scopes: [ 28 | {name: 'chore'}, 29 | {name: 'deps'}, 30 | {name: 'ci-cd'}, 31 | {name: 'component'}, 32 | {name: 'provider'}, 33 | {name: 'core'}, 34 | {name: 'maintenance'}, 35 | ], 36 | 37 | appendBranchNameToCommitMessage: true, 38 | appendIssueFromBranchName: true, 39 | allowTicketNumber: false, 40 | isTicketNumberRequired: false, 41 | 42 | // override the messages, defaults are as follows 43 | messages: { 44 | type: "Select the type of change that you're committing:", 45 | scope: 'Denote the SCOPE of this change:', 46 | // used if allowCustomScopes is true 47 | customScope: 'Denote the SCOPE of this change:', 48 | subject: 'Write a SHORT, IMPERATIVE tense description of the change:\n', 49 | body: 'Provide a LONGER description of the change (mandatory). Use "\\n" to break new line:\n', 50 | breaking: 'List any BREAKING CHANGES (optional):\n', 51 | footer: 'List any ISSUES CLOSED by this change (optional). E.g.: GH-144:\n', 52 | confirmCommit: 'Are you sure you want to proceed with the commit above?', 53 | }, 54 | 55 | allowCustomScopes: false, 56 | allowBreakingChanges: ['feat', 'fix'], 57 | 58 | // limit subject length 59 | subjectLimit: 100, 60 | breaklineChar: '|', // It is supported for fields body and footer. 61 | footerPrefix: '', 62 | askForBreakingChangeFirst: true, // default is false 63 | }; 64 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | api-docs/ 5 | .cz-config.js 6 | commitlint.config.js 7 | .nyc_output 8 | .eslintrc.js 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '@loopback/eslint-config', 3 | rules: { 4 | 'no-extra-boolean-cast': 'off', 5 | '@typescript-eslint/interface-name-prefix': 'off', 6 | 'no-prototype-builtins': 'off', 7 | 'no-unused-vars': 'off', 8 | }, 9 | parserOptions: { 10 | project: './tsconfig.json', 11 | tsconfigRootDir: __dirname, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at support@sourcefuse.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # loopback4-authentication 2 | 3 | ## Contributing 4 | 5 | First off, thank you for considering contributing to the project. It's people like you that helps in keeping this extension useful. 6 | 7 | ### Where do I go from here? 8 | 9 | If you've noticed a bug or have a question, [search the issue tracker](https://github.com/sourcefuse/loopback4-authentication/issues) to see if 10 | someone else in the community has already created a ticket. If not, go ahead and 11 | [make one](https://github.com/sourcefuse/loopback4-authentication/issues/new/choose)! 12 | 13 | ### Fork & create a branch 14 | 15 | If this is something you think you can fix, then [fork](https://help.github.com/articles/fork-a-repo) this repo and 16 | create a branch with a descriptive name. 17 | 18 | A good branch name would be (where issue #325 is the ticket you're working on): 19 | 20 | ```sh 21 | git checkout -b 325-add-new-feature 22 | ``` 23 | 24 | ### Make a Pull Request 25 | 26 | At this point, you should switch back to your master branch and make sure it's 27 | up to date with loopback4-authentication's master branch: 28 | 29 | ```sh 30 | git remote add upstream git@github.com:sourcefuse/loopback4-authentication.git 31 | git checkout master 32 | git pull upstream master 33 | ``` 34 | 35 | Then update your feature branch from your local copy of master, and push it! 36 | 37 | ```sh 38 | git checkout 325-add-new-feature 39 | git rebase master 40 | git push --set-upstream origin 325-add-new-feature 41 | ``` 42 | 43 | Finally, go to GitHub and [make a Pull Request](https://help.github.com/articles/creating-a-pull-request). 44 | 45 | ### Keeping your Pull Request updated 46 | 47 | If a maintainer asks you to "rebase" your PR, they're saying that a lot of code 48 | has changed, and that you need to update your branch so it's easier to merge. 49 | 50 | To learn more about rebasing in Git, there are a lot of [good][git rebasing] 51 | [resources][interactive rebase] but here's the suggested workflow: 52 | 53 | ```sh 54 | git checkout 325-add-new-feature 55 | git pull --rebase upstream master 56 | git push --force-with-lease 325-add-new-feature 57 | ``` 58 | 59 | [git rebasing]: http://git-scm.com/book/en/Git-Branching-Rebasing 60 | [interactive rebase]: https://help.github.com/articles/interactive-rebase 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] Intermediate change (work in progress) 15 | 16 | ## How Has This Been Tested? 17 | 18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 19 | 20 | - [ ] Test A 21 | - [ ] Test B 22 | 23 | ## Checklist: 24 | 25 | - [ ] Performed a self-review of my own code 26 | - [ ] npm test passes on your machine 27 | - [ ] New tests added or existing tests modified to cover all changes 28 | - [ ] Code conforms with the style guide 29 | - [ ] API Documentation in code was updated 30 | - [ ] Any dependent changes have been merged and published in downstream modules 31 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Label to use when marking an issue or a PR as stale 2 | staleLabel: stale 3 | 4 | # Configuration for issues 5 | issues: 6 | # Number of days of inactivity before an issue becomes stale 7 | daysUntilStale: 90 8 | # Comment to post when marking an issue as stale. Set to `false` to disable 9 | markComment: > 10 | This issue has been marked stale because it has not seen any activity within 11 | three months. If you believe this to be an error, please contact one of the code owners. 12 | This issue will be closed within 15 days of being stale. 13 | # Number of days of inactivity before a stale issue is closed 14 | daysUntilClose: 15 15 | # Comment to post when closing a stale issue. Set to `false` to disable 16 | closeComment: > 17 | This issue has been closed due to continued inactivity. Thank you for your understanding. 18 | If you believe this to be in error, please contact one of the code owners. 19 | # Configuration for pull requests 20 | pulls: 21 | # Number of days of inactivity before a PR becomes stale 22 | daysUntilStale: 60 23 | # Comment to post when marking a PR as stale. Set to `false` to disable 24 | markComment: > 25 | This pull request has been marked stale because it has not seen any activity 26 | within two months. It will be closed within 15 days of being stale 27 | unless there is new activity. 28 | # Number of days of inactivity before a stale PR is closed 29 | daysUntilClose: 15 30 | # Comment to post when closing a stale issue. Set to `false` to disable 31 | closeComment: > 32 | This pull request has been closed due to continued inactivity. If you are 33 | interested in finishing the proposed changes, then feel free to re-open 34 | this pull request or open a new one. 35 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | # This workflow contains a single job called "npm_test" 10 | jobs: 11 | npm_test: 12 | # The type of runner that the job will run on 13 | runs-on: ubuntu-latest 14 | 15 | # Steps represent a sequence of tasks that will be executed as part of the job 16 | steps: 17 | # Checks-out your repository under $GITHUB_WORKSPACE 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: '18.x' 22 | 23 | - name: Install Dependencies 📌 24 | run: npm ci 25 | 26 | - name: Run Test Cases 🔧 27 | run: npm run test 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This Manually Executable Workflow is for NPM Releases 2 | 3 | name: Release [Manual] 4 | on: workflow_dispatch 5 | permissions: 6 | contents: write 7 | jobs: 8 | Release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | # fetch-depth is necessary to get all tags 14 | # otherwise lerna can't detect the changes and will end up bumping the versions for all packages 15 | fetch-depth: 0 16 | token: ${{ secrets.RELEASE_COMMIT_GH_PAT }} 17 | - name: Setup Node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: '18.x' 21 | - name: Configure CI Git User 22 | run: | 23 | git config --global user.name $CONFIG_USERNAME 24 | git config --global user.email $CONFIG_EMAIL 25 | git remote set-url origin https://$GITHUB_ACTOR:$GITHUB_PAT@github.com/sourcefuse/loopback4-authorization 26 | env: 27 | GITHUB_PAT: ${{ secrets.RELEASE_COMMIT_GH_PAT }} 28 | CONFIG_USERNAME: ${{ vars.RELEASE_COMMIT_USERNAME }} 29 | CONFIG_EMAIL: ${{ vars.RELEASE_COMMIT_EMAIL }} 30 | - name: Authenticate with Registry 31 | run: | 32 | echo "@${NPM_USERNAME}:registry=https://registry.npmjs.org/" > .npmrc 33 | echo "registry=https://registry.npmjs.org/" >> .npmrc 34 | echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc 35 | npm whoami 36 | env: 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | NPM_USERNAME: ${{ vars.NPM_USERNAME }} 39 | 40 | - name: Install 📌 41 | run: | 42 | npm install 43 | - name: Test 🔧 44 | run: npm run test 45 | - name: Semantic Publish to NPM 🚀 46 | # "HUSKY=0" disables pre-commit-msg check (Needed in order to allow semantic-release perform the release commit) 47 | run: HUSKY=0 npx semantic-release 48 | env: 49 | GH_TOKEN: ${{ secrets.RELEASE_COMMIT_GH_PAT }} 50 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | - name: Changelog 📝 52 | run: cd src/release_notes && HUSKY=0 node release-notes.js 53 | -------------------------------------------------------------------------------- /.github/workflows/sync-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Sync Docs to arc-docs repo 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | DOCS_REPO: sourcefuse/arc-docs 10 | BRANCH_PREFIX: automated-docs-sync/ 11 | GITHUB_TOKEN: ${{secrets.ARC_DOCS_API_TOKEN_GITHUB}} 12 | CONFIG_USERNAME: ${{ vars.DOCS_PR_USERNAME }} 13 | CONFIG_EMAIL: ${{ vars.DOCS_PR_EMAIL }} 14 | 15 | jobs: 16 | sync-docs: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout Extension Code 21 | uses: actions/checkout@v3 22 | with: 23 | token: ${{env.GITHUB_TOKEN}} 24 | path: './extension/' 25 | 26 | - name: Checkout Docs Repository 27 | uses: actions/checkout@v3 28 | with: 29 | token: ${{env.GITHUB_TOKEN}} 30 | repository: ${{env.DOCS_REPO}} 31 | path: './arc-docs/' 32 | 33 | - name: Configure GIT 34 | id: configure_git 35 | working-directory: arc-docs 36 | run: | 37 | git config --global user.email $CONFIG_EMAIL 38 | git config --global user.name $CONFIG_USERNAME 39 | 40 | extension_branch="${{env.BRANCH_PREFIX}}$(basename $GITHUB_REPOSITORY)" 41 | echo "extension_branch=$extension_branch" >> $GITHUB_OUTPUT 42 | 43 | - name: Update Files 44 | id: update_files 45 | working-directory: arc-docs 46 | run: | 47 | extension_branch="${{ steps.configure_git.outputs.extension_branch }}" 48 | 49 | # Create a new branch if it doesn't exist, or switch to it if it does 50 | git checkout -B $extension_branch || git checkout $extension_branch 51 | 52 | # Copy README from the extension repo 53 | cp ../extension/docs/README.md docs/arc-api-docs/extensions/$(basename $GITHUB_REPOSITORY)/ 54 | git add . 55 | 56 | if git diff --quiet --cached; then 57 | have_changes="false"; 58 | else 59 | have_changes="true"; 60 | fi 61 | 62 | echo "Have Changes to be commited: $have_changes" 63 | echo "have_changes=$have_changes" >> $GITHUB_OUTPUT 64 | 65 | - name: Commit Changes 66 | id: commit 67 | working-directory: arc-docs 68 | if: steps.update_files.outputs.have_changes == 'true' 69 | run: | 70 | git commit -m "sync $(basename $GITHUB_REPOSITORY) docs" 71 | - name: Push Changes 72 | id: push_branch 73 | if: steps.update_files.outputs.have_changes == 'true' 74 | working-directory: arc-docs 75 | run: | 76 | extension_branch="${{ steps.configure_git.outputs.extension_branch }}" 77 | git push https://oauth2:${GITHUB_TOKEN}@github.com/${{env.DOCS_REPO}}.git HEAD:$extension_branch --force 78 | 79 | - name: Check PR Status 80 | id: pr_status 81 | if: steps.update_files.outputs.have_changes == 'true' 82 | working-directory: arc-docs 83 | run: | 84 | extension_branch="${{ steps.configure_git.outputs.extension_branch }}" 85 | gh pr status --json headRefName >> "${{github.workspace}}/pr-status.json" 86 | pr_exists="$(jq --arg extension_branch "$extension_branch" '.createdBy[].headRefName == $extension_branch' "${{github.workspace}}/pr-status.json")" 87 | echo "PR Exists: $pr_exists" 88 | echo "pr_exists=$pr_exists" >> $GITHUB_OUTPUT 89 | 90 | - name: Create Pull Request 91 | id: create_pull_request 92 | if: steps.pr_status.outputs.pr_exists != 'true' && steps.update_files.outputs.have_changes == 'true' 93 | working-directory: arc-docs 94 | run: | 95 | extension_branch="${{ steps.configure_git.outputs.extension_branch }}" 96 | 97 | gh pr create --head $(git branch --show-current) --title "Sync ${{ github.event.repository.name }} Docs" --body "This Pull Request has been created by the 'sync-docs' action within the '${{ github.event.repository.name }}' repository, with the purpose of updating markdown files." 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # Generated apidocs 61 | api-docs/ 62 | 63 | # Transpiled JavaScript files from Typescript 64 | dist/ 65 | 66 | *.tsbuildinfo 67 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | exec < /dev/tty && npx cz --hook || true 5 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "recursive": true, 3 | "require": "source-map-support/register" 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=true 2 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["dist"], 3 | "exclude": ["dist/__tests__/"], 4 | "extension": [".js", ".ts"], 5 | "reporter": ["text", "html"], 6 | "exclude-after-remap": false 7 | } 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.json 3 | api-docs 4 | coverage 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "trailingComma": "all", 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | # Path to sources 2 | sonar.sources=src 3 | sonar.exclusions=src/__tests__/** 4 | #sonar.inclusions= 5 | 6 | # Path to tests 7 | sonar.tests=src/__tests__ 8 | #sonar.test.exclusions= 9 | #sonar.test.inclusions= 10 | 11 | # Source encoding 12 | sonar.sourceEncoding=UTF-8 13 | 14 | # Exclusions for copy-paste detection 15 | #sonar.cpd.exclusions= 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [80], 3 | "editor.tabCompletion": "on", 4 | "editor.tabSize": 2, 5 | "editor.trimAutoWhitespace": true, 6 | "editor.formatOnSave": true, 7 | 8 | "sonarlint.connectedMode.project": { 9 | "serverId": "sf_sonar", 10 | "projectKey": "sourcefuse_loopback4-authorization" 11 | }, 12 | 13 | "files.exclude": { 14 | "**/.DS_Store": true, 15 | "**/.git": true, 16 | "**/.hg": true, 17 | "**/.svn": true, 18 | "**/CVS": true, 19 | "dist": true, 20 | }, 21 | "files.insertFinalNewline": true, 22 | "files.trimTrailingWhitespace": true, 23 | 24 | "typescript.tsdk": "./node_modules/typescript/lib" 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Watch and Compile Project", 8 | "type": "shell", 9 | "command": "npm", 10 | "args": ["--silent", "run", "build:watch"], 11 | "group": { 12 | "kind": "build", 13 | "isDefault": true 14 | }, 15 | "problemMatcher": "$tsc-watch" 16 | }, 17 | { 18 | "label": "Build, Test and Lint", 19 | "type": "shell", 20 | "command": "npm", 21 | "args": ["--silent", "run", "test:dev"], 22 | "group": { 23 | "kind": "test", 24 | "isDefault": true 25 | }, 26 | "problemMatcher": ["$tsc", "$tslint5"] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.yo-rc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Release [v7.0.3](https://github.com/sourcefuse/loopback4-authorization/compare/v7.0.2..v7.0.3) December 18, 2024 2 | Welcome to the December 18, 2024 release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 3 | 4 | - [](https://github.com/sourcefuse/loopback4-authorization/issues/) :- [chore(deps): version update ](https://github.com/sourcefuse/loopback4-authorization/commit/5d0714562da3bf702dc3170d8b574bf1bd613de8) was commited on December 18, 2024 by [Sunny Tyagi](mailto:107617248+Tyagi-Sunny@users.noreply.github.com) 5 | 6 | - * chore(deps): version update 7 | 8 | - version update 9 | 10 | - gh-0 11 | 12 | - * chore(deps): include pr 141 13 | 14 | - include pr 141 15 | 16 | - 141 17 | 18 | 19 | Clink on the above links to understand the changes in detail. 20 | ___ 21 | 22 | ## Release [v7.0.2](https://github.com/sourcefuse/loopback4-authorization/compare/v7.0.1..v7.0.2) June 4, 2024 23 | Welcome to the June 4, 2024 release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 24 | 25 | - [loopback version updates](https://github.com/sourcefuse/loopback4-authorization/issues/130) :- [chore(deps): loopback version updates ](https://github.com/sourcefuse/loopback4-authorization/commit/741835854eaeb59849d86a79213dd9373135c686) was commited on June 4, 2024 by [Surbhi](mailto:98279679+Surbhi-sharma1@users.noreply.github.com) 26 | 27 | - loopback version updates 28 | 29 | - GH-130 30 | 31 | 32 | Clink on the above links to understand the changes in detail. 33 | ___ 34 | 35 | ## Release [v7.0.1](https://github.com/sourcefuse/loopback4-authorization/compare/v7.0.0..v7.0.1) May 15, 2024 36 | Welcome to the May 15, 2024 release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 37 | 38 | - [Changelog not generated](https://github.com/sourcefuse/loopback4-authorization/issues/128) :- [fix(ci-cd): step to generate changelog ](https://github.com/sourcefuse/loopback4-authorization/commit/341f8154ed9fdb306cba02e8f392c91963012cd6) was commited on May 15, 2024 by [yeshamavani](mailto:83634146+yeshamavani@users.noreply.github.com) 39 | 40 | - add the missing ci checks on pr 41 | 42 | - GH-128 43 | 44 | 45 | - [Copy the Readme to root as well](https://github.com/sourcefuse/loopback4-authorization/issues/126) :- [docs(chore): copy the Readme to root as well ](https://github.com/sourcefuse/loopback4-authorization/commit/0fc85a206dfb4a3e6a9c48148165a60d90f31fe5) was commited on May 13, 2024 by [arpit1503khanna](mailto:108673359+arpit1503khanna@users.noreply.github.com) 46 | 47 | - copy the Readme to root as well 48 | 49 | - GH-126 50 | 51 | 52 | - [](https://github.com/sourcefuse/loopback4-authorization/issues/) :- [](https://github.com/sourcefuse/loopback4-authorization/commit/964d5c67422b68ed1f98054cab1f1b2b0ee63265) was commited on March 13, 2024 by [yeshamavani](mailto:83634146+yeshamavani@users.noreply.github.com) 53 | 54 | 55 | - [](https://github.com/sourcefuse/loopback4-authorization/issues/00) :- [chore(ci-cd): update the readme path in sync docs workflow ](https://github.com/sourcefuse/loopback4-authorization/commit/d1008920f8cd1cb3ad58964ee00536d57f09b55a) was commited on March 13, 2024 by [yeshamavani](mailto:83634146+yeshamavani@users.noreply.github.com) 56 | 57 | - readme moved to docs folder 58 | 59 | - GH-00 60 | 61 | 62 | Clink on the above links to understand the changes in detail. 63 | ___ 64 | 65 | ## Release [v6.1.0](https://github.com/sourcefuse/loopback4-authorization/compare/v6.0.0..v6.1.0) July 14, 2023 66 | Welcome to the July 14, 2023 release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 67 | 68 | - [Add ARC Branding in README](https://github.com/sourcefuse/loopback4-authorization/issues/99) :- [docs(chore): add arc branding ](https://github.com/sourcefuse/loopback4-authorization/commit/aa5b1dc8f48ba02a8a3a32ba1ce8fd3a923aa0ab) was commited on July 14, 2023 by [Surbhi](mailto:98279679+Surbhi-sharma1@users.noreply.github.com) 69 | 70 | - add arc branding 71 | 72 | - GH-99 73 | 74 | 75 | - [Loopback version update](https://github.com/sourcefuse/loopback4-authorization/issues/87) :- [chore(deps): loopback version updates ](https://github.com/sourcefuse/loopback4-authorization/commit/05d2fed8347491f4c5238b0a3d582da5d77d1e55) was commited on July 14, 2023 by [Surbhi](mailto:98279679+Surbhi-sharma1@users.noreply.github.com) 76 | 77 | - loopback version updates 78 | 79 | - GH-87 80 | 81 | 82 | - [loopback4-authorization:add ability to override permissions passed in metadata](https://github.com/sourcefuse/loopback4-authorization/issues/95) :- [feat(provider): add ability to override metadata permissions ](https://github.com/sourcefuse/loopback4-authorization/commit/7fd0f937960f7d8822b8d58efb0409fab054c731) was commited on July 10, 2023 by [Surbhi](mailto:98279679+Surbhi-sharma1@users.noreply.github.com) 83 | 84 | - * feat(provider): add ability to override metadata permissions 85 | 86 | - add ability to override metadata permissions with the permission object passed 87 | 88 | - by the user. 89 | 90 | - GH-95 91 | 92 | - * feat(provider): add ability to override user permissions 93 | 94 | - add ability to provide user permissions 95 | 96 | - GH-95 97 | 98 | - * docs(provider): add description to overrride the permissions in readme file 99 | 100 | - add description to overrride the permissions in readme file 101 | 102 | - GH-95 103 | 104 | 105 | - [Throws not found for static files](https://github.com/sourcefuse/loopback4-authorization/issues/51) :- [docs(component): update readme for serving static files ](https://github.com/sourcefuse/loopback4-authorization/commit/b57177ec180a60c0c59aa0189cb4240751cadb0b) was commited on July 6, 2023 by [Surbhi](mailto:98279679+Surbhi-sharma1@users.noreply.github.com) 106 | 107 | - update readme for serving static files 108 | 109 | - GH-51 110 | 111 | 112 | - [](https://github.com/sourcefuse/loopback4-authorization/issues/) :- [fix(chore): fix sonar code smells ](https://github.com/sourcefuse/loopback4-authorization/commit/7c6fc277b7649131913a40c45985b87890093be3) was commited on July 6, 2023 by [RaghavaroraSF](mailto:97958393+RaghavaroraSF@users.noreply.github.com) 113 | 114 | - * fix(chore): fix sonar code smells 115 | 116 | - resolving sonar code smells to improve quality gate 117 | 118 | - GH-78 119 | 120 | - * fix(chore): fix sonar code smells 121 | 122 | - Resolving sonar code smells to improve quality gate 123 | 124 | - GH-78 125 | 126 | - * refactor(chore): fix sonar code smells 127 | 128 | - and rebase 129 | 130 | - GH-78 131 | 132 | - --------- 133 | 134 | - Co-authored-by: Shubham P <shubham.prajapat@sourcefuse.com> 135 | 136 | 137 | - [](https://github.com/sourcefuse/loopback4-authorization/issues/0) :- [refactor(maintenance): remove tracking dist folder on git ](https://github.com/sourcefuse/loopback4-authorization/commit/c4223d0e5fbdefdcacbc4c7fe6428a1c9f10d698) was commited on July 6, 2023 by [Shubham P](mailto:shubham.prajapat@sourcefuse.com) 138 | 139 | - it's not needed and it just contained build files from last year 140 | 141 | - GH-0 142 | 143 | 144 | Clink on the above links to understand the changes in detail. 145 | ___ 146 | 147 | ## Release [v6.0.0](https://github.com/sourcefuse/loopback4-authorization/compare/v5.1.3..v6.0.0) June 7, 2023 148 | Welcome to the June 7, 2023 release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 149 | 150 | - [Loopback version update](https://github.com/sourcefuse/loopback4-authorization/issues/87) :- [chore(deps): loopback version update ](https://github.com/sourcefuse/loopback4-authorization/commit/1dbb12cbb7fa2f86ec2f96dd0a36551b46581230) was commited on June 1, 2023 by [RaghavaroraSF](mailto:97958393+RaghavaroraSF@users.noreply.github.com) 151 | 152 | - Loopback version update 153 | 154 | - GH-87 155 | 156 | 157 | - [Remove support for node v14](https://github.com/sourcefuse/loopback4-authorization/issues/91) :- [feat(chore): remove support for node v14,v12,v10 ](https://github.com/sourcefuse/loopback4-authorization/commit/9e526e59935ebc0e3c6344dcd078840e017814fd) was commited on May 11, 2023 by [RaghavaroraSF](mailto:97958393+RaghavaroraSF@users.noreply.github.com) 158 | 159 | - Version 14, v12, v10 of Nodejs reached its end of life 160 | 161 | - BREAKING CHANGE: 162 | 163 | - End of life of node v14, node v12 and node v10 164 | 165 | - GH-91 166 | 167 | 168 | - [License and Copyright Headers’ Year Upgradation](https://github.com/sourcefuse/loopback4-authorization/issues/89) :- [chore(maintenance): update license and copyright headers ](https://github.com/sourcefuse/loopback4-authorization/commit/768f2868a08129e4f8615c7ef7c61bf0144a709f) was commited on May 4, 2023 by [RaghavaroraSF](mailto:97958393+RaghavaroraSF@users.noreply.github.com) 169 | 170 | - to reflect the year change 171 | 172 | - GH-89 173 | 174 | 175 | Clink on the above links to understand the changes in detail. 176 | ___ 177 | 178 | ## Release [v5.1.3](https://github.com/sourcefuse/loopback4-authorization/compare/v5.1.2..v5.1.3) April 24, 2023 179 | Welcome to the April 24, 2023 release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 180 | 181 | - [Loopback version update](https://github.com/sourcefuse/loopback4-authorization/issues/87) :- [chore(deps): loopback version update ](https://github.com/sourcefuse/loopback4-authorization/commit/c36448c4412a849f25e8e24d7d6fd392a61829ed) was commited on April 24, 2023 by [RaghavaroraSF](mailto:97958393+RaghavaroraSF@users.noreply.github.com) 182 | 183 | - loopback version update 184 | 185 | - GH-87 186 | 187 | 188 | Clink on the above links to understand the changes in detail. 189 | ___ 190 | 191 | ## Release [v5.1.2](https://github.com/sourcefuse/loopback4-authorization/compare/v5.1.1..v5.1.2) March 13, 2023 192 | Welcome to the March 13, 2023 release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 193 | 194 | - [loopback version update](https://github.com/sourcefuse/loopback4-authorization/issues/83) :- [chore(deps): loopback version update ](https://github.com/sourcefuse/loopback4-authorization/commit/820244541cc590d69588484083fb5e6c31074040) was commited on March 13, 2023 by [Gautam Agarwal](mailto:108651274+gautam23-sf@users.noreply.github.com) 195 | 196 | - loopback version update 197 | 198 | - GH-83 199 | 200 | 201 | - [Stale Bot missing in the repository](https://github.com/sourcefuse/loopback4-authorization/issues/81) :- [chore(chore): add github stale bot ](https://github.com/sourcefuse/loopback4-authorization/commit/b216e4f111256fdb1a003ac35a35eaffb80d9907) was commited on February 27, 2023 by [yeshamavani](mailto:83634146+yeshamavani@users.noreply.github.com) 202 | 203 | - Added stale.yml file to configure stale options 204 | 205 | - GH-81 206 | 207 | 208 | Clink on the above links to understand the changes in detail. 209 | ___ 210 | 211 | ## Release [v5.1.1](https://github.com/sourcefuse/loopback4-authorization/compare/v5.1.0..v5.1.1) February 17, 2023 212 | Welcome to the February 17, 2023 release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 213 | 214 | - [Correct the changelog Format](https://github.com/sourcefuse/loopback4-authorization/issues/79) :- [fix(chore): correct the changelog format ](https://github.com/sourcefuse/loopback4-authorization/commit/edc06409cf17bd74157bf2b8e4c350b0b6a03e53) was commited on February 17, 2023 by [yeshamavani](mailto:83634146+yeshamavani@users.noreply.github.com) 215 | 216 | - now issue description will be visible 217 | 218 | - GH-79 219 | 220 | 221 | - [Package Update : loopback4-authorization](https://github.com/sourcefuse/loopback4-authorization/issues/76) :- [fix(chore): remove all current vulnerability of loopback4-authorization ](https://github.com/sourcefuse/loopback4-authorization/commit/2da3301b1922c1ec0cc61ff6130c4b34aa84f7da) was commited on February 17, 2023 by [Sunny Tyagi](mailto:107617248+Tyagi-Sunny@users.noreply.github.com) 222 | 223 | - remove all current vulnerability of loopback4-authorization 224 | 225 | - GH-76 226 | 227 | 228 | Clink on the above links to understand the changes in detail. 229 | ___ 230 | 231 | ## Release [v5.1.0](https://github.com/sourcefuse/loopback4-authorization/compare/v5.0.9..v5.1.0) January 11, 2023 232 | Welcome to the January 11, 2023 release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 233 | 234 | - [](https://github.com/sourcefuse/loopback4-authorization/issues/-70) :- [feat(chore): generate detailed and informative changelog ](https://github.com/sourcefuse/loopback4-authorization/commit/c69eaf575c6b21a23779818c27ad3a4e749aaf6a) was commited on January 10, 2023 by [yeshamavani](mailto:83634146+yeshamavani@users.noreply.github.com) 235 | 236 | - Using Customizable npm package to generate changelog 237 | 238 | - GH-70 239 | 240 | 241 | - [](https://github.com/sourcefuse/loopback4-authorization/issues/-68) :- [chore(deps): loopback version update ](https://github.com/sourcefuse/loopback4-authorization/commit/bab60712fa528f2b918106a384f121a5a832444d) was commited on January 10, 2023 by [Surbhi Sharma](mailto:98279679+Surbhi-sharma1@users.noreply.github.com) 242 | 243 | - Updated version of the lb4 dependencies to the latest. 244 | 245 | - GH-68 246 | 247 | 248 | Clink on the above links to understand the changes in detail. 249 | ___ 250 | 251 | ## [5.0.9](https://github.com/sourcefuse/loopback4-authorization/compare/v5.0.8...v5.0.9) (2022-12-05) 252 | 253 | ## [5.0.8](https://github.com/sourcefuse/loopback4-authorization/compare/v5.0.7...v5.0.8) (2022-10-31) 254 | 255 | ## [5.0.7](https://github.com/sourcefuse/loopback4-authorization/compare/v5.0.6...v5.0.7) (2022-09-08) 256 | 257 | ## [5.0.6](https://github.com/sourcefuse/loopback4-authorization/compare/v5.0.5...v5.0.6) (2022-06-17) 258 | 259 | ## [5.0.5](https://github.com/sourcefuse/loopback4-authorization/compare/v5.0.4...v5.0.5) (2022-05-30) 260 | 261 | 262 | ### Bug Fixes 263 | 264 | * **core:** remove spec enhancer by default ([#53](https://github.com/sourcefuse/loopback4-authorization/issues/53)) ([b76cfb8](https://github.com/sourcefuse/loopback4-authorization/commit/b76cfb8aa9ecf73c24fe9f003ff1d2896977c759)), closes [#0](https://github.com/sourcefuse/loopback4-authorization/issues/0) 265 | * **provider:** change order of not found check ([#52](https://github.com/sourcefuse/loopback4-authorization/issues/52)) ([3213f6d](https://github.com/sourcefuse/loopback4-authorization/commit/3213f6d4ceab7a2d5d2dc236561dbda3de241ec5)), closes [#51](https://github.com/sourcefuse/loopback4-authorization/issues/51) 266 | 267 | ## [5.0.4](https://github.com/sourcefuse/loopback4-authorization/compare/v5.0.3...v5.0.4) (2022-05-25) 268 | 269 | 270 | ### Bug Fixes 271 | 272 | * **component): fix(component:** fix authorize permission missing in path description ([#41](https://github.com/sourcefuse/loopback4-authorization/issues/41)) ([3df07ce](https://github.com/sourcefuse/loopback4-authorization/commit/3df07ce58ca6d9bc74b048fc29f636d772bc2787)), closes [#40](https://github.com/sourcefuse/loopback4-authorization/issues/40) 273 | * **deps:** remove vulnerabilities ([#47](https://github.com/sourcefuse/loopback4-authorization/issues/47)) ([b62dc5a](https://github.com/sourcefuse/loopback4-authorization/commit/b62dc5ad72485795cd814d37f6588946e2faf1f0)), closes [#46](https://github.com/sourcefuse/loopback4-authorization/issues/46) [#46](https://github.com/sourcefuse/loopback4-authorization/issues/46) 274 | * **provider): fix(provider:** authorization action gives 404 for route not found ([#36](https://github.com/sourcefuse/loopback4-authorization/issues/36)) ([c517c0a](https://github.com/sourcefuse/loopback4-authorization/commit/c517c0adbe156f0ad2389bacce6d7e3de3aeaab4)), closes [#35](https://github.com/sourcefuse/loopback4-authorization/issues/35) -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | # Developer's Guide 2 | 3 | We use Visual Studio Code for developing LoopBack and recommend the same to our 4 | users. 5 | 6 | ## VSCode setup 7 | 8 | Install the following extensions: 9 | 10 | - [tslint](https://marketplace.visualstudio.com/items?itemName=eg2.tslint) 11 | - [prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) 12 | 13 | ## Setup Commit Hooks 14 | 15 | Run the following script to prepare husky after the first time install - 16 | 17 | `npm run prepare` 18 | 19 | ## Development workflow 20 | 21 | ### Visual Studio Code 22 | 23 | 1. Start the build task (Cmd+Shift+B) to run TypeScript compiler in the 24 | background, watching and recompiling files as you change them. Compilation 25 | errors will be shown in the VSCode's "PROBLEMS" window. 26 | 27 | 2. Execute "Run Rest Task" from the Command Palette (Cmd+Shift+P) to re-run the 28 | test suite and lint the code for both programming and style errors. Linting 29 | errors will be shown in VSCode's "PROBLEMS" window. Failed tests are printed 30 | to terminal output only. 31 | 32 | ### Other editors/IDEs 33 | 34 | 1. Open a new terminal window/tab and start the continous build process via 35 | `npm run build:watch`. It will run TypeScript compiler in watch mode, 36 | recompiling files as you change them. Any compilation errors will be printed 37 | to the terminal. 38 | 39 | 2. In your main terminal window/tab, run `npm run test:dev` to re-run the test 40 | suite and lint the code for both programming and style errors. You should run 41 | this command manually whenever you have new changes to test. Test failures 42 | and linter errors will be printed to the terminal. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2023] [SourceFuse] 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ARC By SourceFuse logo 2 | 3 | # [loopback4-authorization](https://github.com/sourcefuse/loopback4-authorization) 4 | 5 |

6 | 7 | npm version 8 | 9 | 10 | Sonar Quality Gate 11 | 12 | 13 | Synk Status 14 | 15 | 16 | GitHub contributors 17 | 18 | 19 | downloads 20 | 21 | 22 | License 23 | 24 | 25 | Powered By LoopBack 4 26 | 27 |

28 | 29 | ## Overview 30 | 31 | A LoopBack 4 extension for Authorization Capabilities. It's very simple to integration yet powerful and effective. 32 | 33 | ## Install 34 | 35 | ```sh 36 | npm install loopback4-authorization 37 | ``` 38 | 39 | ## Quick Starter 40 | 41 | For a quick starter guide, you can refer to our [loopback 4 starter](https://github.com/sourcefuse/loopback4-starter) application which utilizes method #3 from the above in a simple multi-tenant application. 42 | 43 | ## Usage 44 | 45 | ### Ways of Integration: 46 | 47 | On a higher level, it provides three ways of integration: 48 | 49 | #### 1. User Level Permissions Only 50 | 51 | Where permissions are associated directly to user. In this case, each user entry in DB contains specific array of permission keys. 52 | 53 | #### 2. Role Based Permissions 54 | 55 | Where permissions are associated to roles and users have a specific role attached. This actually reduces redundancy in DB a lot, as most of the time, users will have many common permissions. If that is not the case for you, then, use the first method. 56 | 57 | #### 3. Role Based Permissions with User Level Flexibility 58 | 59 | This is the most flexible architecture. In this case, method #2 is implemented as is. 60 | 61 | On top of it, we also add user-level permissions override, allow/deny permissions over role permissions. So, say there is user who can perform all admin role actions except he cannot remove users from the system. So, DeleteUser permission can be denied at user level and role can be set as Admin for the user. 62 | 63 | [Extension enhancement using CASBIN authorisation](#Extension-enhancement-using-CASBIN-authorisation) 64 | 65 | Refer to the usage section below for details on integration. 66 | 67 | In order to use this component into your LoopBack application, please follow below steps. 68 | 69 | ### Steps 70 | 71 | #### Bind Component 72 | 73 | Add `AuthorizationComponent` to your application, Like below: 74 | 75 | ```ts 76 | this.bind(AuthorizationBindings.CONFIG).to({ 77 | allowAlwaysPaths: ['/explorer'], 78 | }); 79 | this.component(AuthorizationComponent); 80 | ``` 81 | 82 | #### Implement Permission Interface 83 | 84 | If using method #1 from above, implement Permissions interface in User model and add permissions array. 85 | 86 | ```ts 87 | @model({ 88 | name: 'users', 89 | }) 90 | export class User extends Entity implements Permissions { 91 | // ..... 92 | // other attributes here 93 | // ..... 94 | 95 | @property({ 96 | type: 'array', 97 | itemType: 'string', 98 | }) 99 | permissions: string[]; 100 | 101 | constructor(data?: Partial) { 102 | super(data); 103 | } 104 | } 105 | ``` 106 | 107 | If using method #2 or #3 from above, implement Permissions interface in Role model and add permissions array. 108 | 109 | ```ts 110 | @model({ 111 | name: 'roles', 112 | }) 113 | export class Role extends Entity implements Permissions { 114 | // ..... 115 | // other attributes here 116 | // ..... 117 | 118 | @property({ 119 | type: 'array', 120 | itemType: 'string', 121 | }) 122 | permissions: string[]; 123 | 124 | constructor(data?: Partial) { 125 | super(data); 126 | } 127 | } 128 | ``` 129 | 130 | #### Implement `UserPermissionsOverride` Interface 131 | 132 | If using method #3 from above, implement UserPermissionsOverride interface in User model and add user level permissions array as below. 133 | Do this if there is a use-case of explicit allow/deny of permissions at user-level in the application. 134 | You can skip otherwise. 135 | 136 | ```ts 137 | @model({ 138 | name: 'users', 139 | }) 140 | export class User extends Entity implements UserPermissionsOverride { 141 | // ..... 142 | // other attributes here 143 | // ..... 144 | 145 | @property({ 146 | type: 'array', 147 | itemType: 'object', 148 | }) 149 | permissions: UserPermission[]; 150 | 151 | constructor(data?: Partial) { 152 | super(data); 153 | } 154 | } 155 | ``` 156 | 157 | #### User Permissions Provider 158 | 159 | For method #3, This extension exposes a provider function [AuthorizationBindings.USER_PERMISSIONS](https://github.com/sourcefuse/loopback4-authorization/blob/master/src/providers/user-permissions.provider.ts) to evaluate the user permissions based on its role permissions and user-level overrides. 160 | 161 | Just inject it like below: 162 | 163 | ```ts 164 | @inject(AuthorizationBindings.USER_PERMISSIONS) 165 | private readonly getUserPermissions: UserPermissionsFn, 166 | ``` 167 | 168 | and invoke it 169 | 170 | ```ts 171 | const permissions = this.getUserPermissions(user.permissions, role.permissions); 172 | ``` 173 | 174 | Add a step in custom sequence to check for authorization whenever any endpoint is hit. 175 | 176 | ```ts 177 | import {inject} from '@loopback/context'; 178 | import { 179 | FindRoute, 180 | HttpErrors, 181 | InvokeMethod, 182 | ParseParams, 183 | Reject, 184 | RequestContext, 185 | RestBindings, 186 | Send, 187 | SequenceHandler, 188 | } from '@loopback/rest'; 189 | import {AuthenticateFn, AuthenticationBindings} from 'loopback4-authentication'; 190 | import { 191 | AuthorizationBindings, 192 | AuthorizeErrorKeys, 193 | AuthorizeFn, 194 | UserPermissionsFn, 195 | } from 'loopback4-authorization'; 196 | 197 | import {AuthClient} from './models/auth-client.model'; 198 | import {User} from './models/user.model'; 199 | 200 | const SequenceActions = RestBindings.SequenceActions; 201 | 202 | export class MySequence implements SequenceHandler { 203 | constructor( 204 | @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, 205 | @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams, 206 | @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, 207 | @inject(SequenceActions.SEND) public send: Send, 208 | @inject(SequenceActions.REJECT) public reject: Reject, 209 | @inject(AuthenticationBindings.USER_AUTH_ACTION) 210 | protected authenticateRequest: AuthenticateFn, 211 | @inject(AuthenticationBindings.CLIENT_AUTH_ACTION) 212 | protected authenticateRequestClient: AuthenticateFn, 213 | @inject(AuthorizationBindings.AUTHORIZE_ACTION) 214 | protected checkAuthorisation: AuthorizeFn, 215 | @inject(AuthorizationBindings.USER_PERMISSIONS) 216 | private readonly getUserPermissions: UserPermissionsFn, 217 | ) {} 218 | 219 | async handle(context: RequestContext) { 220 | const requestTime = Date.now(); 221 | try { 222 | const {request, response} = context; 223 | const route = this.findRoute(request); 224 | const args = await this.parseParams(request, route); 225 | request.body = args[args.length - 1]; 226 | await this.authenticateRequestClient(request); 227 | const authUser: User = await this.authenticateRequest(request); 228 | 229 | // Do ths if you are using method #3 230 | const permissions = this.getUserPermissions( 231 | authUser.permissions, 232 | authUser.role.permissions, 233 | ); 234 | // This is the important line added for authorization. Needed for all 3 methods 235 | const isAccessAllowed: boolean = await this.checkAuthorisation( 236 | permissions, // do authUser.permissions if using method #1 237 | request, 238 | ); 239 | // Checking access to route here 240 | if (!isAccessAllowed) { 241 | throw new HttpErrors.Forbidden(AuthorizeErrorKeys.NotAllowedAccess); 242 | } 243 | 244 | const result = await this.invoke(route, args); 245 | this.send(response, result); 246 | } catch (err) { 247 | this.reject(context, err); 248 | } 249 | } 250 | } 251 | ``` 252 | 253 | The above sequence also contains user authentication using [loopback4-authentication](https://github.com/sourcefuse/loopback4-authentication) package. You can refer to the documentation for the same for more details. 254 | 255 | Now we can add access permission keys to the controller methods using authorize decorator as below: 256 | 257 | ```ts 258 | @authorize(['CreateRole']) 259 | @post(rolesPath, { 260 | responses: { 261 | [STATUS_CODE.OK]: { 262 | description: 'Role model instance', 263 | content: { 264 | [CONTENT_TYPE.JSON]: {schema: {'x-ts-type': Role}}, 265 | }, 266 | }, 267 | }, 268 | }) 269 | async create(@requestBody() role: Role): Promise { 270 | return await this.roleRepository.create(role); 271 | } 272 | ``` 273 | 274 | This endpoint will only be accessible if logged in user has permission 275 | `CreateRole`. 276 | 277 | A good practice is to keep all permission strings in a separate enum file like this. 278 | 279 | ```ts 280 | export const enum PermissionKey { 281 | ViewOwnUser = 'ViewOwnUser', 282 | ViewAnyUser = 'ViewAnyUser', 283 | ViewTenantUser = 'ViewTenantUser', 284 | CreateAnyUser = 'CreateAnyUser', 285 | CreateTenantUser = 'CreateTenantUser', 286 | UpdateOwnUser = 'UpdateOwnUser', 287 | UpdateTenantUser = 'UpdateTenantUser', 288 | UpdateAnyUser = 'UpdateAnyUser', 289 | DeleteTenantUser = 'DeleteTenantUser', 290 | DeleteAnyUser = 'DeleteAnyUser', 291 | 292 | ViewTenant = 'ViewTenant', 293 | CreateTenant = 'CreateTenant', 294 | UpdateTenant = 'UpdateTenant', 295 | DeleteTenant = 'DeleteTenant', 296 | 297 | ViewRole = 'ViewRole', 298 | CreateRole = 'CreateRole', 299 | UpdateRole = 'UpdateRole', 300 | DeleteRole = 'DeleteRole', 301 | 302 | ViewAudit = 'ViewAudit', 303 | CreateAudit = 'CreateAudit', 304 | UpdateAudit = 'UpdateAudit', 305 | DeleteAudit = 'DeleteAudit', 306 | } 307 | ``` 308 | 309 | ### Overriding Permissions 310 | 311 | API endpoints provided by ARC API (aka Sourceloop) services have their permissions pre-defined in them bundled. 312 | 313 | In order to override them you can bind your custom permissions in the `AuthorizationBindings.PERMISSION` binding key. 314 | This accepts an object that should have Controller class name as the root level key and the value of which is another object of method to permissions array mapping. 315 | 316 | Like below: 317 | 318 | ```ts 319 | this.bind(AuthorizationBindings.PERMISSION).to({ 320 | MessageController: { 321 | create: ['CreateMessage', 'ViewMessage'], 322 | updateAll: ['UpdateMessage', 'ViewMessage', 'ViewMessageNum'] 323 | } 324 | AttachmentFileController: { 325 | create: ['CreateAttachmentFile', 'ViewAttachmentFile'], 326 | updateAll: ['UpdateAttachmentFile', 'ViewAttachmentFileNum'] 327 | } 328 | }); 329 | ``` 330 | 331 | You can easily check the name of the controller and it's method name from the source code of the services or from the Swagger UI (clicking the endpoint in swagger append the controller and method name in the URL like `LoginController.login` where `login` is the method name). 332 | 333 | ## Serving the static files: 334 | 335 | Authorization configuration binding sets up paths that can be accessed without any authorization checks, allowing static files to be served directly from the root URL of the application.The allowAlwaysPaths property is used to define these paths for the files in public directory i.e for a test.html file in public directory ,one can provide its path as follows: 336 | 337 | ``` 338 | this.bind(AuthorizationBindings.CONFIG).to({ 339 | allowAlwaysPaths: ['/explorer','/test.html'], 340 | }); 341 | ``` 342 | 343 | To set up the public directory as a static,one can add the following in application.ts file. 344 | 345 | ``` 346 | this.static('/', path.join(__dirname, '../public')); 347 | 348 | ``` 349 | 350 | If, in case the file is in some other folder then `app.static()` can be called multiple times to configure the app to serve static assets from different directories. 351 | 352 | ``` 353 | this.static('/', path.join(__dirname, '../public')); 354 | this.static('/downloads', path.join(__dirname, '../downloads')); 355 | 356 | ``` 357 | 358 | For more details,refer [here](https://loopback.io/doc/en/lb4/Serving-static-files.html#:~:text=One%20of%20the%20basic%20requirements,the%20API%20are%20explained%20below.) 359 | 360 | ## Extension enhancement using CASBIN authorisation 361 | 362 | As a further enhancement to these methods, we are using [casbin library](https://casbin.org/docs/en/overview) to define permissions at level of entity or resource associated with an API call. Casbin authorisation implementation can be performed in two ways: 363 | 364 | 1. **Using default casbin policy document** - Define policy document in default casbin format in the app, and configure authorise decorator to use those policies. 365 | 2. **Defining custom logic to form dynamic policies** - Implement dynamic permissions based on app logic in casbin-enforcer-config provider. Authorisation extension will dynamically create casbin policy using this business logic to give the authorisation decisions. 366 | 367 | ### Casbin Usage 368 | 369 | In order to use this enhacement into your LoopBack application, please follow below steps. 370 | 371 | - Add providers to implement casbin authorisation along with authorisation component. 372 | 373 | ```ts 374 | this.bind(AuthorizationBindings.CONFIG).to({ 375 | allowAlwaysPaths: ['/explorer'], 376 | }); 377 | this.component(AuthorizationComponent); 378 | 379 | this.bind(AuthorizationBindings.CASBIN_ENFORCER_CONFIG_GETTER).toProvider( 380 | CasbinEnforcerConfigProvider, 381 | ); 382 | 383 | this.bind(AuthorizationBindings.CASBIN_RESOURCE_MODIFIER_FN).toProvider( 384 | CasbinResValModifierProvider, 385 | ); 386 | ``` 387 | 388 | - Implement the **Casbin Resource value modifier provider**. Customise the resource value based on business logic using route arguments parameter in the provider. 389 | 390 | ```ts 391 | import {Getter, inject, Provider} from '@loopback/context'; 392 | import {HttpErrors} from '@loopback/rest'; 393 | import { 394 | AuthorizationBindings, 395 | AuthorizationMetadata, 396 | CasbinResourceModifierFn, 397 | } from 'loopback4-authorization'; 398 | 399 | export class CasbinResValModifierProvider 400 | implements Provider 401 | { 402 | constructor( 403 | @inject.getter(AuthorizationBindings.METADATA) 404 | private readonly getCasbinMetadata: Getter, 405 | @inject(AuthorizationBindings.PATHS_TO_ALLOW_ALWAYS) 406 | private readonly allowAlwaysPath: string[], 407 | ) {} 408 | 409 | value(): CasbinResourceModifierFn { 410 | return (pathParams: string[], req: Request) => this.action(pathParams, req); 411 | } 412 | 413 | async action(pathParams: string[], req: Request): Promise { 414 | const metadata: AuthorizationMetadata = await this.getCasbinMetadata(); 415 | 416 | if ( 417 | !metadata && 418 | !!this.allowAlwaysPath.find(path => req.path.indexOf(path) === 0) 419 | ) { 420 | return ''; 421 | } 422 | 423 | if (!metadata) { 424 | throw new HttpErrors.InternalServerError(`Metadata object not found`); 425 | } 426 | const res = metadata.resource; 427 | 428 | // Now modify the resource parameter using on path params, as per business logic. 429 | // Returning resource value as such for default case. 430 | 431 | return `${res}`; 432 | } 433 | } 434 | ``` 435 | 436 | - Implement the **casbin enforcer config provider** . Provide the casbin model path. Model definition can be initialized from [.CONF file, from code, or from a string](https://casbin.org/docs/en/model-storage). 437 | In the case of policy creation being handled by extension (isCasbinPolicy parameter is false), provide the array of Resource-Permission objects for a given user, based on business logic. 438 | In other case, provide the policy from file or as CSV string or from [casbin Adapters](https://casbin.org/docs/en/adapters). 439 | **NOTE**: In the second case, if model is initialized from .CONF file, then any of the above formats can be used for policy. But if model is being initialised from code or string, then policy should be provided as [casbin adapter](https://casbin.org/docs/en/adapters) only. 440 | 441 | ```ts 442 | import {Provider} from '@loopback/context'; 443 | import { 444 | CasbinConfig, 445 | CasbinEnforcerConfigGetterFn, 446 | IAuthUserWithPermissions, 447 | } from 'loopback4-authorization'; 448 | import * as path from 'path'; 449 | 450 | export class CasbinEnforcerConfigProvider 451 | implements Provider 452 | { 453 | constructor() {} 454 | 455 | value(): CasbinEnforcerConfigGetterFn { 456 | return ( 457 | authUser: IAuthUserWithPermissions, 458 | resource: string, 459 | isCasbinPolicy?: boolean, 460 | ) => this.action(authUser, resource, isCasbinPolicy); 461 | } 462 | 463 | async action( 464 | authUser: IAuthUserWithPermissions, 465 | resource: string, 466 | isCasbinPolicy?: boolean, 467 | ): Promise { 468 | const model = path.resolve(__dirname, './../../fixtures/casbin/model.conf'); // Model initialization from file path 469 | /** 470 | * import * as casbin from 'casbin'; 471 | * 472 | * To initialize model from code, use 473 | * let m = new casbin.Model(); 474 | * m.addDef('r', 'r', 'sub, obj, act'); and so on... 475 | * 476 | * To initialize model from string, use 477 | * const text = ` 478 | * [request_definition] 479 | * r = sub, obj, act 480 | * 481 | * [policy_definition] 482 | * p = sub, obj, act 483 | * 484 | * [policy_effect] 485 | * e = some(where (p.eft == allow)) 486 | * 487 | * [matchers] 488 | * m = r.sub == p.sub && r.obj == p.obj && r.act == p.act 489 | * `; 490 | * const model = casbin.newModelFromString(text); 491 | */ 492 | 493 | // Write business logic to find out the allowed resource-permission sets for this user. Below is a dummy value. 494 | //const allowedRes = [{resource: 'session', permission: "CreateMeetingSession"}]; 495 | 496 | const policy = path.resolve( 497 | __dirname, 498 | './../../fixtures/casbin/policy.csv', 499 | ); 500 | 501 | const result: CasbinConfig = { 502 | model, 503 | //allowedRes, 504 | policy, 505 | }; 506 | return result; 507 | } 508 | } 509 | ``` 510 | 511 | - Add the dependency injections for resource value modifer provider, and casbin authorisation function in the sequence.ts 512 | 513 | ```ts 514 | @inject(AuthorizationBindings.CASBIN_AUTHORIZE_ACTION) 515 | protected checkAuthorisation: CasbinAuthorizeFn, 516 | @inject(AuthorizationBindings.CASBIN_RESOURCE_MODIFIER_FN) 517 | protected casbinResModifierFn: CasbinResourceModifierFn, 518 | ``` 519 | 520 | - Add a step in custom sequence to check for authorization whenever any end 521 | point is hit. 522 | 523 | ```ts 524 | import {inject} from '@loopback/context'; 525 | import { 526 | FindRoute, 527 | HttpErrors, 528 | InvokeMethod, 529 | ParseParams, 530 | Reject, 531 | RequestContext, 532 | RestBindings, 533 | Send, 534 | SequenceHandler, 535 | } from '@loopback/rest'; 536 | import {AuthenticateFn, AuthenticationBindings} from 'loopback4-authentication'; 537 | import { 538 | AuthorizationBindings, 539 | AuthorizeErrorKeys, 540 | AuthorizeFn, 541 | UserPermissionsFn, 542 | } from 'loopback4-authorization'; 543 | 544 | import {AuthClient} from './models/auth-client.model'; 545 | import {User} from './models/user.model'; 546 | 547 | const SequenceActions = RestBindings.SequenceActions; 548 | 549 | export class MySequence implements SequenceHandler { 550 | constructor( 551 | @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, 552 | @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams, 553 | @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, 554 | @inject(SequenceActions.SEND) public send: Send, 555 | @inject(SequenceActions.REJECT) public reject: Reject, 556 | @inject(AuthenticationBindings.USER_AUTH_ACTION) 557 | protected authenticateRequest: AuthenticateFn, 558 | @inject(AuthenticationBindings.CLIENT_AUTH_ACTION) 559 | protected authenticateRequestClient: AuthenticateFn, 560 | @inject(AuthorizationBindings.CASBIN_AUTHORIZE_ACTION) 561 | protected checkAuthorisation: CasbinAuthorizeFn, 562 | @inject(AuthorizationBindings.CASBIN_RESOURCE_MODIFIER_FN) 563 | protected casbinResModifierFn: CasbinResourceModifierFn, 564 | ) {} 565 | 566 | async handle(context: RequestContext) { 567 | const requestTime = Date.now(); 568 | try { 569 | const {request, response} = context; 570 | const route = this.findRoute(request); 571 | const args = await this.parseParams(request, route); 572 | request.body = args[args.length - 1]; 573 | await this.authenticateRequestClient(request); 574 | const authUser: User = await this.authenticateRequest(request); 575 | 576 | // Invoke Resource value modifier 577 | const resVal = await this.casbinResModifierFn(args); 578 | 579 | // Check authorisation 580 | const isAccessAllowed: boolean = await this.checkAuthorisation( 581 | authUser, 582 | resVal, 583 | request, 584 | ); 585 | // Checking access to route here 586 | if (!isAccessAllowed) { 587 | throw new HttpErrors.Forbidden(AuthorizeErrorKeys.NotAllowedAccess); 588 | } 589 | 590 | const result = await this.invoke(route, args); 591 | this.send(response, result); 592 | } catch (err) { 593 | this.reject(context, err); 594 | } 595 | } 596 | } 597 | ``` 598 | 599 | - Now we can add access permission keys to the controller methods using authorize 600 | decorator as below. Set isCasbinPolicy parameter to use casbin default policy format. Default is false. 601 | 602 | ```ts 603 | @authorize({permissions: ['CreateRole'], resource:'role', isCasbinPolicy: true}) 604 | @post(rolesPath, { 605 | responses: { 606 | [STATUS_CODE.OK]: { 607 | description: 'Role model instance', 608 | content: { 609 | [CONTENT_TYPE.JSON]: {schema: {'x-ts-type': Role}}, 610 | }, 611 | }, 612 | }, 613 | }) 614 | async create(@requestBody() role: Role): Promise { 615 | return await this.roleRepository.create(role); 616 | } 617 | ``` 618 | 619 | ## Feedback 620 | 621 | If you've noticed a bug or have a question or have a feature request, [search the issue tracker](https://github.com/sourcefuse/loopback4-authorization/issues) to see if someone else in the community has already created a ticket. 622 | If not, go ahead and [make one](https://github.com/sourcefuse/loopback4-authorization/issues/new/choose)! 623 | All feature requests are welcome. Implementation time may vary. Feel free to contribute the same, if you can. 624 | If you think this extension is useful, please [star](https://help.github.com/en/articles/about-stars) it. Appreciation really helps in keeping this project alive. 625 | 626 | ## Contributing 627 | 628 | Please read [CONTRIBUTING.md](https://github.com/sourcefuse/loopback4-authorization/blob/master/.github/CONTRIBUTING.md) for details on the process for submitting pull requests to us. 629 | 630 | ## Code of conduct 631 | 632 | Code of conduct guidelines [here](https://github.com/sourcefuse/loopback4-authorization/blob/master/.github/CODE_OF_CONDUCT.md). 633 | 634 | ## License 635 | 636 | [MIT](https://github.com/sourcefuse/loopback4-authorization/blob/master/LICENSE) 637 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: loopback4-authorization 5 | annotations: 6 | github.com/project-slug: sourcefuse/loopback4-authorization 7 | backstage.io/techdocs-ref: dir:. 8 | namespace: arc 9 | description: A LoopBack 4 extension for managing API Authorization. 10 | tags: 11 | - authorization 12 | - access-management 13 | - loopback 14 | - extension 15 | links: 16 | - url: https://npmjs.com/package/loopback4-authorization 17 | title: NPM Package 18 | - url: https://loopback.io/doc/en/lb4/Extending-LoopBack-4.html#overview 19 | title: Extending LoopBack 20 | spec: 21 | type: component 22 | lifecycle: production 23 | owner: sourcefuse 24 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'header-max-length': [2, 'always', 100], 5 | 'body-leading-blank': [2, 'always'], 6 | 'footer-leading-blank': [0, 'always'], 7 | 'references-empty': [2, 'never'], 8 | 'body-empty': [2, 'never'], 9 | }, 10 | parserPreset: { 11 | parserOpts: { 12 | issuePrefixes: ['GH-'], 13 | }, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ARC By SourceFuse logo 2 | 3 | # [loopback4-authorization](https://github.com/sourcefuse/loopback4-authorization) 4 | 5 |

6 | 7 | npm version 8 | 9 | 10 | Sonar Quality Gate 11 | 12 | 13 | Synk Status 14 | 15 | 16 | GitHub contributors 17 | 18 | 19 | downloads 20 | 21 | 22 | License 23 | 24 | 25 | Powered By LoopBack 4 26 | 27 |

28 | 29 | ## Overview 30 | 31 | A LoopBack 4 extension for Authorization Capabilities. It's very simple to integration yet powerful and effective. 32 | 33 | ## Install 34 | 35 | ```sh 36 | npm install loopback4-authorization 37 | ``` 38 | 39 | ## Quick Starter 40 | 41 | For a quick starter guide, you can refer to our [loopback 4 starter](https://github.com/sourcefuse/loopback4-starter) application which utilizes method #3 from the above in a simple multi-tenant application. 42 | 43 | ## Usage 44 | 45 | ### Ways of Integration: 46 | 47 | On a higher level, it provides three ways of integration: 48 | 49 | #### 1. User Level Permissions Only 50 | 51 | Where permissions are associated directly to user. In this case, each user entry in DB contains specific array of permission keys. 52 | 53 | #### 2. Role Based Permissions 54 | 55 | Where permissions are associated to roles and users have a specific role attached. This actually reduces redundancy in DB a lot, as most of the time, users will have many common permissions. If that is not the case for you, then, use the first method. 56 | 57 | #### 3. Role Based Permissions with User Level Flexibility 58 | 59 | This is the most flexible architecture. In this case, method #2 is implemented as is. 60 | 61 | On top of it, we also add user-level permissions override, allow/deny permissions over role permissions. So, say there is user who can perform all admin role actions except he cannot remove users from the system. So, DeleteUser permission can be denied at user level and role can be set as Admin for the user. 62 | 63 | [Extension enhancement using CASBIN authorisation](#Extension-enhancement-using-CASBIN-authorisation) 64 | 65 | Refer to the usage section below for details on integration. 66 | 67 | In order to use this component into your LoopBack application, please follow below steps. 68 | 69 | ### Steps 70 | 71 | #### Bind Component 72 | 73 | Add `AuthorizationComponent` to your application, Like below: 74 | 75 | ```ts 76 | this.bind(AuthorizationBindings.CONFIG).to({ 77 | allowAlwaysPaths: ['/explorer'], 78 | }); 79 | this.component(AuthorizationComponent); 80 | ``` 81 | 82 | #### Implement Permission Interface 83 | 84 | If using method #1 from above, implement Permissions interface in User model and add permissions array. 85 | 86 | ```ts 87 | @model({ 88 | name: 'users', 89 | }) 90 | export class User extends Entity implements Permissions { 91 | // ..... 92 | // other attributes here 93 | // ..... 94 | 95 | @property({ 96 | type: 'array', 97 | itemType: 'string', 98 | }) 99 | permissions: string[]; 100 | 101 | constructor(data?: Partial) { 102 | super(data); 103 | } 104 | } 105 | ``` 106 | 107 | If using method #2 or #3 from above, implement Permissions interface in Role model and add permissions array. 108 | 109 | ```ts 110 | @model({ 111 | name: 'roles', 112 | }) 113 | export class Role extends Entity implements Permissions { 114 | // ..... 115 | // other attributes here 116 | // ..... 117 | 118 | @property({ 119 | type: 'array', 120 | itemType: 'string', 121 | }) 122 | permissions: string[]; 123 | 124 | constructor(data?: Partial) { 125 | super(data); 126 | } 127 | } 128 | ``` 129 | 130 | #### Implement `UserPermissionsOverride` Interface 131 | 132 | If using method #3 from above, implement UserPermissionsOverride interface in User model and add user level permissions array as below. 133 | Do this if there is a use-case of explicit allow/deny of permissions at user-level in the application. 134 | You can skip otherwise. 135 | 136 | ```ts 137 | @model({ 138 | name: 'users', 139 | }) 140 | export class User extends Entity implements UserPermissionsOverride { 141 | // ..... 142 | // other attributes here 143 | // ..... 144 | 145 | @property({ 146 | type: 'array', 147 | itemType: 'object', 148 | }) 149 | permissions: UserPermission[]; 150 | 151 | constructor(data?: Partial) { 152 | super(data); 153 | } 154 | } 155 | ``` 156 | 157 | #### User Permissions Provider 158 | 159 | For method #3, This extension exposes a provider function [AuthorizationBindings.USER_PERMISSIONS](https://github.com/sourcefuse/loopback4-authorization/blob/master/src/providers/user-permissions.provider.ts) to evaluate the user permissions based on its role permissions and user-level overrides. 160 | 161 | Just inject it like below: 162 | 163 | ```ts 164 | @inject(AuthorizationBindings.USER_PERMISSIONS) 165 | private readonly getUserPermissions: UserPermissionsFn, 166 | ``` 167 | 168 | and invoke it 169 | 170 | ```ts 171 | const permissions = this.getUserPermissions(user.permissions, role.permissions); 172 | ``` 173 | 174 | Add a step in custom sequence to check for authorization whenever any endpoint is hit. 175 | 176 | ```ts 177 | import {inject} from '@loopback/context'; 178 | import { 179 | FindRoute, 180 | HttpErrors, 181 | InvokeMethod, 182 | ParseParams, 183 | Reject, 184 | RequestContext, 185 | RestBindings, 186 | Send, 187 | SequenceHandler, 188 | } from '@loopback/rest'; 189 | import {AuthenticateFn, AuthenticationBindings} from 'loopback4-authentication'; 190 | import { 191 | AuthorizationBindings, 192 | AuthorizeErrorKeys, 193 | AuthorizeFn, 194 | UserPermissionsFn, 195 | } from 'loopback4-authorization'; 196 | 197 | import {AuthClient} from './models/auth-client.model'; 198 | import {User} from './models/user.model'; 199 | 200 | const SequenceActions = RestBindings.SequenceActions; 201 | 202 | export class MySequence implements SequenceHandler { 203 | constructor( 204 | @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, 205 | @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams, 206 | @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, 207 | @inject(SequenceActions.SEND) public send: Send, 208 | @inject(SequenceActions.REJECT) public reject: Reject, 209 | @inject(AuthenticationBindings.USER_AUTH_ACTION) 210 | protected authenticateRequest: AuthenticateFn, 211 | @inject(AuthenticationBindings.CLIENT_AUTH_ACTION) 212 | protected authenticateRequestClient: AuthenticateFn, 213 | @inject(AuthorizationBindings.AUTHORIZE_ACTION) 214 | protected checkAuthorisation: AuthorizeFn, 215 | @inject(AuthorizationBindings.USER_PERMISSIONS) 216 | private readonly getUserPermissions: UserPermissionsFn, 217 | ) {} 218 | 219 | async handle(context: RequestContext) { 220 | const requestTime = Date.now(); 221 | try { 222 | const {request, response} = context; 223 | const route = this.findRoute(request); 224 | const args = await this.parseParams(request, route); 225 | request.body = args[args.length - 1]; 226 | await this.authenticateRequestClient(request); 227 | const authUser: User = await this.authenticateRequest(request); 228 | 229 | // Do ths if you are using method #3 230 | const permissions = this.getUserPermissions( 231 | authUser.permissions, 232 | authUser.role.permissions, 233 | ); 234 | // This is the important line added for authorization. Needed for all 3 methods 235 | const isAccessAllowed: boolean = await this.checkAuthorisation( 236 | permissions, // do authUser.permissions if using method #1 237 | request, 238 | ); 239 | // Checking access to route here 240 | if (!isAccessAllowed) { 241 | throw new HttpErrors.Forbidden(AuthorizeErrorKeys.NotAllowedAccess); 242 | } 243 | 244 | const result = await this.invoke(route, args); 245 | this.send(response, result); 246 | } catch (err) { 247 | this.reject(context, err); 248 | } 249 | } 250 | } 251 | ``` 252 | 253 | The above sequence also contains user authentication using [loopback4-authentication](https://github.com/sourcefuse/loopback4-authentication) package. You can refer to the documentation for the same for more details. 254 | 255 | Now we can add access permission keys to the controller methods using authorize decorator as below: 256 | 257 | ```ts 258 | @authorize(['CreateRole']) 259 | @post(rolesPath, { 260 | responses: { 261 | [STATUS_CODE.OK]: { 262 | description: 'Role model instance', 263 | content: { 264 | [CONTENT_TYPE.JSON]: {schema: {'x-ts-type': Role}}, 265 | }, 266 | }, 267 | }, 268 | }) 269 | async create(@requestBody() role: Role): Promise { 270 | return await this.roleRepository.create(role); 271 | } 272 | ``` 273 | 274 | This endpoint will only be accessible if logged in user has permission 275 | `CreateRole`. 276 | 277 | A good practice is to keep all permission strings in a separate enum file like this. 278 | 279 | ```ts 280 | export const enum PermissionKey { 281 | ViewOwnUser = 'ViewOwnUser', 282 | ViewAnyUser = 'ViewAnyUser', 283 | ViewTenantUser = 'ViewTenantUser', 284 | CreateAnyUser = 'CreateAnyUser', 285 | CreateTenantUser = 'CreateTenantUser', 286 | UpdateOwnUser = 'UpdateOwnUser', 287 | UpdateTenantUser = 'UpdateTenantUser', 288 | UpdateAnyUser = 'UpdateAnyUser', 289 | DeleteTenantUser = 'DeleteTenantUser', 290 | DeleteAnyUser = 'DeleteAnyUser', 291 | 292 | ViewTenant = 'ViewTenant', 293 | CreateTenant = 'CreateTenant', 294 | UpdateTenant = 'UpdateTenant', 295 | DeleteTenant = 'DeleteTenant', 296 | 297 | ViewRole = 'ViewRole', 298 | CreateRole = 'CreateRole', 299 | UpdateRole = 'UpdateRole', 300 | DeleteRole = 'DeleteRole', 301 | 302 | ViewAudit = 'ViewAudit', 303 | CreateAudit = 'CreateAudit', 304 | UpdateAudit = 'UpdateAudit', 305 | DeleteAudit = 'DeleteAudit', 306 | } 307 | ``` 308 | 309 | ### Overriding Permissions 310 | 311 | API endpoints provided by ARC API (aka Sourceloop) services have their permissions pre-defined in them bundled. 312 | 313 | In order to override them you can bind your custom permissions in the `AuthorizationBindings.PERMISSION` binding key. 314 | This accepts an object that should have Controller class name as the root level key and the value of which is another object of method to permissions array mapping. 315 | 316 | Like below: 317 | 318 | ```ts 319 | this.bind(AuthorizationBindings.PERMISSION).to({ 320 | MessageController: { 321 | create: ['CreateMessage', 'ViewMessage'], 322 | updateAll: ['UpdateMessage', 'ViewMessage', 'ViewMessageNum'] 323 | } 324 | AttachmentFileController: { 325 | create: ['CreateAttachmentFile', 'ViewAttachmentFile'], 326 | updateAll: ['UpdateAttachmentFile', 'ViewAttachmentFileNum'] 327 | } 328 | }); 329 | ``` 330 | 331 | You can easily check the name of the controller and it's method name from the source code of the services or from the Swagger UI (clicking the endpoint in swagger append the controller and method name in the URL like `LoginController.login` where `login` is the method name). 332 | 333 | ## Serving the static files: 334 | 335 | Authorization configuration binding sets up paths that can be accessed without any authorization checks, allowing static files to be served directly from the root URL of the application.The allowAlwaysPaths property is used to define these paths for the files in public directory i.e for a test.html file in public directory ,one can provide its path as follows: 336 | 337 | ``` 338 | this.bind(AuthorizationBindings.CONFIG).to({ 339 | allowAlwaysPaths: ['/explorer','/test.html'], 340 | }); 341 | ``` 342 | 343 | To set up the public directory as a static,one can add the following in application.ts file. 344 | 345 | ``` 346 | this.static('/', path.join(__dirname, '../public')); 347 | 348 | ``` 349 | 350 | If, in case the file is in some other folder then `app.static()` can be called multiple times to configure the app to serve static assets from different directories. 351 | 352 | ``` 353 | this.static('/', path.join(__dirname, '../public')); 354 | this.static('/downloads', path.join(__dirname, '../downloads')); 355 | 356 | ``` 357 | 358 | For more details,refer [here](https://loopback.io/doc/en/lb4/Serving-static-files.html#:~:text=One%20of%20the%20basic%20requirements,the%20API%20are%20explained%20below.) 359 | 360 | ## Extension enhancement using CASBIN authorisation 361 | 362 | As a further enhancement to these methods, we are using [casbin library](https://casbin.org/docs/en/overview) to define permissions at level of entity or resource associated with an API call. Casbin authorisation implementation can be performed in two ways: 363 | 364 | 1. **Using default casbin policy document** - Define policy document in default casbin format in the app, and configure authorise decorator to use those policies. 365 | 2. **Defining custom logic to form dynamic policies** - Implement dynamic permissions based on app logic in casbin-enforcer-config provider. Authorisation extension will dynamically create casbin policy using this business logic to give the authorisation decisions. 366 | 367 | ### Casbin Usage 368 | 369 | In order to use this enhacement into your LoopBack application, please follow below steps. 370 | 371 | - Add providers to implement casbin authorisation along with authorisation component. 372 | 373 | ```ts 374 | this.bind(AuthorizationBindings.CONFIG).to({ 375 | allowAlwaysPaths: ['/explorer'], 376 | }); 377 | this.component(AuthorizationComponent); 378 | 379 | this.bind(AuthorizationBindings.CASBIN_ENFORCER_CONFIG_GETTER).toProvider( 380 | CasbinEnforcerConfigProvider, 381 | ); 382 | 383 | this.bind(AuthorizationBindings.CASBIN_RESOURCE_MODIFIER_FN).toProvider( 384 | CasbinResValModifierProvider, 385 | ); 386 | ``` 387 | 388 | - Implement the **Casbin Resource value modifier provider**. Customise the resource value based on business logic using route arguments parameter in the provider. 389 | 390 | ```ts 391 | import {Getter, inject, Provider} from '@loopback/context'; 392 | import {HttpErrors} from '@loopback/rest'; 393 | import { 394 | AuthorizationBindings, 395 | AuthorizationMetadata, 396 | CasbinResourceModifierFn, 397 | } from 'loopback4-authorization'; 398 | 399 | export class CasbinResValModifierProvider 400 | implements Provider 401 | { 402 | constructor( 403 | @inject.getter(AuthorizationBindings.METADATA) 404 | private readonly getCasbinMetadata: Getter, 405 | @inject(AuthorizationBindings.PATHS_TO_ALLOW_ALWAYS) 406 | private readonly allowAlwaysPath: string[], 407 | ) {} 408 | 409 | value(): CasbinResourceModifierFn { 410 | return (pathParams: string[], req: Request) => this.action(pathParams, req); 411 | } 412 | 413 | async action(pathParams: string[], req: Request): Promise { 414 | const metadata: AuthorizationMetadata = await this.getCasbinMetadata(); 415 | 416 | if ( 417 | !metadata && 418 | !!this.allowAlwaysPath.find(path => req.path.indexOf(path) === 0) 419 | ) { 420 | return ''; 421 | } 422 | 423 | if (!metadata) { 424 | throw new HttpErrors.InternalServerError(`Metadata object not found`); 425 | } 426 | const res = metadata.resource; 427 | 428 | // Now modify the resource parameter using on path params, as per business logic. 429 | // Returning resource value as such for default case. 430 | 431 | return `${res}`; 432 | } 433 | } 434 | ``` 435 | 436 | - Implement the **casbin enforcer config provider** . Provide the casbin model path. Model definition can be initialized from [.CONF file, from code, or from a string](https://casbin.org/docs/en/model-storage). 437 | In the case of policy creation being handled by extension (isCasbinPolicy parameter is false), provide the array of Resource-Permission objects for a given user, based on business logic. 438 | In other case, provide the policy from file or as CSV string or from [casbin Adapters](https://casbin.org/docs/en/adapters). 439 | **NOTE**: In the second case, if model is initialized from .CONF file, then any of the above formats can be used for policy. But if model is being initialised from code or string, then policy should be provided as [casbin adapter](https://casbin.org/docs/en/adapters) only. 440 | 441 | ```ts 442 | import {Provider} from '@loopback/context'; 443 | import { 444 | CasbinConfig, 445 | CasbinEnforcerConfigGetterFn, 446 | IAuthUserWithPermissions, 447 | } from 'loopback4-authorization'; 448 | import * as path from 'path'; 449 | 450 | export class CasbinEnforcerConfigProvider 451 | implements Provider 452 | { 453 | constructor() {} 454 | 455 | value(): CasbinEnforcerConfigGetterFn { 456 | return ( 457 | authUser: IAuthUserWithPermissions, 458 | resource: string, 459 | isCasbinPolicy?: boolean, 460 | ) => this.action(authUser, resource, isCasbinPolicy); 461 | } 462 | 463 | async action( 464 | authUser: IAuthUserWithPermissions, 465 | resource: string, 466 | isCasbinPolicy?: boolean, 467 | ): Promise { 468 | const model = path.resolve(__dirname, './../../fixtures/casbin/model.conf'); // Model initialization from file path 469 | /** 470 | * import * as casbin from 'casbin'; 471 | * 472 | * To initialize model from code, use 473 | * let m = new casbin.Model(); 474 | * m.addDef('r', 'r', 'sub, obj, act'); and so on... 475 | * 476 | * To initialize model from string, use 477 | * const text = ` 478 | * [request_definition] 479 | * r = sub, obj, act 480 | * 481 | * [policy_definition] 482 | * p = sub, obj, act 483 | * 484 | * [policy_effect] 485 | * e = some(where (p.eft == allow)) 486 | * 487 | * [matchers] 488 | * m = r.sub == p.sub && r.obj == p.obj && r.act == p.act 489 | * `; 490 | * const model = casbin.newModelFromString(text); 491 | */ 492 | 493 | // Write business logic to find out the allowed resource-permission sets for this user. Below is a dummy value. 494 | //const allowedRes = [{resource: 'session', permission: "CreateMeetingSession"}]; 495 | 496 | const policy = path.resolve( 497 | __dirname, 498 | './../../fixtures/casbin/policy.csv', 499 | ); 500 | 501 | const result: CasbinConfig = { 502 | model, 503 | //allowedRes, 504 | policy, 505 | }; 506 | return result; 507 | } 508 | } 509 | ``` 510 | 511 | - Add the dependency injections for resource value modifer provider, and casbin authorisation function in the sequence.ts 512 | 513 | ```ts 514 | @inject(AuthorizationBindings.CASBIN_AUTHORIZE_ACTION) 515 | protected checkAuthorisation: CasbinAuthorizeFn, 516 | @inject(AuthorizationBindings.CASBIN_RESOURCE_MODIFIER_FN) 517 | protected casbinResModifierFn: CasbinResourceModifierFn, 518 | ``` 519 | 520 | - Add a step in custom sequence to check for authorization whenever any end 521 | point is hit. 522 | 523 | ```ts 524 | import {inject} from '@loopback/context'; 525 | import { 526 | FindRoute, 527 | HttpErrors, 528 | InvokeMethod, 529 | ParseParams, 530 | Reject, 531 | RequestContext, 532 | RestBindings, 533 | Send, 534 | SequenceHandler, 535 | } from '@loopback/rest'; 536 | import {AuthenticateFn, AuthenticationBindings} from 'loopback4-authentication'; 537 | import { 538 | AuthorizationBindings, 539 | AuthorizeErrorKeys, 540 | AuthorizeFn, 541 | UserPermissionsFn, 542 | } from 'loopback4-authorization'; 543 | 544 | import {AuthClient} from './models/auth-client.model'; 545 | import {User} from './models/user.model'; 546 | 547 | const SequenceActions = RestBindings.SequenceActions; 548 | 549 | export class MySequence implements SequenceHandler { 550 | constructor( 551 | @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, 552 | @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams, 553 | @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, 554 | @inject(SequenceActions.SEND) public send: Send, 555 | @inject(SequenceActions.REJECT) public reject: Reject, 556 | @inject(AuthenticationBindings.USER_AUTH_ACTION) 557 | protected authenticateRequest: AuthenticateFn, 558 | @inject(AuthenticationBindings.CLIENT_AUTH_ACTION) 559 | protected authenticateRequestClient: AuthenticateFn, 560 | @inject(AuthorizationBindings.CASBIN_AUTHORIZE_ACTION) 561 | protected checkAuthorisation: CasbinAuthorizeFn, 562 | @inject(AuthorizationBindings.CASBIN_RESOURCE_MODIFIER_FN) 563 | protected casbinResModifierFn: CasbinResourceModifierFn, 564 | ) {} 565 | 566 | async handle(context: RequestContext) { 567 | const requestTime = Date.now(); 568 | try { 569 | const {request, response} = context; 570 | const route = this.findRoute(request); 571 | const args = await this.parseParams(request, route); 572 | request.body = args[args.length - 1]; 573 | await this.authenticateRequestClient(request); 574 | const authUser: User = await this.authenticateRequest(request); 575 | 576 | // Invoke Resource value modifier 577 | const resVal = await this.casbinResModifierFn(args); 578 | 579 | // Check authorisation 580 | const isAccessAllowed: boolean = await this.checkAuthorisation( 581 | authUser, 582 | resVal, 583 | request, 584 | ); 585 | // Checking access to route here 586 | if (!isAccessAllowed) { 587 | throw new HttpErrors.Forbidden(AuthorizeErrorKeys.NotAllowedAccess); 588 | } 589 | 590 | const result = await this.invoke(route, args); 591 | this.send(response, result); 592 | } catch (err) { 593 | this.reject(context, err); 594 | } 595 | } 596 | } 597 | ``` 598 | 599 | - Now we can add access permission keys to the controller methods using authorize 600 | decorator as below. Set isCasbinPolicy parameter to use casbin default policy format. Default is false. 601 | 602 | ```ts 603 | @authorize({permissions: ['CreateRole'], resource:'role', isCasbinPolicy: true}) 604 | @post(rolesPath, { 605 | responses: { 606 | [STATUS_CODE.OK]: { 607 | description: 'Role model instance', 608 | content: { 609 | [CONTENT_TYPE.JSON]: {schema: {'x-ts-type': Role}}, 610 | }, 611 | }, 612 | }, 613 | }) 614 | async create(@requestBody() role: Role): Promise { 615 | return await this.roleRepository.create(role); 616 | } 617 | ``` 618 | 619 | ## Feedback 620 | 621 | If you've noticed a bug or have a question or have a feature request, [search the issue tracker](https://github.com/sourcefuse/loopback4-authorization/issues) to see if someone else in the community has already created a ticket. 622 | If not, go ahead and [make one](https://github.com/sourcefuse/loopback4-authorization/issues/new/choose)! 623 | All feature requests are welcome. Implementation time may vary. Feel free to contribute the same, if you can. 624 | If you think this extension is useful, please [star](https://help.github.com/en/articles/about-stars) it. Appreciation really helps in keeping this project alive. 625 | 626 | ## Contributing 627 | 628 | Please read [CONTRIBUTING.md](https://github.com/sourcefuse/loopback4-authorization/blob/master/.github/CONTRIBUTING.md) for details on the process for submitting pull requests to us. 629 | 630 | ## Code of conduct 631 | 632 | Code of conduct guidelines [here](https://github.com/sourcefuse/loopback4-authorization/blob/master/.github/CODE_OF_CONDUCT.md). 633 | 634 | ## License 635 | 636 | [MIT](https://github.com/sourcefuse/loopback4-authorization/blob/master/LICENSE) 637 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: loopback4-authorization 2 | site_description: loopback4-authorization 3 | 4 | plugins: 5 | - techdocs-core 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback4-authorization", 3 | "version": "7.0.3", 4 | "description": "ARC authorization extension for loopback-next applications.", 5 | "keywords": [ 6 | "loopback-extension", 7 | "loopback", 8 | "loopback-next", 9 | "authorization", 10 | "authorisation" 11 | ], 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "engines": { 15 | "node": ">=18" 16 | }, 17 | "scripts": { 18 | "build": "npm run clean && lb-tsc", 19 | "build:watch": "lb-tsc --watch", 20 | "clean": "lb-clean dist *.tsbuildinfo .eslintcache", 21 | "lint": "npm run prettier:check && npm run eslint", 22 | "lint:fix": "npm run eslint:fix && npm run prettier:fix", 23 | "prettier:cli": "lb-prettier \"**/*.ts\" \"**/*.js\"", 24 | "prettier:check": "npm run prettier:cli -- -l", 25 | "prettier:fix": "npm run prettier:cli -- --write", 26 | "eslint": "lb-eslint --report-unused-disable-directives .", 27 | "eslint:fix": "npm run eslint -- --fix", 28 | "pretest": "npm run build", 29 | "test": "lb-mocha --allow-console-logs \"dist/__tests__\"", 30 | "coverage": "lb-nyc npm run test", 31 | "coverage:ci": "lb-nyc report --reporter=text-lcov | coveralls", 32 | "posttest": "npm run lint", 33 | "test:dev": "lb-mocha --allow-console-logs dist/__tests__/**/*.js && npm run posttest", 34 | "prepublishOnly": "npm run build && npm run lint", 35 | "prepare": "husky install" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/sourcefuse/loopback4-authorization" 40 | }, 41 | "author": "Sourcefuse", 42 | "license": "MIT", 43 | "files": [ 44 | "README.md", 45 | "dist", 46 | "src", 47 | "!*/__tests__" 48 | ], 49 | "dependencies": { 50 | "@loopback/core": "^6.1.6", 51 | "casbin": "^5.32.0", 52 | "casbin-pg-adapter": "^1.4.0", 53 | "lodash": "^4.17.21" 54 | }, 55 | "devDependencies": { 56 | "@commitlint/cli": "^17.7.1", 57 | "@commitlint/config-conventional": "^17.7.0", 58 | "@loopback/boot": "^7.0.9", 59 | "@loopback/build": "^11.0.8", 60 | "@loopback/context": "^7.0.9", 61 | "@loopback/eslint-config": "^15.0.4", 62 | "@loopback/rest": "^14.0.9", 63 | "@loopback/testlab": "^7.0.8", 64 | "@semantic-release/changelog": "^6.0.1", 65 | "@semantic-release/commit-analyzer": "^9.0.2", 66 | "@semantic-release/git": "^10.0.1", 67 | "@semantic-release/npm": "^9.0.1", 68 | "@semantic-release/release-notes-generator": "^10.0.3", 69 | "@types/lodash": "^4.14.181", 70 | "@types/node": "^18.11.9", 71 | "commitizen": "^4.2.4", 72 | "cz-conventional-changelog": "^3.3.0", 73 | "cz-customizable": "^6.3.0", 74 | "cz-customizable-ghooks": "^2.0.0", 75 | "eslint": "^8.57.0", 76 | "fs-extra": "^11.2.0", 77 | "git-release-notes": "^5.0.0", 78 | "husky": "^7.0.4", 79 | "jsdom": "^21.0.0", 80 | "semantic-release": "^19.0.3", 81 | "simple-git": "^3.15.1", 82 | "typescript": "~5.2.2" 83 | }, 84 | "publishConfig": { 85 | "registry": "https://registry.npmjs.org/" 86 | }, 87 | "overrides": { 88 | "git-release-notes": { 89 | "ejs": "^3.1.8", 90 | "yargs": "^17.6.2" 91 | }, 92 | "@semantic-release/npm": { 93 | "npm": "^9.4.2" 94 | } 95 | }, 96 | "config": { 97 | "commitizen": { 98 | "path": "./node_modules/cz-customizable" 99 | } 100 | }, 101 | "release": { 102 | "branches": [ 103 | "master" 104 | ], 105 | "plugins": [ 106 | [ 107 | "@semantic-release/commit-analyzer", 108 | { 109 | "preset": "angular", 110 | "releaseRules": [ 111 | { 112 | "type": "chore", 113 | "scope": "deps", 114 | "release": "patch" 115 | } 116 | ] 117 | } 118 | ], 119 | "@semantic-release/release-notes-generator", 120 | "@semantic-release/npm", 121 | [ 122 | "@semantic-release/git", 123 | { 124 | "assets": [ 125 | "package.json", 126 | "CHANGELOG.md" 127 | ], 128 | "message": "chore(release): ${nextRelease.version} semantic" 129 | } 130 | ], 131 | "@semantic-release/github" 132 | ], 133 | "repositoryUrl": "git@github.com:sourcefuse/loopback4-authorization.git" 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/README.md: -------------------------------------------------------------------------------- 1 | # Acceptance tests 2 | -------------------------------------------------------------------------------- /src/__tests__/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration tests 2 | -------------------------------------------------------------------------------- /src/__tests__/unit/README.md: -------------------------------------------------------------------------------- 1 | # Unit tests 2 | -------------------------------------------------------------------------------- /src/__tests__/unit/authorization-action.provider.unit.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | ShotRequestOptions, 4 | sinon, 5 | stubExpressContext, 6 | } from '@loopback/testlab'; 7 | import {Request} from 'express'; 8 | import {AuthorizeActionProvider} from '../../providers'; 9 | import {AuthorizationMetadata} from '../../types'; 10 | import {mockUser} from './data/mock-user'; 11 | import {Context} from '@loopback/core'; 12 | 13 | const mockPermissions: AuthorizationMetadata = { 14 | permissions: ['ViewTodo'], 15 | }; 16 | 17 | describe('AuthorizeActionProvider', () => { 18 | it('should return true if user has required permissions', async () => { 19 | const metadataGetterStub = sinon.stub().resolves(mockPermissions); 20 | const action = new AuthorizeActionProvider( 21 | metadataGetterStub, 22 | [], 23 | getRequestContext(), 24 | ).value(); 25 | const result = await action(mockUser.permissions); 26 | expect(result).to.be.true(); 27 | }); 28 | it('should return true for all users if resource has "*" as the first permissions', async () => { 29 | const metadataGetterStub = sinon.stub().resolves({permissions: ['*']}); 30 | const action = new AuthorizeActionProvider( 31 | metadataGetterStub, 32 | [], 33 | getRequestContext(), 34 | ).value(); 35 | const result = await action([]); 36 | expect(result).to.be.true(); 37 | }); 38 | it('should return false if user does not have required permissions', async () => { 39 | const metadataGetterStub = sinon.stub().resolves(mockPermissions); 40 | const action = new AuthorizeActionProvider( 41 | metadataGetterStub, 42 | [], 43 | getRequestContext(), 44 | ).value(); 45 | const result = await action([]); 46 | expect(result).to.be.false(); 47 | }); 48 | it('should return true if requested resource is in always allowed list', async () => { 49 | const metadataGetterStub = sinon.stub().resolves(mockPermissions); 50 | const action = new AuthorizeActionProvider( 51 | metadataGetterStub, 52 | ['/always/allowed'], 53 | getRequestContext(), 54 | ).value(); 55 | const mockRequest = givenRequest({ 56 | url: '/always/allowed', 57 | payload: {}, 58 | }); 59 | const result = await action([], mockRequest); 60 | expect(result).to.be.true(); 61 | }); 62 | it('should return true if requested resource has a parent path in always allowed list', async () => { 63 | const metadataGetterStub = sinon.stub().resolves(mockPermissions); 64 | const action = new AuthorizeActionProvider( 65 | metadataGetterStub, 66 | ['/always/allowed'], 67 | getRequestContext(), 68 | ).value(); 69 | const mockRequest = givenRequest({ 70 | url: '/always/allowed/child', 71 | payload: {}, 72 | }); 73 | const result = await action([], mockRequest); 74 | expect(result).to.be.true(); 75 | }); 76 | it('should return false if requested resource is not in always allowed list, and user does not have required permissions', async () => { 77 | const metadataGetterStub = sinon.stub().resolves(mockPermissions); 78 | const action = new AuthorizeActionProvider( 79 | metadataGetterStub, 80 | ['/not/always/allowed'], 81 | getRequestContext(), 82 | ).value(); 83 | const mockRequest = givenRequest({ 84 | url: '/always/allowed', 85 | payload: {}, 86 | }); 87 | const result = await action([], mockRequest); 88 | expect(result).to.be.false(); 89 | }); 90 | it('should return false if resource has no attached metadata', async () => { 91 | const requestContext = getRequestContext(); 92 | requestContext.get.resolves(''); 93 | 94 | const metadataGetterStub = sinon.stub().resolves(); 95 | const action = new AuthorizeActionProvider( 96 | metadataGetterStub, 97 | [], 98 | requestContext, 99 | ).value(); 100 | const result = await action(mockUser.permissions); 101 | expect(result).to.be.false(); 102 | }); 103 | 104 | it('should throw 404 if non existing route is hit', async () => { 105 | const requestContext = getRequestContext(); 106 | requestContext.get.throws(); 107 | const metadataGetterStub = sinon.stub().resolves(); 108 | const action = new AuthorizeActionProvider( 109 | metadataGetterStub, 110 | [], 111 | requestContext, 112 | ).value(); 113 | const result = await action(mockUser.permissions).catch(err => err); 114 | expect(result).be.instanceOf(Error); 115 | expect(result).to.have.property('message').which.eql('API not found !'); 116 | }); 117 | function givenRequest(options?: ShotRequestOptions): Request { 118 | return stubExpressContext(options).request; 119 | } 120 | function getRequestContext(): sinon.SinonStubbedInstance { 121 | return sinon.createStubInstance(Context); 122 | } 123 | }); 124 | -------------------------------------------------------------------------------- /src/__tests__/unit/authorization-metadata.provider.unit.ts: -------------------------------------------------------------------------------- 1 | import {expect} from '@loopback/testlab'; 2 | import {getAuthorizeMetadata} from '../../providers'; 3 | import {AuthorizationMetadata, PermissionObject} from '../../types'; 4 | import {authorize} from '../../decorators'; 5 | import {get} from '@loopback/rest'; 6 | 7 | describe('getAuthorizeMetadata()', function () { 8 | it('should return the authorization metadata when userPermission is provided', () => { 9 | class TestController { 10 | @authorize({permissions: ['default1', 'default2']}) 11 | @get('/') 12 | async testMethod() { 13 | // This is intentional. 14 | } 15 | } 16 | const methodName = 'testMethod'; 17 | const mockUserPermission: PermissionObject = { 18 | TestController: { 19 | testMethod: ['permission1', 'permission2'], 20 | }, 21 | }; 22 | 23 | const authorizationMetadata = getAuthorizeMetadata( 24 | TestController, 25 | methodName, 26 | mockUserPermission, 27 | ); 28 | expect(authorizationMetadata?.permissions).which.eql( 29 | mockUserPermission.TestController[methodName], 30 | ); 31 | }); 32 | 33 | it('should return permissions from metadata if userpermission is not provided', () => { 34 | class TestController { 35 | @authorize({permissions: ['default1', 'default2']}) 36 | @get('/') 37 | async testMethod() { 38 | // This is intentional. 39 | } 40 | } 41 | const methodName = 'testMethod'; 42 | const authorizationMetadata = getAuthorizeMetadata( 43 | TestController, 44 | methodName, 45 | ); 46 | const expectedResult: AuthorizationMetadata = { 47 | permissions: ['default1', 'default2'], 48 | }; 49 | expect(authorizationMetadata?.permissions).which.eql( 50 | expectedResult.permissions, 51 | ); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/__tests__/unit/casbin-authorization-action.provider.unit.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | ShotRequestOptions, 4 | sinon, 5 | stubExpressContext, 6 | } from '@loopback/testlab'; 7 | import * as casbin from 'casbin'; 8 | import {StringAdapter} from 'casbin'; 9 | import {Request} from 'express'; 10 | import {CasbinAuthorizationProvider} from '../../providers'; 11 | import {PermissionKeys} from './data'; 12 | import {model} from './data/mock-model'; 13 | import {policy} from './data/mock-policy'; 14 | import {mockUser} from './data/mock-user'; 15 | 16 | describe('CasbinAuthorizeActionProvider', () => { 17 | describe('With CasbinPolicy true', () => { 18 | it('should return true if user is authorized according to the policy', async () => { 19 | const configGetter = sinon.stub().resolves( 20 | Promise.resolve({ 21 | model: casbin.newModelFromString(model), 22 | policy: new StringAdapter(policy), 23 | }), 24 | ); 25 | const casbinMetadataGetter = sinon.stub().resolves({ 26 | permissions: [PermissionKeys.ViewTODO], 27 | resource: 'todo', 28 | isCasbinPolicy: true, 29 | }); 30 | const action = new CasbinAuthorizationProvider( 31 | casbinMetadataGetter, 32 | configGetter, 33 | [], 34 | ).value(); 35 | 36 | const mockRequest = givenRequest({ 37 | url: '/', 38 | }); 39 | const decision = await action(mockUser, 'todo', mockRequest); 40 | expect(decision).to.be.true(); 41 | }); 42 | 43 | it('should return false if user is not authorized according to the policy', async () => { 44 | const configGetter = sinon.stub().resolves( 45 | Promise.resolve({ 46 | model: casbin.newModelFromString(model), 47 | policy: new StringAdapter(policy), 48 | }), 49 | ); 50 | const casbinMetadataGetter = sinon.stub().resolves({ 51 | permissions: [PermissionKeys.UpdateTODO], 52 | resource: 'todo', 53 | isCasbinPolicy: true, 54 | }); 55 | const action = new CasbinAuthorizationProvider( 56 | casbinMetadataGetter, 57 | configGetter, 58 | [], 59 | ).value(); 60 | 61 | const mockRequest = givenRequest({ 62 | url: '/', 63 | }); 64 | const decision = await action(mockUser, 'todo', mockRequest); 65 | expect(decision).to.be.false(); 66 | }); 67 | 68 | it('should return true if the resource is always allowed, even if user does not have any permissions', async () => { 69 | const configGetter = sinon.stub().resolves( 70 | Promise.resolve({ 71 | model: casbin.newModelFromString(model), 72 | policy: new StringAdapter(policy), 73 | }), 74 | ); 75 | const casbinMetadataGetter = sinon.stub().resolves({ 76 | permissions: [PermissionKeys.UpdateTODO], 77 | resource: 'todo', 78 | isCasbinPolicy: true, 79 | }); 80 | const action = new CasbinAuthorizationProvider( 81 | casbinMetadataGetter, 82 | configGetter, 83 | ['/always/allowed'], 84 | ).value(); 85 | 86 | const mockRequest = givenRequest({ 87 | url: '/always/allowed', 88 | }); 89 | const decision = await action(mockUser, 'todo', mockRequest); 90 | expect(decision).to.be.true(); 91 | }); 92 | 93 | it('should return true if the resource is always allowed, even if resource has no authorization metadata', async () => { 94 | const configGetter = sinon.stub().resolves( 95 | Promise.resolve({ 96 | model: casbin.newModelFromString(model), 97 | policy: new StringAdapter(policy), 98 | }), 99 | ); 100 | const casbinMetadataGetter = sinon.stub().resolves(); 101 | const action = new CasbinAuthorizationProvider( 102 | casbinMetadataGetter, 103 | configGetter, 104 | ['/always/allowed'], 105 | ).value(); 106 | 107 | const mockRequest = givenRequest({ 108 | url: '/always/allowed', 109 | }); 110 | const decision = await action(mockUser, 'todo', mockRequest); 111 | expect(decision).to.be.true(); 112 | }); 113 | 114 | it('should return false for a resource with no metadata', async () => { 115 | const configGetter = sinon.stub().resolves( 116 | Promise.resolve({ 117 | model: casbin.newModelFromString(model), 118 | policy: new StringAdapter(policy), 119 | }), 120 | ); 121 | const casbinMetadataGetter = sinon.stub().resolves(); 122 | const action = new CasbinAuthorizationProvider( 123 | casbinMetadataGetter, 124 | configGetter, 125 | [], 126 | ).value(); 127 | 128 | const mockRequest = givenRequest({ 129 | url: '/', 130 | }); 131 | const decision = await action(mockUser, 'todo', mockRequest); 132 | expect(decision).to.be.false(); 133 | }); 134 | 135 | it('should return true for a resource with "*" as first permission, even when user does not have necessary permissions', async () => { 136 | const configGetter = sinon.stub().resolves( 137 | Promise.resolve({ 138 | model: casbin.newModelFromString(model), 139 | policy: new StringAdapter(policy), 140 | }), 141 | ); 142 | const casbinMetadataGetter = sinon.stub().resolves({ 143 | permissions: ['*'], 144 | resource: 'todo', 145 | isCasbinPolicy: true, 146 | }); 147 | const action = new CasbinAuthorizationProvider( 148 | casbinMetadataGetter, 149 | configGetter, 150 | [], 151 | ).value(); 152 | 153 | const mockRequest = givenRequest({ 154 | url: '/', 155 | }); 156 | const decision = await action(mockUser, 'todo', mockRequest); 157 | expect(decision).to.be.true(); 158 | }); 159 | 160 | it('should throw an error if resource parameter is missing in authorization metadata', async () => { 161 | const configGetter = sinon.stub().resolves( 162 | Promise.resolve({ 163 | model: casbin.newModelFromString(model), 164 | policy: new StringAdapter(policy), 165 | }), 166 | ); 167 | const casbinMetadataGetter = sinon.stub().resolves({ 168 | permissions: [PermissionKeys.UpdateTODO], 169 | isCasbinPolicy: true, 170 | }); 171 | const action = new CasbinAuthorizationProvider( 172 | casbinMetadataGetter, 173 | configGetter, 174 | [], 175 | ).value(); 176 | 177 | const mockRequest = givenRequest({ 178 | url: '/', 179 | }); 180 | const decision = action(mockUser, 'todo', mockRequest); 181 | 182 | await expect(decision).to.be.rejectedWith( 183 | `Resource parameter is missing in the decorator.`, 184 | ); 185 | }); 186 | 187 | it('should throw an error if permissions parameter is missing in authorization metadata', async () => { 188 | const configGetter = sinon.stub().resolves( 189 | Promise.resolve({ 190 | model: casbin.newModelFromString(model), 191 | policy: new StringAdapter(policy), 192 | }), 193 | ); 194 | const casbinMetadataGetter = sinon.stub().resolves({ 195 | resource: 'todo', 196 | isCasbinPolicy: true, 197 | }); 198 | const action = new CasbinAuthorizationProvider( 199 | casbinMetadataGetter, 200 | configGetter, 201 | [], 202 | ).value(); 203 | 204 | const mockRequest = givenRequest({ 205 | url: '/', 206 | }); 207 | const decision = action(mockUser, 'todo', mockRequest); 208 | 209 | await expect(decision).to.be.rejectedWith( 210 | `Permissions are missing in the decorator.`, 211 | ); 212 | }); 213 | 214 | it('should throw an error if user id is missing', async () => { 215 | const configGetter = sinon.stub().resolves( 216 | Promise.resolve({ 217 | model: casbin.newModelFromString(model), 218 | policy: new StringAdapter(policy), 219 | }), 220 | ); 221 | const casbinMetadataGetter = sinon.stub().resolves({ 222 | permissions: [PermissionKeys.UpdateTODO], 223 | resource: 'todo', 224 | isCasbinPolicy: true, 225 | }); 226 | const action = new CasbinAuthorizationProvider( 227 | casbinMetadataGetter, 228 | configGetter, 229 | [], 230 | ).value(); 231 | 232 | const mockRequest = givenRequest({ 233 | url: '/', 234 | }); 235 | const decision = action( 236 | { 237 | ...mockUser, 238 | id: undefined, 239 | }, 240 | 'todo', 241 | mockRequest, 242 | ); 243 | 244 | await expect(decision).to.be.rejectedWith(`User not found.`); 245 | }); 246 | }); 247 | 248 | describe('With Casbin Policy false', () => { 249 | it('should return true if user is authorized according to the policy', async () => { 250 | const configGetter = sinon.stub().resolves( 251 | Promise.resolve({ 252 | model: casbin.newModelFromString(model), 253 | allowedRes: [{resource: 'todo', permission: 'ViewTodo'}], 254 | }), 255 | ); 256 | 257 | const casbinMetadataGetter = sinon.stub().resolves({ 258 | permissions: [PermissionKeys.ViewTODO], 259 | resource: 'todo', 260 | isCasbinPolicy: false, 261 | }); 262 | const action = new CasbinAuthorizationProvider( 263 | casbinMetadataGetter, 264 | configGetter, 265 | [], 266 | ).value(); 267 | 268 | const mockRequest = givenRequest({ 269 | url: '/', 270 | }); 271 | const decision = await action(mockUser, 'todo', mockRequest); 272 | expect(decision).to.be.true(); 273 | }); 274 | 275 | it('should return false if user is not authorized according to the policy', async () => { 276 | const configGetter = sinon.stub().resolves( 277 | Promise.resolve({ 278 | model: casbin.newModelFromString(model), 279 | allowedRes: [{resource: 'todo', permission: 'ViewTodo'}], 280 | }), 281 | ); 282 | 283 | const casbinMetadataGetter = sinon.stub().resolves({ 284 | permissions: [PermissionKeys.UpdateTODO], 285 | resource: 'todo', 286 | isCasbinPolicy: false, 287 | }); 288 | const action = new CasbinAuthorizationProvider( 289 | casbinMetadataGetter, 290 | configGetter, 291 | [], 292 | ).value(); 293 | 294 | const mockRequest = givenRequest({ 295 | url: '/', 296 | }); 297 | const decision = await action(mockUser, 'todo', mockRequest); 298 | expect(decision).to.be.false(); 299 | }); 300 | }); 301 | 302 | function givenRequest(options?: ShotRequestOptions): Request { 303 | return stubExpressContext(options).request; 304 | } 305 | }); 306 | -------------------------------------------------------------------------------- /src/__tests__/unit/data/index.ts: -------------------------------------------------------------------------------- 1 | export * from './permission.enum'; 2 | -------------------------------------------------------------------------------- /src/__tests__/unit/data/mock-model.ts: -------------------------------------------------------------------------------- 1 | export const model = ` 2 | [request_definition] 3 | r = sub, obj, act 4 | 5 | [policy_definition] 6 | p = sub, obj, act 7 | 8 | [policy_effect] 9 | e = some(where (p.eft == allow)) 10 | 11 | [matchers] 12 | m = r.sub == p.sub && r.obj == p.obj && r.act == p.act 13 | `; 14 | -------------------------------------------------------------------------------- /src/__tests__/unit/data/mock-policy.ts: -------------------------------------------------------------------------------- 1 | export const policy = ` 2 | p, u1, todo, ViewTodo, 3 | `; 4 | -------------------------------------------------------------------------------- /src/__tests__/unit/data/mock-user.ts: -------------------------------------------------------------------------------- 1 | import {IAuthUserWithPermissions} from '../../../types'; 2 | 3 | export const mockUser: IAuthUserWithPermissions = { 4 | id: '1', 5 | role: 'Test', 6 | firstName: 'Test', 7 | lastName: 'User', 8 | username: 'test.user', 9 | authClientId: 0, 10 | permissions: ['ViewTodo'], 11 | }; 12 | -------------------------------------------------------------------------------- /src/__tests__/unit/data/permission.enum.ts: -------------------------------------------------------------------------------- 1 | export enum PermissionKeys { 2 | ViewTODO = 'ViewTodo', 3 | UpdateTODO = 'UpdateTodo', 4 | } 5 | -------------------------------------------------------------------------------- /src/component.ts: -------------------------------------------------------------------------------- 1 | import {Binding, Component, inject, ProviderMap} from '@loopback/core'; 2 | 3 | import {AuthorizationBindings} from './keys'; 4 | import {AuthorizeActionProvider} from './providers/authorization-action.provider'; 5 | import {AuthorizationMetadataProvider} from './providers/authorization-metadata.provider'; 6 | import {CasbinAuthorizationProvider} from './providers/casbin-authorization-action.provider'; 7 | import {UserPermissionsProvider} from './providers/user-permissions.provider'; 8 | import {AuthorizationConfig} from './types'; 9 | 10 | export class AuthorizationComponent implements Component { 11 | providers?: ProviderMap; 12 | bindings?: Binding[] = []; 13 | 14 | constructor( 15 | @inject(AuthorizationBindings.CONFIG) 16 | private readonly config?: AuthorizationConfig, 17 | @inject(AuthorizationBindings.PERMISSION, {optional: true}) 18 | private readonly permission?: unknown, 19 | ) { 20 | this.providers = { 21 | [AuthorizationBindings.AUTHORIZE_ACTION.key]: AuthorizeActionProvider, 22 | [AuthorizationBindings.CASBIN_AUTHORIZE_ACTION.key]: 23 | CasbinAuthorizationProvider, 24 | [AuthorizationBindings.METADATA.key]: AuthorizationMetadataProvider, 25 | [AuthorizationBindings.USER_PERMISSIONS.key]: UserPermissionsProvider, 26 | }; 27 | if (!this.permission) { 28 | this.bindings?.push( 29 | Binding.bind(AuthorizationBindings.PERMISSION).to(null), 30 | ); 31 | } 32 | if ( 33 | this.config?.allowAlwaysPaths && 34 | this.config?.allowAlwaysPaths?.length > 0 35 | ) { 36 | this.bindings?.push( 37 | Binding.bind(AuthorizationBindings.PATHS_TO_ALLOW_ALWAYS).to( 38 | this.config.allowAlwaysPaths, 39 | ), 40 | ); 41 | } else { 42 | this.bindings?.push( 43 | Binding.bind(AuthorizationBindings.PATHS_TO_ALLOW_ALWAYS).to([ 44 | '/explorer', 45 | ]), 46 | ); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/controllers/README.md: -------------------------------------------------------------------------------- 1 | # Controllers 2 | 3 | This directory contains source files for the controllers exported by this 4 | extension. 5 | 6 | For more information, see . 7 | -------------------------------------------------------------------------------- /src/decorators/README.md: -------------------------------------------------------------------------------- 1 | # Decorators 2 | 3 | ## Overview 4 | 5 | Decorators provide annotations for class methods and arguments. Decorators use 6 | the form `@decorator` where `decorator` is the name of the function that will be 7 | called at runtime. 8 | 9 | ## Basic Usage 10 | 11 | ### txIdFromHeader 12 | 13 | This simple decorator allows you to annotate a `Controller` method argument. The 14 | decorator will annotate the method argument with the value of the header 15 | `X-Transaction-Id` from the request. 16 | 17 | **Example** 18 | 19 | ```ts 20 | class MyController { 21 | @get('/') 22 | getHandler(@txIdFromHeader() txId: string) { 23 | return `Your transaction id is: ${txId}`; 24 | } 25 | } 26 | ``` 27 | 28 | ## Related Resources 29 | 30 | You can check out the following resource to learn more about decorators and how 31 | they are used in LoopBack Next. 32 | 33 | - [TypeScript Handbook: Decorators](https://www.typescriptlang.org/docs/handbook/decorators.html) 34 | - [Decorators in LoopBack](http://loopback.io/doc/en/lb4/Decorators.html) 35 | -------------------------------------------------------------------------------- /src/decorators/authorize.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MetadataInspector, 3 | MethodDecoratorFactory, 4 | Reflector, 5 | } from '@loopback/core'; 6 | import {AuthorizationMetadata} from '../types'; 7 | import {AUTHORIZATION_METADATA_ACCESSOR} from '../keys'; 8 | import {specPreprocessor} from './spec-preprocessor'; 9 | import {OperationObject} from '@loopback/rest'; 10 | 11 | type OperationMeta = { 12 | verb: string; 13 | path: string; 14 | spec: OperationObject; 15 | }; 16 | 17 | const OAI3KEY_METHODS = 'openapi-v3:methods'; 18 | 19 | export function authorize(metadata: AuthorizationMetadata) { 20 | const authorizedecorator = 21 | MethodDecoratorFactory.createDecorator( 22 | AUTHORIZATION_METADATA_ACCESSOR, 23 | { 24 | permissions: metadata.permissions || [], 25 | resource: metadata.resource ?? '', 26 | isCasbinPolicy: metadata.isCasbinPolicy ?? false, 27 | }, 28 | ); 29 | const authorizationWithMetadata = ( 30 | target: Object, 31 | propertyKey: string, 32 | descriptor: TypedPropertyDescriptor, 33 | ) => { 34 | const meta: OperationMeta | undefined = MetadataInspector.getMethodMetadata( 35 | OAI3KEY_METHODS, 36 | target, 37 | propertyKey, 38 | ); 39 | if (meta) { 40 | meta.spec = specPreprocessor(target, propertyKey, metadata, meta.spec); 41 | Reflector.deleteMetadata(OAI3KEY_METHODS, target, propertyKey); 42 | Reflector.defineMetadata(OAI3KEY_METHODS, meta, target, propertyKey); 43 | authorizedecorator(target, propertyKey, descriptor); 44 | } 45 | }; 46 | 47 | return authorizationWithMetadata; 48 | } 49 | -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authorize.decorator'; 2 | -------------------------------------------------------------------------------- /src/decorators/spec-preprocessor.ts: -------------------------------------------------------------------------------- 1 | import {OperationObject} from 'openapi3-ts'; 2 | import {AuthorizationMetadata} from '../types'; 3 | 4 | const defaultResponse = (ctor: {name: string}, op: string) => ({ 5 | '200': { 6 | description: `Return value of ${ctor.name}.${op}`, 7 | content: {}, 8 | }, 9 | }); 10 | 11 | export const specPreprocessor = ( 12 | target: Object, 13 | propertyKey: string, 14 | authorizations: AuthorizationMetadata, 15 | spec?: OperationObject, 16 | ) => { 17 | let desc = spec?.description ?? ''; 18 | if (authorizations?.permissions && authorizations?.permissions.length > 0) { 19 | authorizations.permissions 20 | .filter((permission: string) => permission.trim() !== '*') 21 | .forEach((permission, i) => { 22 | if (i === 0) { 23 | desc += `\n\n| Permissions |\n| ------- |\n`; 24 | } 25 | desc += `| ${permission} |\n`; 26 | }); 27 | } 28 | if (spec) { 29 | spec.description = desc; 30 | } else { 31 | spec = { 32 | description: desc, 33 | responses: defaultResponse(target.constructor, propertyKey), 34 | } as OperationObject; 35 | } 36 | return spec; 37 | }; 38 | -------------------------------------------------------------------------------- /src/enhancer/spec-description-enhancer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Application, 3 | CoreBindings, 4 | inject, 5 | injectable, 6 | MetadataInspector, 7 | } from '@loopback/core'; 8 | import { 9 | asSpecEnhancer, 10 | OASEnhancer, 11 | OpenAPIObject, 12 | OpenApiSpec, 13 | RestEndpoint, 14 | } from '@loopback/rest'; 15 | 16 | @injectable(asSpecEnhancer) 17 | export class DescSpecEnhancer implements OASEnhancer { 18 | @inject(CoreBindings.APPLICATION_INSTANCE) private readonly app: Application; 19 | name = 'info'; 20 | modifySpec(spec: OpenAPIObject): OpenApiSpec { 21 | for (const controller of this.app.find(`${CoreBindings.CONTROLLERS}.*`)) { 22 | const ctor = controller.valueConstructor; 23 | if (!ctor) { 24 | continue; 25 | } 26 | const endpoints = MetadataInspector.getAllMethodMetadata( 27 | 'openapi-v3:methods', 28 | ctor.prototype, 29 | ); 30 | for (const route in endpoints) { 31 | const routeData = endpoints[route]; 32 | if ( 33 | routeData?.spec?.description && 34 | !spec.paths[routeData.path][routeData.verb].description 35 | ) { 36 | spec.paths[routeData.path][routeData.verb].description = 37 | routeData.spec.description; 38 | } 39 | } 40 | } 41 | return spec; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/error-keys.ts: -------------------------------------------------------------------------------- 1 | export const enum AuthorizeErrorKeys { 2 | NotAllowedAccess = 'NotAllowedAccess', 3 | } 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './component'; 2 | export * from './types'; 3 | export * from './keys'; 4 | export * from './error-keys'; 5 | export * from './decorators'; 6 | export * from './providers'; 7 | -------------------------------------------------------------------------------- /src/keys.ts: -------------------------------------------------------------------------------- 1 | import {BindingKey} from '@loopback/context'; 2 | import {MetadataAccessor} from '@loopback/metadata'; 3 | import { 4 | AuthorizeFn, 5 | AuthorizationMetadata, 6 | UserPermissionsFn, 7 | AuthorizationConfig, 8 | CasbinAuthorizeFn, 9 | CasbinEnforcerConfigGetterFn, 10 | CasbinResourceModifierFn, 11 | PermissionObject, 12 | } from './types'; 13 | 14 | /** 15 | * Binding keys used by this component. 16 | */ 17 | export namespace AuthorizationBindings { 18 | export const AUTHORIZE_ACTION = BindingKey.create( 19 | 'sf.userAuthorization.actions.authorize', 20 | ); 21 | export const PERMISSION = BindingKey.create( 22 | `sf.userAuthorization.authorize.permissions`, 23 | ); 24 | 25 | export const CASBIN_AUTHORIZE_ACTION = BindingKey.create( 26 | 'sf.userAuthorization.actions.casbin.authorize', 27 | ); 28 | 29 | export const METADATA = BindingKey.create( 30 | 'sf.userAuthorization.operationMetadata', 31 | ); 32 | 33 | export const USER_PERMISSIONS = BindingKey.create>( 34 | 'sf.userAuthorization.actions.userPermissions', 35 | ); 36 | 37 | export const CASBIN_ENFORCER_CONFIG_GETTER = 38 | BindingKey.create( 39 | 'sf.userAuthorization.actions.casbin.config', 40 | ); 41 | 42 | export const CASBIN_RESOURCE_MODIFIER_FN = 43 | BindingKey.create( 44 | 'sf.userAuthorization.actions.casbin.resourceModifier', 45 | ); 46 | 47 | export const CONFIG = BindingKey.create( 48 | 'sf.userAuthorization.config', 49 | ); 50 | 51 | export const PATHS_TO_ALLOW_ALWAYS = 'sf.userAuthorization.allowAlways'; 52 | } 53 | 54 | export const AUTHORIZATION_METADATA_ACCESSOR = MetadataAccessor.create< 55 | AuthorizationMetadata, 56 | MethodDecorator 57 | >('sf.userAuthorization.accessor.operationMetadata'); 58 | -------------------------------------------------------------------------------- /src/mixins/README.md: -------------------------------------------------------------------------------- 1 | # Mixins 2 | 3 | This directory contains source files for the mixins exported by this extension. 4 | 5 | ## Overview 6 | 7 | Sometimes it's helpful to write partial classes and then combining them together 8 | to build more powerful classes. This pattern is called Mixins (mixing in partial 9 | classes) and is supported by LoopBack 4. 10 | 11 | LoopBack 4 supports mixins at an `Application` level. Your partial class can 12 | then be mixed into the `Application` class. A mixin class can modify or override 13 | existing methods of the class or add new ones! It is also possible to mixin 14 | multiple classes together as needed. 15 | 16 | ### High level example 17 | 18 | ```ts 19 | class MyApplication extends MyMixinClass(Application) { 20 | // Your code 21 | } 22 | 23 | // Multiple Classes mixed together 24 | class MyApp extends MyMixinClass(MyMixinClass2(Application)) { 25 | // Your code 26 | } 27 | ``` 28 | 29 | ## Getting Started 30 | 31 | For hello-extensions we write a simple Mixin that allows the `Application` class 32 | to bind a `Logger` class from ApplicationOptions, Components, or `.logger()` 33 | method that is mixed in. `Logger` instances are bound to the key 34 | `loggers.${Logger.name}`. Once a Logger has been bound, the user can retrieve it 35 | by using 36 | [Dependency Injection](http://loopback.io/doc/en/lb4/Dependency-injection.html) 37 | and the key for the `Logger`. 38 | 39 | ### What is a Logger? 40 | 41 | > A Logger class is provides a mechanism for logging messages of varying 42 | > priority by providing an implementation for `Logger.info()` & 43 | > `Logger.error()`. An example of a Logger is `console` which has 44 | > `console.log()` and `console.error()`. 45 | 46 | #### An example Logger 47 | 48 | ```ts 49 | class ColorLogger implements Logger { 50 | log(...args: LogArgs) { 51 | console.log('log :', ...args); 52 | } 53 | 54 | error(...args: LogArgs) { 55 | // log in red color 56 | console.log('\x1b[31m error: ', ...args, '\x1b[0m'); 57 | } 58 | } 59 | ``` 60 | 61 | ## LoggerMixin 62 | 63 | A complete & functional implementation can be found in `logger.mixin.ts`. _Here 64 | are some key things to keep in mind when writing your own Mixin_. 65 | 66 | ### constructor() 67 | 68 | A Mixin constructor must take an array of any type as it's argument. This would 69 | represent `ApplicationOptions` for our base class `Application` as well as any 70 | properties we would like for our Mixin. 71 | 72 | It is also important for the constructor to call `super(args)` so `Application` 73 | continues to work as expected. 74 | 75 | ```ts 76 | constructor(...args: any[]) { 77 | super(args); 78 | } 79 | ``` 80 | 81 | ### Binding via `ApplicationOptions` 82 | 83 | As mentioned earlier, since our `args` represents `ApplicationOptions`, we can 84 | make it possible for users to pass in their `Logger` implementations in a 85 | `loggers` array on `ApplicationOptions`. We can then read the array and 86 | automatically bind these for the user. 87 | 88 | #### Example user experience 89 | 90 | ```ts 91 | class MyApp extends LoggerMixin(Application) { 92 | constructor(...args: any[]) { 93 | super(...args); 94 | } 95 | } 96 | 97 | const app = new MyApp({ 98 | loggers: [ColorLogger], 99 | }); 100 | ``` 101 | 102 | #### Example Implementation 103 | 104 | To implement this, we would check `this.options` to see if it has a `loggers` 105 | array and if so, bind it by calling the `.logger()` method. (More on that 106 | below). 107 | 108 | ```ts 109 | if (this.options.loggers) { 110 | for (const logger of this.options.loggers) { 111 | this.logger(logger); 112 | } 113 | } 114 | ``` 115 | 116 | ### Binding via `.logger()` 117 | 118 | As mentioned earlier, we can add a new function to our `Application` class 119 | called `.logger()` into which a user would pass in their `Logger` implementation 120 | so we can bind it to the `loggers.*` key for them. We just add this new method 121 | on our partial Mixin class. 122 | 123 | ```ts 124 | logger(logClass: Logger) { 125 | const loggerKey = `loggers.${logClass.name}`; 126 | this.bind(loggerKey).toClass(logClass); 127 | } 128 | ``` 129 | 130 | ### Binding a `Logger` from a `Component` 131 | 132 | Our base class of `Application` already has a method that binds components. We 133 | can modify this method to continue binding a `Component` as usual but also 134 | binding any `Logger` instances provided by that `Component`. When modifying 135 | behavior of an existing method, we can ensure existing behavior by calling the 136 | `super.method()`. In our case the method is `.component()`. 137 | 138 | ```ts 139 | component(component: Constructor) { 140 | super.component(component); // ensures existing behavior from Application 141 | this.mountComponentLoggers(component); 142 | } 143 | ``` 144 | 145 | We have now modified `.component()` to do it's thing and then call our method 146 | `mountComponentLoggers()`. In this method is where we check for `Logger` 147 | implementations declared by the component in a `loggers` array by retrieving the 148 | instance of the `Component`. Then if `loggers` array exists, we bind the 149 | `Logger` instances as normal (by leveraging our `.logger()` method). 150 | 151 | ```ts 152 | mountComponentLoggers(component: Constructor) { 153 | const componentKey = `components.${component.name}`; 154 | const compInstance = this.getSync(componentKey); 155 | 156 | if (compInstance.loggers) { 157 | for (const logger of compInstance.loggers) { 158 | this.logger(logger); 159 | } 160 | } 161 | } 162 | ``` 163 | 164 | ## Retrieving the Logger instance 165 | 166 | Now that we have bound a Logger to our Application via one of the many ways made 167 | possible by `LoggerMixin`, we need to be able to retrieve it so we can use it. 168 | Let's say we want to use it in a controller. Here's an example to retrieving it 169 | so we can use it. 170 | 171 | ```ts 172 | class MyController { 173 | constructor(@inject('loggers.ColorLogger') protected log: Logger) {} 174 | 175 | helloWorld() { 176 | this.log.log('hello log'); 177 | this.log.error('hello error'); 178 | } 179 | } 180 | ``` 181 | 182 | ## Examples for using LoggerMixin 183 | 184 | ### Using the app's `.logger()` method 185 | 186 | ```ts 187 | class LoggingApplication extends LoggerMixin(Application) { 188 | constructor(...args: any[]) { 189 | super(...args); 190 | this.logger(ColorLogger); 191 | } 192 | } 193 | ``` 194 | 195 | ### Using the app's constructor 196 | 197 | ```ts 198 | class LoggerApplication extends LoggerMixin(Application) { 199 | constructor() { 200 | super({ 201 | loggers: [ColorLogger], 202 | }); 203 | } 204 | } 205 | ``` 206 | 207 | ### Binding a Logger provided by a component 208 | 209 | ```ts 210 | class LoggingComponent implements Component { 211 | loggers: [ColorLogger]; 212 | } 213 | 214 | const app = new LoggingApplication(); 215 | app.component(LoggingComponent); // Logger from MyComponent will be bound to loggers.ColorLogger 216 | ``` 217 | -------------------------------------------------------------------------------- /src/policy.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sourcefuse/loopback4-authorization/847e04d1edf97b04135f97fd14fc070d7985fbfc/src/policy.csv -------------------------------------------------------------------------------- /src/providers/README.md: -------------------------------------------------------------------------------- 1 | # Providers 2 | 3 | This directory contains providers contributing additional bindings, for example 4 | custom sequence actions. 5 | 6 | ## Overview 7 | 8 | A [provider](http://loopback.io/doc/en/lb4/Creating-components.html#providers) 9 | is a class that provides a `value()` function. This function is called `Context` 10 | when another entity requests a value to be injected. 11 | 12 | Here we create a provider for a logging function that can be used as a new 13 | action in a custom [sequence](http://loopback.io/doc/en/lb4/Sequence.html). 14 | 15 | The logger will log the URL, the parsed request parameters, and the result. The 16 | logger is also capable of timing the sequence if you start a timer at the start 17 | of the sequence using `this.logger.startTimer()`. 18 | 19 | ## Basic Usage 20 | 21 | ### TimerProvider 22 | 23 | TimerProvider is automatically bound to your Application's 24 | [Context](http://loopback.io/doc/en/lb4/Context.html) using the LogComponent 25 | which exports this provider with a binding key of `extension-starter.timer`. You 26 | can learn more about components in the 27 | [related resources section](#related-resources). 28 | 29 | This provider makes availble to your application a timer function which given a 30 | start time _(given as an array [seconds, nanoseconds])_ can give you a total 31 | time elapsed since the start in milliseconds. The timer can also start timing if 32 | no start time is given. This is used by LogComponent to allow a user to time a 33 | Sequence. 34 | 35 | _NOTE:_ _You can get the start time in the required format by using 36 | `this.logger.startTimer()`._ 37 | 38 | You can provide your own implementation of the elapsed time function by binding 39 | it to the binding key (accessible via `ExtensionStarterBindings`) as follows: 40 | 41 | ```ts 42 | app.bind(ExtensionStarterBindings.TIMER).to(timerFn); 43 | ``` 44 | 45 | ### LogProvider 46 | 47 | LogProvider can automatically be bound to your Application's Context using the 48 | LogComponent which exports the provider with a binding key of 49 | `extension-starter.actions.log`. 50 | 51 | The key can be accessed by importing `ExtensionStarterBindings` as follows: 52 | 53 | **Example: Binding Keys** 54 | 55 | ```ts 56 | import {ExtensionStarterBindings} from 'HelloExtensions'; 57 | // Key can be accessed as follows now 58 | const key = ExtensionStarterBindings.LOG_ACTION; 59 | ``` 60 | 61 | LogProvider gives us a seuqence action and a `startTimer` function. In order to 62 | use the sequence action, you must define your own sequence as shown below. 63 | 64 | **Example: Sequence** 65 | 66 | ```ts 67 | class LogSequence implements SequenceHandler { 68 | constructor( 69 | @inject(coreSequenceActions.FIND_ROUTE) protected findRoute: FindRoute, 70 | @inject(coreSequenceActions.PARSE_PARAMS) 71 | protected parseParams: ParseParams, 72 | @inject(coreSequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, 73 | @inject(coreSequenceActions.SEND) protected send: Send, 74 | @inject(coreSequenceActions.REJECT) protected reject: Reject, 75 | // We get the logger injected by the LogProvider here 76 | @inject(ExtensionStarterBindings.LOG_ACTION) protected logger: LogFn, 77 | ) {} 78 | 79 | async handle(context: RequestContext) { 80 | const {request, response} = context; 81 | 82 | // We define these variable outside so they can be accessed by logger. 83 | let args: any = []; 84 | let result: any; 85 | 86 | // Optionally start timing the sequence using the timer 87 | // function available via LogFn 88 | const start = this.logger.startTimer(); 89 | 90 | try { 91 | const route = this.findRoute(request); 92 | args = await this.parseParams(request, route); 93 | result = await this.invoke(route, args); 94 | this.send(response, result); 95 | } catch (error) { 96 | result = error; // so we can log the error message in the logger 97 | this.reject(context, error); 98 | } 99 | 100 | // We call the logger function given to us by LogProvider 101 | this.logger(request, args, result, start); 102 | } 103 | } 104 | ``` 105 | 106 | Once a sequence has been written, we can just use that in our Application as 107 | follows: 108 | 109 | **Example: Application** 110 | 111 | ```ts 112 | const app = new Application({ 113 | sequence: LogSequence, 114 | }); 115 | app.component(LogComponent); 116 | 117 | // Now all requests handled by our sequence will be logged. 118 | ``` 119 | 120 | ## Related Resources 121 | 122 | You can check out the following resource to learn more about providers, 123 | components, sequences, and binding keys. 124 | 125 | - [Providers](http://loopback.io/doc/en/lb4/Creating-components.html#providers) 126 | - [Creating Components](http://loopback.io/doc/en/lb4/Creating-components.html) 127 | - [Using Components](http://loopback.io/doc/en/lb4/Using-components.html) 128 | - [Sequence](http://loopback.io/doc/en/lb4/Sequence.html) 129 | - [Binding Keys](http://loopback.io/doc/en/lb4/Decorators.html) 130 | -------------------------------------------------------------------------------- /src/providers/authorization-action.provider.ts: -------------------------------------------------------------------------------- 1 | import {Getter, inject, Provider} from '@loopback/context'; 2 | 3 | import {AuthorizationBindings} from '../keys'; 4 | import {AuthorizationMetadata, AuthorizeFn} from '../types'; 5 | 6 | import {intersection} from 'lodash'; 7 | import {Request} from 'express'; 8 | import {HttpErrors, RestBindings} from '@loopback/rest'; 9 | import {CoreBindings, Context} from '@loopback/core'; 10 | 11 | export class AuthorizeActionProvider implements Provider { 12 | constructor( 13 | @inject.getter(AuthorizationBindings.METADATA) 14 | private readonly getMetadata: Getter, 15 | @inject(AuthorizationBindings.PATHS_TO_ALLOW_ALWAYS) 16 | private readonly allowAlwaysPath: string[], 17 | @inject(RestBindings.Http.CONTEXT) 18 | private readonly requestContext: Context, 19 | ) {} 20 | 21 | value(): AuthorizeFn { 22 | return (response, req) => this.action(response, req); 23 | } 24 | 25 | async action(userPermissions: string[], request?: Request): Promise { 26 | const metadata: AuthorizationMetadata = await this.getMetadata(); 27 | 28 | if (request && this.checkIfAllowedAlways(request)) { 29 | return true; 30 | } 31 | 32 | if (metadata) { 33 | if (metadata.permissions.indexOf('*') === 0) { 34 | // Return immediately with true, if allowed to all 35 | // This is for publicly open routes only 36 | return true; 37 | } 38 | } else { 39 | try { 40 | await this.requestContext.get(CoreBindings.CONTROLLER_METHOD_NAME); 41 | return false; 42 | } catch (error) { 43 | throw new HttpErrors.NotFound('API not found !'); 44 | } 45 | } 46 | 47 | const permissionsToCheck = metadata.permissions; 48 | return intersection(userPermissions, permissionsToCheck).length > 0; 49 | } 50 | 51 | checkIfAllowedAlways(req: Request): boolean { 52 | let allowed = false; 53 | allowed = !!this.allowAlwaysPath.find(path => req.path.indexOf(path) === 0); 54 | return allowed; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/providers/authorization-metadata.provider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Constructor, 3 | inject, 4 | MetadataInspector, 5 | Provider, 6 | } from '@loopback/context'; 7 | import {CoreBindings} from '@loopback/core'; 8 | 9 | import {AUTHORIZATION_METADATA_ACCESSOR, AuthorizationBindings} from '../keys'; 10 | import {AuthorizationMetadata, PermissionObject} from '../types'; 11 | 12 | export class AuthorizationMetadataProvider 13 | implements Provider 14 | { 15 | constructor( 16 | @inject(CoreBindings.CONTROLLER_CLASS, {optional: true}) 17 | private readonly controllerClass: Constructor<{}>, 18 | @inject(CoreBindings.CONTROLLER_METHOD_NAME, {optional: true}) 19 | private readonly methodName: string, 20 | @inject(AuthorizationBindings.PERMISSION) 21 | private permissionObject: PermissionObject, 22 | ) {} 23 | 24 | value(): AuthorizationMetadata | undefined { 25 | if (!this.controllerClass || !this.methodName) return; 26 | return getAuthorizeMetadata( 27 | this.controllerClass, 28 | this.methodName, 29 | this.permissionObject, 30 | ); 31 | } 32 | } 33 | 34 | export function getAuthorizeMetadata( 35 | controllerClass: Constructor<{}>, 36 | methodName: string, 37 | userPermission?: PermissionObject, 38 | ): AuthorizationMetadata | undefined { 39 | const authorizationMetadata = 40 | MetadataInspector.getMethodMetadata( 41 | AUTHORIZATION_METADATA_ACCESSOR, 42 | controllerClass.prototype, 43 | methodName, 44 | ) ?? {permissions: []}; 45 | if (userPermission) { 46 | const methodPermissions = 47 | userPermission?.[controllerClass.name]?.[methodName]; 48 | if (methodPermissions) { 49 | authorizationMetadata.permissions = methodPermissions; 50 | } 51 | } 52 | return authorizationMetadata; 53 | } 54 | -------------------------------------------------------------------------------- /src/providers/casbin-authorization-action.provider.ts: -------------------------------------------------------------------------------- 1 | import {Getter, inject, Provider} from '@loopback/core'; 2 | import {Request} from '@loopback/express'; 3 | import {HttpErrors} from '@loopback/rest'; 4 | import * as casbin from 'casbin'; 5 | import {AuthorizationBindings} from '../keys'; 6 | import { 7 | AuthorizationMetadata, 8 | CasbinAuthorizeFn, 9 | CasbinEnforcerConfigGetterFn, 10 | IAuthUserWithPermissions, 11 | ResourcePermissionObject, 12 | } from '../types'; 13 | 14 | export class CasbinAuthorizationProvider 15 | implements Provider 16 | { 17 | constructor( 18 | @inject.getter(AuthorizationBindings.METADATA) 19 | private readonly getCasbinMetadata: Getter, 20 | @inject(AuthorizationBindings.CASBIN_ENFORCER_CONFIG_GETTER) 21 | private readonly getCasbinEnforcerConfig: CasbinEnforcerConfigGetterFn, 22 | @inject(AuthorizationBindings.PATHS_TO_ALLOW_ALWAYS) 23 | private readonly allowAlwaysPath: string[], 24 | ) {} 25 | 26 | value(): CasbinAuthorizeFn { 27 | return (response, resource, request) => 28 | this.action(response, resource, request); 29 | } 30 | 31 | async action( 32 | user: IAuthUserWithPermissions, 33 | resource: string, 34 | request?: Request, 35 | ): Promise { 36 | let authDecision = false; 37 | try { 38 | // fetch decorator metadata 39 | const metadata: AuthorizationMetadata = await this.getCasbinMetadata(); 40 | 41 | if (request && this.checkIfAllowedAlways(request)) { 42 | return true; 43 | } 44 | 45 | if (metadata?.permissions?.indexOf('*') === 0) { 46 | // Return immediately with true, if allowed to all 47 | // This is for publicly open routes only 48 | return true; 49 | } 50 | 51 | if (!metadata?.resource) { 52 | if (!metadata) { 53 | return false; 54 | } 55 | throw new HttpErrors.Unauthorized( 56 | `Resource parameter is missing in the decorator.`, 57 | ); 58 | } 59 | 60 | if (!user.id) { 61 | throw new HttpErrors.Unauthorized(`User not found.`); 62 | } 63 | const subject = this.getUserName(`${user.id}`); 64 | 65 | const desiredPermissions = this.getDesiredPermissions(metadata); 66 | 67 | // Fetch casbin config by invoking casbin-config-getter-provider 68 | const casbinConfig = await this.getCasbinEnforcerConfig( 69 | user, 70 | metadata.resource, 71 | metadata.isCasbinPolicy, 72 | ); 73 | 74 | let enforcer: casbin.Enforcer; 75 | 76 | // If casbin config policy format is being used, create enforcer 77 | if (metadata.isCasbinPolicy) { 78 | enforcer = await casbin.newEnforcer( 79 | casbinConfig.model, 80 | casbinConfig.policy, 81 | ); 82 | } 83 | // In case casbin policy is coming via provider, use that to initialise enforcer 84 | else if (casbinConfig.allowedRes) { 85 | const policy = this.createCasbinPolicy( 86 | casbinConfig.allowedRes, 87 | subject, 88 | ); 89 | const stringAdapter = new casbin.StringAdapter(policy); 90 | enforcer = new casbin.Enforcer(); 91 | await enforcer.initWithModelAndAdapter( 92 | casbinConfig.model as casbin.Model, 93 | stringAdapter, 94 | ); 95 | } else { 96 | return false; 97 | } 98 | 99 | // Use casbin enforce method to get authorization decision 100 | for (const permission of desiredPermissions) { 101 | const decision = await enforcer.enforce(subject, resource, permission); 102 | authDecision = authDecision || decision; 103 | } 104 | } catch (err) { 105 | throw new HttpErrors.Unauthorized(err.message); 106 | } 107 | 108 | return authDecision; 109 | } 110 | 111 | // Generate the user name according to the naming convention 112 | // in casbin policy 113 | // A user's name would be `u${ id }` 114 | getUserName(id: string): string { 115 | return `u${id}`; 116 | } 117 | 118 | getDesiredPermissions(metadata: AuthorizationMetadata): Array { 119 | if (metadata.permissions && metadata.permissions.length > 0) { 120 | return metadata.permissions; 121 | } else { 122 | throw new HttpErrors.Unauthorized( 123 | `Permissions are missing in the decorator.`, 124 | ); 125 | } 126 | } 127 | 128 | // Create casbin policy for user based on ResourcePermission data provided by extension client 129 | createCasbinPolicy( 130 | resPermObj: ResourcePermissionObject[], 131 | subject: string, 132 | ): string { 133 | let result = ''; 134 | resPermObj.forEach(resPerm => { 135 | const policy = `p, ${subject}, ${resPerm.resource}, ${resPerm.permission} 136 | `; 137 | result += policy; 138 | }); 139 | 140 | return result; 141 | } 142 | 143 | checkIfAllowedAlways(req: Request): boolean { 144 | let allowed = false; 145 | allowed = !!this.allowAlwaysPath.find(path => req.path.indexOf(path) === 0); 146 | return allowed; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/providers/casbin-enforcer-config.provider.ts: -------------------------------------------------------------------------------- 1 | import {Provider} from '@loopback/context'; 2 | 3 | import {HttpErrors} from '@loopback/rest'; 4 | import {CasbinEnforcerConfigGetterFn, IAuthUserWithPermissions} from '../types'; 5 | 6 | export class CasbinEnforcerProvider 7 | implements Provider 8 | { 9 | value(): CasbinEnforcerConfigGetterFn { 10 | return async ( 11 | authUser: IAuthUserWithPermissions, 12 | resource: string, 13 | isCasbinPolicy?: boolean, 14 | ) => { 15 | throw new HttpErrors.NotImplemented( 16 | `CasbinEnforcerConfigGetterFn Provider is not implemented`, 17 | ); 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authorization-metadata.provider'; 2 | export * from './authorization-action.provider'; 3 | export * from './user-permissions.provider'; 4 | export * from './casbin-authorization-action.provider'; 5 | export * from './casbin-enforcer-config.provider'; 6 | -------------------------------------------------------------------------------- /src/providers/user-permissions.provider.ts: -------------------------------------------------------------------------------- 1 | import {Provider} from '@loopback/context'; 2 | 3 | import {UserPermission, UserPermissionsFn} from '../types'; 4 | 5 | export class UserPermissionsProvider 6 | implements Provider> 7 | { 8 | value(): UserPermissionsFn { 9 | return (userPermissions, rolePermissions) => 10 | this.action(userPermissions, rolePermissions); 11 | } 12 | 13 | action( 14 | userPermissions: UserPermission[], 15 | rolePermissions: string[], 16 | ): string[] { 17 | let perms: string[] = []; 18 | // First add all permissions associated with role 19 | perms = perms.concat(rolePermissions); 20 | // Now update permissions based on user permissions 21 | userPermissions.forEach((userPerm: UserPermission) => { 22 | if (userPerm.allowed && perms.indexOf(userPerm.permission) < 0) { 23 | // Add permission if it is not part of role but allowed to user 24 | perms.push(userPerm.permission); 25 | } else if (!userPerm.allowed && perms.indexOf(userPerm.permission) >= 0) { 26 | // Remove permission if it is disallowed for user 27 | perms.splice(perms.indexOf(userPerm.permission), 1); 28 | } else { 29 | //this is intentional 30 | } 31 | }); 32 | return perms; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/release_notes/mymarkdown.ejs: -------------------------------------------------------------------------------- 1 | ## Release [<%= range.split('..')[1] %>](https://github.com/sourcefuse/loopback4-authorization/compare/<%= range %>) <%= new Date().toLocaleDateString('en-us', {year:"numeric", month:"long", day:"numeric"}) 2 | ;%> 3 | Welcome to the <%= new Date().toLocaleDateString('en-us', {year:"numeric", month:"long", day:"numeric"});%> release of loopback4-authorization. There are many updates in this version that we hope you will like, the key highlights include: 4 | <% commits.forEach(function (commit) { %> 5 | - [<%= commit.issueTitle %>](https://github.com/sourcefuse/loopback4-authorization/issues/<%= commit.issueno %>) :- [<%= commit.title %>](https://github.com/sourcefuse/loopback4-authorization/commit/<%= commit.sha1%>) was commited on <%= commit.committerDate %> by [<%= commit.authorName %>](mailto:<%= commit.authorEmail %>) 6 | <% commit.messageLines.forEach(function (message) { %> 7 | - <%= message %> 8 | <% }) %> 9 | <% }) %> 10 | Clink on the above links to understand the changes in detail. 11 | ___ 12 | 13 | -------------------------------------------------------------------------------- /src/release_notes/post-processing.js: -------------------------------------------------------------------------------- 1 | const https = require('node:https'); 2 | const jsdom = require('jsdom'); 3 | module.exports = async function (data, callback) { 4 | const rewritten = []; 5 | for (const commit of data.commits) { 6 | if (commit.title.indexOf('chore(release)') !== -1) { 7 | continue; 8 | } 9 | 10 | const commitTitle = commit.title; 11 | commit.title = commitTitle.substring(0, commitTitle.indexOf('#') - 1); 12 | 13 | commit.messageLines = commit.messageLines.filter(message => { 14 | if (message.indexOf('efs/') === -1) return message; 15 | }); 16 | 17 | commit.messageLines.forEach(message => { 18 | commit.issueno = message.includes('GH-') 19 | ? message.replace('GH-', '').trim() 20 | : null; 21 | }); 22 | 23 | const issueDesc = await getIssueDesc(commit.issueno).then(res => { 24 | return res; 25 | }); 26 | commit.issueTitle = issueDesc; 27 | 28 | commit.committerDate = new Date(commit.committerDate).toLocaleDateString( 29 | 'en-us', 30 | { 31 | year: 'numeric', 32 | month: 'long', 33 | day: 'numeric', 34 | }, 35 | ); 36 | rewritten.push(commit); 37 | } 38 | callback({ 39 | commits: rewritten.filter(Boolean), 40 | range: data.range, 41 | }); 42 | }; 43 | 44 | function getIssueDesc(issueNo) { 45 | return new Promise((resolve, reject) => { 46 | let result = ''; 47 | const req = https.get( 48 | `https://github.com/sourcefuse/loopback4-authorization/issues/${encodeURIComponent( 49 | issueNo, 50 | )}`, 51 | res => { 52 | res.setEncoding('utf8'); 53 | res.on('data', chunk => { 54 | result = result + chunk; 55 | }); 56 | res.on('end', () => { 57 | const {JSDOM} = jsdom; 58 | const dom = new JSDOM(result); 59 | const title = dom.window.document.getElementsByClassName( 60 | 'js-issue-title markdown-title', 61 | ); 62 | let issueTitle = ''; 63 | for (const ele of title) { 64 | if (ele.nodeName === 'BDI') { 65 | issueTitle = ele.innerHTML; 66 | } 67 | } 68 | resolve(issueTitle); 69 | }); 70 | }, 71 | ); 72 | req.on('error', e => { 73 | reject(e); 74 | }); 75 | req.end(); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /src/release_notes/release-notes.js: -------------------------------------------------------------------------------- 1 | const releaseNotes = require('git-release-notes'); 2 | const simpleGit = require('simple-git/promise'); 3 | const path = require('path'); 4 | const {readFile, writeFile, ensureFile} = require('fs-extra'); 5 | 6 | async function generateReleaseNotes() { 7 | try { 8 | const OPTIONS = { 9 | branch: 'master', 10 | s: './post-processing.js', 11 | }; 12 | const RANGE = await getRange(); 13 | const TEMPLATE = './mymarkdown.ejs'; 14 | 15 | const changelog = await releaseNotes(OPTIONS, RANGE, TEMPLATE); 16 | 17 | const changelogPath = path.resolve(__dirname, '../..', 'CHANGELOG.md'); 18 | await ensureFile(changelogPath); 19 | const currentFile = (await readFile(changelogPath)).toString().trim(); 20 | if (currentFile) { 21 | console.log('Update %s', changelogPath); 22 | } else { 23 | console.log('Create %s', changelogPath); 24 | } 25 | 26 | await writeFile(changelogPath, changelog); 27 | await writeFile(changelogPath, currentFile, {flag: 'a+'}); 28 | await addAndCommit().then(() => { 29 | console.log('Changelog has been updated'); 30 | }); 31 | } catch (ex) { 32 | console.error(ex); 33 | process.exit(1); 34 | } 35 | } 36 | 37 | async function getRange() { 38 | const git = simpleGit(); 39 | const tags = (await git.tag({'--sort': 'committerdate'})).split('\n'); 40 | tags.pop(); 41 | 42 | const startTag = tags.slice(-2)[0]; 43 | const endTag = tags.slice(-1)[0]; 44 | return `${startTag}..${endTag}`; 45 | } 46 | 47 | async function addAndCommit() { 48 | const git = simpleGit(); 49 | await git.add(['../../CHANGELOG.md']); 50 | await git.commit('chore(release): changelog file', { 51 | '--no-verify': null, 52 | }); 53 | await git.push('origin', 'master'); 54 | } 55 | 56 | generateReleaseNotes().catch(ex => { 57 | console.error(ex); 58 | process.exit(1); 59 | }); 60 | -------------------------------------------------------------------------------- /src/repositories/README.md: -------------------------------------------------------------------------------- 1 | # Repositories 2 | 3 | This directory contains code for repositories provided by this extension. 4 | 5 | For more information, see . 6 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {Request} from '@loopback/express'; 2 | import {FileAdapter, Model} from 'casbin'; 3 | 4 | import PostgresAdapter from 'casbin-pg-adapter'; 5 | 6 | /** 7 | * Authorize action method type 8 | * 9 | * @param userPermissions - Array of permission keys granted to the user 10 | * This is actually a union of permissions picked up based on role 11 | * attached to the user and allowed permissions at specific user level 12 | */ 13 | export type AuthorizeFn = ( 14 | userPermissions: string[], 15 | request?: Request, 16 | ) => Promise; 17 | 18 | /** 19 | * Casbin authorize action method type 20 | * @param user - User object corresponding to the logged in user 21 | * @param resVal - value of the resource for which authorisation is being sought 22 | * 23 | */ 24 | export type CasbinAuthorizeFn = ( 25 | user: IAuthUserWithPermissions, 26 | resVal: string, 27 | request: Request, 28 | ) => Promise; 29 | 30 | export type PermissionObject = { 31 | [controller: string]: { 32 | [method: string]: string[]; 33 | }; 34 | }; 35 | /** 36 | * Authorization metadata interface for the method decorator 37 | */ 38 | export interface AuthorizationMetadata { 39 | // Array of permissions required at the method level. 40 | // User need to have at least one of these to access the API method. 41 | permissions: string[]; 42 | 43 | // Name of resource for which authorisation is being sought 44 | resource?: string; 45 | 46 | /** 47 | * Boolean flag to determine whether we are using casbin policy format or not 48 | * isCasbinPolicy = true, when we are providing casbin format policy doc in application 49 | * isCasbinPolicy = false, when we are implementing provider in app to give casbin policy 50 | */ 51 | isCasbinPolicy?: boolean; 52 | } 53 | 54 | /** 55 | * Authorization config type for providing config to the component 56 | */ 57 | export interface AuthorizationConfig { 58 | /** 59 | * Specify paths to always allow. No permissions check needed. 60 | */ 61 | allowAlwaysPaths: string[]; 62 | } 63 | 64 | /** 65 | * Permissions interface to be implemented by models 66 | */ 67 | export interface Permissions { 68 | permissions: T[]; 69 | } 70 | 71 | /** 72 | * Override permissions at user level 73 | */ 74 | export interface UserPermissionsOverride { 75 | permissions: UserPermission[]; 76 | } 77 | 78 | /** 79 | * User Permission model 80 | * used for explicit allow/deny any permission at user level 81 | */ 82 | export interface UserPermission { 83 | permission: T; 84 | allowed: boolean; 85 | } 86 | 87 | /** 88 | * User permissions manipulation method type. 89 | * 90 | * This is where we can add our business logic to read and 91 | * union permissions associated to user via role with 92 | * those associated directly to the user. 93 | * 94 | */ 95 | export type UserPermissionsFn = ( 96 | userPermissions: UserPermission[], 97 | rolePermissions: T[], 98 | ) => T[]; 99 | 100 | /** 101 | * Casbin enforcer getter method type 102 | * 103 | * This method provides the Casbin config 104 | * required to initialise a Casbin enforcer 105 | */ 106 | export type CasbinEnforcerConfigGetterFn = ( 107 | authUser: IAuthUserWithPermissions, 108 | resource: string, 109 | isCasbinPolicy?: boolean, 110 | ) => Promise; 111 | 112 | /** 113 | * Casbin resource value modifier method type 114 | * 115 | * This method can help modify the resource value 116 | * for integration with casbin, as per business logic 117 | */ 118 | export type CasbinResourceModifierFn = ( 119 | pathParams: string[], 120 | req: Request, 121 | ) => Promise; 122 | 123 | /** 124 | * Casbin config object 125 | */ 126 | export interface CasbinConfig { 127 | model: string | Model; 128 | policy?: string | PostgresAdapter | FileAdapter; 129 | allowedRes?: ResourcePermissionObject[]; 130 | } 131 | 132 | export interface ResourcePermissionObject { 133 | resource: string; 134 | permission: string; 135 | } 136 | 137 | export interface IAuthUser { 138 | id?: number | string; 139 | username: string; 140 | password?: string; 141 | } 142 | 143 | export interface IUserPrefs { 144 | locale?: string; 145 | } 146 | 147 | export interface IAuthUserWithPermissions extends IAuthUser { 148 | id?: string; 149 | identifier?: ID; 150 | permissions: string[]; 151 | authClientId: number; 152 | userPreferences?: IUserPrefs; 153 | email?: string; 154 | role: string; 155 | firstName: string; 156 | lastName: string; 157 | middleName?: string; 158 | allowedResources?: string[]; 159 | } 160 | export interface IUserResource { 161 | resourceName: string; 162 | resourceValue: ResourceValueType; 163 | allowed: boolean; 164 | } 165 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "@loopback/build/config/tsconfig.common.json", 4 | "compilerOptions": { 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "experimentalDecorators": true 8 | }, 9 | "include": ["src", ".eslintrc.js"] 10 | } 11 | --------------------------------------------------------------------------------