Add the permission package

This commit is contained in:
Convly 2022-07-21 10:39:53 +02:00
parent 6571302986
commit ad154e74e3
14 changed files with 839 additions and 0 deletions

View File

@ -0,0 +1,16 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[{package.json,*.yml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

View File

@ -0,0 +1,100 @@
############################
# OS X
############################
.DS_Store
.AppleDouble
.LSOverride
Icon
.Spotlight-V100
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
############################
# Packages
############################
*.7z
*.csv
*.dat
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
*.com
*.class
*.dll
*.exe
*.o
*.seed
*.so
*.swo
*.swp
*.swn
*.swm
*.out
*.pid
############################
# Logs and databases
############################
.tmp
*.log
*.sql
*.sqlite
############################
# Misc.
############################
*#
.editorconfig
.idea
nbproject
############################
# Node.js
############################
lib-cov
lcov.info
pids
logs
results
build
node_modules
.node_history
############################
# Tests
############################
test
tests
__tests__
jest.config.js
testApp
coverage

View File

@ -0,0 +1,22 @@
Copyright (c) 2015-present Strapi Solutions SAS
Portions of the Strapi software are licensed as follows:
* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE".
* All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below.
MIT Expat License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,143 @@
<p align="center">
<a href="https://strapi.io">
<img src="https://strapi.io/assets/strapi-logo-dark.svg" width="318px" alt="Strapi logo" />
</a>
</p>
<h3 align="center">API creation made simple, secure and fast.</h3>
<p align="center">The most advanced open-source headless CMS to build powerful APIs with no effort.</p>
<p align="center"><a href="https://strapi.io/demo">Try live demo</a></p>
<br />
<p align="center">
<a href="https://www.npmjs.org/package/@strapi/strapi">
<img src="https://img.shields.io/npm/v/@strapi/strapi/latest.svg" alt="NPM Version" />
</a>
<a href="https://github.com/strapi/strapi/actions/workflows/tests.yml">
<img src="https://github.com/strapi/strapi/actions/workflows/tests.yml/badge.svg?branch=master" alt="Tests" />
</a>
<a href="https://discord.strapi.io">
<img src="https://img.shields.io/discord/811989166782021633?label=Discord" alt="Strapi on Discord" />
</a>
</p>
<br>
<p align="center">
<a href="https://strapi.io">
<img src="https://raw.githubusercontent.com/strapi/strapi/0bcebf77b37182fe021cb59cc19be8f5db4a18ac/public/assets/administration_panel.png" alt="Administration panel" />
</a>
</p>
<br>
Strapi is a free and open-source headless CMS delivering your content anywhere you need.
- **Keep control over your data**. With Strapi, you know where your data is stored, and you keep full control at all times.
- **Self-hosted**. You can host and scale Strapi projects the way you want. You can choose any hosting platform you want: AWS, Render, Netlify, Heroku, a VPS, or a dedicated server. You can scale as you grow, 100% independent.
- **Database agnostic**. Strapi works with SQL databases. You can choose the database you prefer: PostgreSQL, MySQL, MariaDB, and SQLite.
- **Customizable**. You can quickly build your logic by fully customizing APIs, routes, or plugins to fit your needs perfectly.
## Getting Started
<a href="https://docs.strapi.io/developer-docs/latest/getting-started/quick-start.html" target="_blank">Read the Getting Started tutorial</a> or follow the steps below:
### ⏳ Installation
Install Strapi with this **Quickstart** command to create a Strapi project instantly:
- (Use **yarn** to install the Strapi project (recommended). [Install yarn with these docs](https://yarnpkg.com/lang/en/docs/install/).)
```bash
yarn create strapi-app my-project --quickstart
```
**or**
- (Use npm/npx to install the Strapi project.)
```bash
npx create-strapi-app my-project --quickstart
```
This command generates a brand new project with the default features (authentication, permissions, content management, content type builder & file upload). The **Quickstart** command installs Strapi using a **SQLite** database which is used for prototyping in development.
Enjoy 🎉
### 🖐 Requirements
Complete installation requirements can be found in the documentation under <a href="https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/deployment.html">Installation Requirements</a>.
**Supported operating systems**:
- Ubuntu LTS/Debian 9.x
- CentOS/RHEL 8
- macOS Mojave
- Windows 10
- Docker - [Docker-Repo](https://github.com/strapi/strapi-docker)
(Please note that Strapi may work on other operating systems, but these are not tested nor officially supported at this time.)
**Node:**
- NodeJS >= 12 <= 16
- NPM >= 6.x
**Database:**
- MySQL >= 5.7.8
- MariaDB >= 10.2.7
- PostgreSQL >= 10
- SQLite >= 3
**We recommend always using the latest version of Strapi to start your new projects**.
## Features
- **Modern Admin Panel:** Elegant, entirely customizable and a fully extensible admin panel.
- **Secure by default:** Reusable policies, CORS, CSP, P3P, Xframe, XSS, and more.
- **Plugins Oriented:** Install the auth system, content management, custom plugins, and more, in seconds.
- **Blazing Fast:** Built on top of Node.js, Strapi delivers amazing performance.
- **Front-end Agnostic:** Use any front-end framework (React, Vue, Angular, etc.), mobile apps or even IoT.
- **Powerful CLI:** Scaffold projects and APIs on the fly.
- **SQL databases:** Works with PostgreSQL, MySQL, MariaDB, and SQLite.
**[See more on our website](https://strapi.io/overview)**.
## Contributing
Please read our [Contributing Guide](./CONTRIBUTING.md) before submitting a Pull Request to the project.
## Community support
For general help using Strapi, please refer to [the official Strapi documentation](https://docs.strapi.io). For additional help, you can use one of these channels to ask a question:
- [Discord](https://discord.strapi.io) (For live discussion with the Community and Strapi team)
- [GitHub](https://github.com/strapi/strapi) (Bug reports, Contributions)
- [Community Forum](https://forum.strapi.io) (Questions and Discussions)
- [Roadmap & Feature Requests](https://feedback.strapi.io/)
- [Twitter](https://twitter.com/strapijs) (Get the news fast)
- [Facebook](https://www.facebook.com/Strapi-616063331867161)
- [YouTube Channel](https://www.youtube.com/strapi) (Learn from Video Tutorials)
## Migration
Follow our [migration guides](https://docs.strapi.io/developer-docs/latest/update-migration-guides/migration-guides.html) on the documentation to keep your projects up-to-date.
## Roadmap
Check out our [roadmap](https://feedback.strapi.io/) to get informed of the latest features released and the upcoming ones. You may also give us insights and vote for a specific feature.
## Documentation
See our dedicated [repository](https://github.com/strapi/documentation) for the Strapi documentation, or view our documentation live:
- [Developer docs](https://docs.strapi.io/developer-docs/latest/getting-started/introduction.html)
- [User guide](https://docs.strapi.io/user-docs/latest/getting-started/introduction.html)
## Try live demo
See for yourself what's under the hood by getting access to a [hosted Strapi project](https://strapi.io/demo) with sample data.
## License
See the [LICENSE](./LICENSE) file for licensing information.

48
packages/core/permissions/index.d.ts vendored Normal file
View File

@ -0,0 +1,48 @@
import { Ability } from '@casl/ability';
import { hooks, providerFactory } from '@strapi/utils';
type Provider = ReturnType<typeof providerFactory>;
interface Permission {
action: string;
subject?: string | object;
properties?: object;
conditions?: string[];
}
interface Action {
name: string;
section: string;
}
interface Condition {
name: string;
handler(): boolean | object;
}
type StrapiHook<
T extends keyof Pick<
typeof hooks,
'createAsyncParallelHook' | 'createAsyncSeriesHook' | 'createAsyncSeriesWaterfallHook'
>
> = ReturnType<typeof hooks[T]>;
interface EngineHooks {
willEvaluatePermission: StrapiHook<'createAsyncSeriesHook'>;
willRegisterPermission: StrapiHook<'createAsyncSeriesHook'>;
}
interface ActionProvider<T extends Action = Action> extends Provider {}
interface ConditionProvider<T extends Condition = Condition> extends Provider {}
interface PermissionEngine {
hooks: EngineHooks;
generateAbility(permissions: Permission[], options?: object): Ability;
}
interface PermissionEngineParams {
providers: { action: ActionProvider; condition: ConditionProvider };
abilityBuilderFactory(): { can: Function; build: Function };
}

View File

@ -0,0 +1,10 @@
'use strict';
const baseConfig = require('../../../jest.base-config');
const pkg = require('./package');
module.exports = {
...baseConfig,
displayName: (pkg.strapi && pkg.strapi.name) || pkg.name,
roots: [__dirname],
};

View File

@ -0,0 +1,7 @@
'use strict';
const permission = require('./permission');
module.exports = {
permission,
};

View File

@ -0,0 +1,68 @@
'use strict';
const _ = require('lodash/fp');
const PERMISSION_FIELDS = ['action', 'subject', 'properties', 'conditions'];
const sanitizePermissionFields = _.pick(PERMISSION_FIELDS);
/**
* @typedef {import('../../../index').Permission} Permission
*/
/**
* Creates a permission with default values for optional properties
*
* @return {Pick<Permission, 'conditions' | 'properties' | 'subject'>}
*/
const getDefaultPermission = () => ({
conditions: [],
properties: {},
subject: null,
});
/**
* Create a new permission based on given attributes
*
* @param {object} attributes
*
* @return {Permission}
*/
const create = _.pipe(_.pick(PERMISSION_FIELDS), _.merge(getDefaultPermission()));
/**
* Add a condition to a permission
*
* @param {string} condition The condition to add
* @param {Permission} permission The permission on which we want to add the condition
*
* @return {Permission}
*/
const addCondition = _.curry((condition, permission) => {
const { conditions } = permission;
const newConditions = Array.isArray(conditions)
? _.uniq(conditions.concat(condition))
: [condition];
return _.set('conditions', newConditions, permission);
});
/**
* Gets a property or a part of a property from a permission.
*
* @function
*
* @param {string} property - The property to get
* @param {Permission} permission - The permission on which we want to access the property
*
* @return {Permission}
*/
const getProperty = _.curry((property, permission) => _.get(`properties.${property}`, permission));
module.exports = {
create,
sanitizePermissionFields,
addCondition,
getProperty,
};

View File

@ -0,0 +1,57 @@
'use strict';
const sift = require('sift');
const { AbilityBuilder, Ability } = require('@casl/ability');
const { pick, isNil, isObject } = require('lodash/fp');
const allowedOperations = [
'$or',
'$and',
'$eq',
'$ne',
'$in',
'$nin',
'$lt',
'$lte',
'$gt',
'$gte',
'$exists',
'$elemMatch',
];
const operations = pick(allowedOperations, sift);
const conditionsMatcher = conditions => {
return sift.createQueryTester(conditions, { operations });
};
/**
* Casl Ability Builder.
*/
const caslAbilityBuilder = () => {
const { can, build, ...rest } = new AbilityBuilder(Ability);
return {
can(permission) {
const { action, subject, properties = {}, condition } = permission;
const { fields } = properties;
return can(
action,
isNil(subject) ? 'all' : subject,
fields,
isObject(condition) ? condition : undefined
);
},
build() {
return build({ conditionsMatcher });
},
...rest,
};
};
module.exports = {
caslAbilityBuilder,
};

View File

@ -0,0 +1,7 @@
'use strict';
const { caslAbilityBuilder } = require('./casl-ability');
module.exports = {
caslAbilityBuilder,
};

View File

@ -0,0 +1,106 @@
'use strict';
const { cloneDeep, has } = require('lodash/fp');
const { hooks } = require('@strapi/utils');
const domain = require('../domain');
/**
* Create a hook map used by the permission Engine
*/
const createEngineHooks = () => ({
'before-format::validate.permission': hooks.createAsyncBailHook(),
'format.permission': hooks.createAsyncSeriesWaterfallHook(),
'post-format::validate.permission': hooks.createAsyncBailHook(),
'before-evaluate.permission': hooks.createAsyncSeriesHook(),
'before-register.permission': hooks.createAsyncSeriesHook(),
});
/**
* Create a context from a domain {@link Permission} used by the validate hooks
* @param {Permission} permission
* @return {{ readonly permission: Permission }}
*/
const createValidateContext = permission => ({
get permission() {
return cloneDeep(permission);
},
});
/**
* Create a context from a domain {@link Permission} used by the format hook
* @param {Permission} permission
* @return {{ readonly permission: Permission }}
*/
const createFormatContext = permission => ({
get permission() {
return cloneDeep(permission);
},
});
/**
* Create a context from a domain {@link Permission} used by the before valuate hook
* @param {Permission} permission
* @return {{readonly permission: Permission, addCondition(string): this}}
*/
const createBeforeEvaluateContext = permission => ({
get permission() {
return cloneDeep(permission);
},
addCondition(condition) {
Object.assign(permission, domain.permission.addCondition(condition, permission));
return this;
},
});
/**
* Create a context from a casl Permission & some options
* @param caslPermission
* @param {object} options
* @param {Permission} options.permission
* @param {object} options.user
*/
const createWillRegisterContext = ({ permission, options }) => ({
...options,
get permission() {
return cloneDeep(permission);
},
condition: {
and(rawConditionObject) {
if (!permission.condition) {
Object.assign(permission, { condition: { $and: [] } });
}
permission.condition.$and.push(rawConditionObject);
return this;
},
or(rawConditionObject) {
if (!permission.condition) {
Object.assign(permission, { condition: { $and: [] } });
}
const orClause = permission.condition.$and.find(has('$or'));
if (orClause) {
orClause.$or.push(rawConditionObject);
} else {
permission.condition.$and.push({ $or: [rawConditionObject] });
}
return this;
},
},
});
module.exports = {
createEngineHooks,
createValidateContext,
createFormatContext,
createBeforeEvaluateContext,
createWillRegisterContext,
};

View File

@ -0,0 +1,210 @@
'use strict';
const _ = require('lodash/fp');
const abilities = require('./abilities');
const {
createEngineHooks,
createWillRegisterContext,
createBeforeEvaluateContext,
createFormatContext,
createValidateContext,
} = require('./hooks');
/**
* @typedef {import("../..").PermissionEngine} PermissionEngine
* @typedef {import("../..").ActionProvider} ActionProvider
* @typedef {import("../..").ConditionProvider} ConditionProvider
* @typedef {import("../..").PermissionEngineParams} PermissionEngineParams
* @typedef {import("../..").Permission} Permission
*/
/**
* Create a default state object for the engine
*/
const createEngineState = () => {
const hooks = createEngineHooks();
return { hooks };
};
module.exports = {
abilities,
/**
* Create a new instance of a permission engine
*
* @param {PermissionEngineParams} params
*
* @return {PermissionEngine}
*/
new(params) {
const { providers, abilityBuilderFactory = abilities.caslAbilityBuilder } = params;
const state = createEngineState();
const runValidationHook = async (hook, context) => state.hooks[hook].call(context);
/**
* Evaluate a permission using local and registered behaviors (using hooks).
* Validate, format (add condition, etc...), evaluate (evaluate conditions) and register a permission
*
* @param {object} params
* @param {object} params.options
* @param {Function} params.register
* @param {Permission} params.permission
*/
const evaluate = async params => {
const { options, register, permission } = params;
const preFormatValidation = await runValidationHook(
'before-format::validate.permission',
createBeforeEvaluateContext(permission)
);
if (preFormatValidation === false) {
return;
}
await state.hooks['format.permission'].call(createFormatContext(permission));
const postFormatValidation = await runValidationHook(
'post-format::validate.permission',
createValidateContext(permission)
);
if (postFormatValidation === false) {
return;
}
await state.hooks['before-evaluate.permission'].call(createBeforeEvaluateContext(permission));
const { action, subject, properties, conditions = [] } = permission;
if (conditions.length === 0) {
return register({ action, subject, properties });
}
const resolveConditions = _.map(providers.condition.get);
const removeInvalidConditions = _.filter(condition => _.isFunction(condition.handler));
const evaluateConditions = conditions => {
return Promise.all(
conditions.map(async condition => ({
condition,
result: await condition.handler(
_.merge(options, { permission: _.cloneDeep(permission) })
),
}))
);
};
const removeInvalidResults = _.filter(
({ result }) => _.isBoolean(result) || _.isObject(result)
);
const evaluatedConditions = await Promise.resolve(conditions)
.then(resolveConditions)
.then(removeInvalidConditions)
.then(evaluateConditions)
.then(removeInvalidResults);
const resultPropEq = _.propEq('result');
const pickResults = _.map(_.prop('result'));
if (evaluatedConditions.every(resultPropEq(true))) {
return;
}
if (_.isEmpty(evaluatedConditions) || evaluatedConditions.some(resultPropEq(false))) {
return register({ action, subject, properties });
}
const results = pickResults(evaluatedConditions).filter(_.isObject);
if (_.isEmpty(results)) {
return register({ action, subject, properties });
}
return register({
action,
subject,
properties,
condition: { $and: [{ $or: results }] },
});
};
/**
* Create a register function that wraps a `can` function
* used to register a permission in the ability builder
*
* @param {Function} can
* @param {object} options
*
* @return {Function}
*/
const createRegisterFunction = (can, options) => {
return async permission => {
const hookContext = createWillRegisterContext({ options, permission });
await state.hooks['before-register.permission'].call(hookContext);
return can(permission);
};
};
return {
get hooks() {
return state.hooks;
},
/**
* Register a new handler for a given hook
*
* @param {string} hook
* @param {Function} handler
*
* @return {this}
*/
on(hook, handler) {
const validHooks = Object.keys(state.hooks);
const isValidHook = validHooks.includes(hook);
if (!isValidHook) {
throw new Error(
`Invalid hook supplied when trying to register an handler to the permission engine. Got "${hook}" but expected one of ${validHooks.join(
', '
)}`
);
}
state.hooks[hook].register(handler);
return this;
},
/**
* Generate an ability based on the instance's
* ability builder and the given permissions
*
* @param {Permission[]} permissions
* @param {object} [options]
*
* @return {object}
*/
async generateAbility(permissions, options = {}) {
const { can, build } = abilityBuilderFactory();
for (const permission of permissions) {
const register = createRegisterFunction(can, options);
await evaluate({ permission, options, register });
}
return build();
},
};
},
};

View File

@ -0,0 +1,9 @@
'use strict';
const domain = require('./domain');
const engine = require('./engine');
module.exports = {
domain,
engine,
};

View File

@ -0,0 +1,36 @@
{
"name": "@strapi/permissions",
"version": "4.2.2",
"description": "Strapi's permission layer.",
"repository": {
"type": "git",
"url": "git://github.com/strapi/strapi.git"
},
"license": "SEE LICENSE IN LICENSE",
"author": {
"name": "Strapi Solutions SAS",
"email": "hi@strapi.io",
"url": "https://strapi.io"
},
"maintainers": [
{
"name": "Strapi Solutions SAS",
"email": "hi@strapi.io",
"url": "https://strapi.io"
}
],
"main": "./lib/index.js",
"scripts": {
"test:unit": "jest --verbose"
},
"dependencies": {
"@strapi/utils": "4.2.2",
"lodash": "4.17.21",
"@casl/ability": "5.4.4",
"sift": "16.0.0"
},
"engines": {
"node": ">=14.19.1 <=16.x.x",
"npm": ">=6.0.0"
}
}