From bd285e0ba93aabdbca6dab97363f6d3b598d0b65 Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Mon, 24 Jan 2022 14:10:35 +0800 Subject: [PATCH] Plugin acl (#166) * feat: getRepository * getRepository return type * export action * add: acl * feat: setResourceAction * feat: action alias * chore: code struct * feat: removeResourceAction * chore: file name * ignorecase * remove ACL * feat: ACL * feat: role toJSON * using emit * chore: test * feat: plugin-acl * feat: acl with predicate * grant universal action test * grant action test * update resource action test * revoke resource action * usingActionsConfig switch * plugin-ui-schema-storage * remove global acl instance * fix: collection manager with sqlite * add own action listener * add acl middleware * add acl allowConfigure strategy option * add plugin-acl allowConfigure * change acl resourceName * add acl middleware merge params * bugfix * append fields on acl action params * acl middleware parse template * fix: collection-manager migrate Co-authored-by: chenos --- packages/acl/src/__tests__/acl.test.ts | 105 +++++- packages/acl/src/acl-available-strategy.ts | 59 +++- packages/acl/src/acl-resource.ts | 5 +- packages/acl/src/acl.ts | 136 ++++++-- packages/database/src/repository.ts | 4 + .../database/src/transaction-decorator.ts | 1 + packages/plugin-acl/package.json | 21 ++ packages/plugin-acl/src/__tests__/acl.test.ts | 317 ++++++++++++++++++ .../src/__tests__/middleware.test.ts | 239 +++++++++++++ packages/plugin-acl/src/__tests__/prepare.ts | 36 ++ .../src/__tests__/role-resource.test.ts | 142 ++++++++ .../plugin-acl/src/__tests__/role.test.ts | 66 ++++ .../plugin-acl/src/__tests__/scope.test.ts | 51 +++ .../plugin-acl/src/acl/available-action.ts | 29 ++ packages/plugin-acl/src/acl/index.ts | 23 ++ .../src/actions/available-actions.ts | 15 + .../src/actions/role-collections.ts | 39 +++ .../src/collections/action-scopes.ts | 19 ++ .../plugin-acl/src/collections/resouces.ts | 24 ++ .../src/collections/resource-actions.ts | 27 ++ packages/plugin-acl/src/collections/roles.ts | 49 +++ packages/plugin-acl/src/index.ts | 1 + packages/plugin-acl/src/server.ts | 112 +++++++ packages/plugin-acl/tsconfig.build.json | 9 + packages/plugin-acl/tsconfig.json | 5 + .../plugin-collection-manager/src/index.ts | 9 +- .../src/models/collection.ts | 3 + .../src/models/field.ts | 2 +- packages/server/src/application.ts | 4 + packages/test/src/mockServer.ts | 4 +- yarn.lock | 38 ++- 31 files changed, 1552 insertions(+), 42 deletions(-) create mode 100644 packages/plugin-acl/package.json create mode 100644 packages/plugin-acl/src/__tests__/acl.test.ts create mode 100644 packages/plugin-acl/src/__tests__/middleware.test.ts create mode 100644 packages/plugin-acl/src/__tests__/prepare.ts create mode 100644 packages/plugin-acl/src/__tests__/role-resource.test.ts create mode 100644 packages/plugin-acl/src/__tests__/role.test.ts create mode 100644 packages/plugin-acl/src/__tests__/scope.test.ts create mode 100644 packages/plugin-acl/src/acl/available-action.ts create mode 100644 packages/plugin-acl/src/acl/index.ts create mode 100644 packages/plugin-acl/src/actions/available-actions.ts create mode 100644 packages/plugin-acl/src/actions/role-collections.ts create mode 100644 packages/plugin-acl/src/collections/action-scopes.ts create mode 100644 packages/plugin-acl/src/collections/resouces.ts create mode 100644 packages/plugin-acl/src/collections/resource-actions.ts create mode 100644 packages/plugin-acl/src/collections/roles.ts create mode 100644 packages/plugin-acl/src/index.ts create mode 100644 packages/plugin-acl/src/server.ts create mode 100644 packages/plugin-acl/tsconfig.build.json create mode 100644 packages/plugin-acl/tsconfig.json diff --git a/packages/acl/src/__tests__/acl.test.ts b/packages/acl/src/__tests__/acl.test.ts index 84d9763f6..20c23735e 100644 --- a/packages/acl/src/__tests__/acl.test.ts +++ b/packages/acl/src/__tests__/acl.test.ts @@ -7,6 +7,67 @@ describe('acl', () => { acl = new ACL(); }); + it('should grant action with own params', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + + acl.setAvailableAction('create', { + type: 'new-data', + }); + + acl.define({ + role: 'admin', + actions: { + 'posts:edit': { + own: true, + }, + }, + }); + + const canResult = acl.can({ role: 'admin', resource: 'posts', action: 'edit' }); + + expect(canResult).toMatchObject({ + role: 'admin', + resource: 'posts', + action: 'edit', + params: { + filter: { + createdById: '{{ ctx.state.currentUser.id }}', + }, + }, + }); + }); + it('should define role with predicate', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + + acl.setAvailableAction('create', { + type: 'new-data', + }); + + acl.define({ + role: 'admin', + strategy: { + actions: ['edit:own', 'create'], + }, + }); + + const canResult = acl.can({ role: 'admin', resource: 'posts', action: 'edit' }); + + expect(canResult).toMatchObject({ + role: 'admin', + resource: 'posts', + action: 'edit', + params: { + filter: { + createdById: '{{ ctx.state.currentUser.id }}', + }, + }, + }); + }); + it('should allow all', () => { acl.setAvailableAction('create', { type: 'new-data', @@ -248,16 +309,16 @@ describe('acl', () => { type: 'old-data', }); - acl.beforeGrantAction('posts:create', (ctx) => { - ctx.params = { - filter: { - status: 'publish', - }, - }; + acl.beforeGrantAction((ctx) => { + if (ctx.path === 'posts:create') { + ctx.params = { + filter: { + status: 'publish', + }, + }; + } }); - expect(acl.listenerCount('posts:create.beforeGrantAction')).toEqual(1); - acl.define({ role: 'admin', actions: { @@ -310,4 +371,32 @@ describe('acl', () => { }, }); }); + + it('should allow system config', () => { + acl.setAvailableAction('create', { + displayName: 'create', + type: 'new-data', + }); + + acl.registerConfigResources(['roles']); + + const role = acl.define({ + role: 'admin', + strategy: { + allowConfigure: true, + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'roles', + action: 'create', + }), + ).toMatchObject({ + role: 'admin', + resource: 'roles', + action: 'create', + }); + }); }); diff --git a/packages/acl/src/acl-available-strategy.ts b/packages/acl/src/acl-available-strategy.ts index d2a6be505..aaf3f16a2 100644 --- a/packages/acl/src/acl-available-strategy.ts +++ b/packages/acl/src/acl-available-strategy.ts @@ -3,9 +3,9 @@ import { ACL } from './acl'; type StrategyValue = false | '*' | string | string[]; export interface AvailableStrategyOptions { - acl: ACL; displayName?: string; - actions: false | string | string[]; + actions?: false | string | string[]; + allowConfigure?: boolean; resource?: '*'; } @@ -25,18 +25,63 @@ export function strategyValueMatched(strategy: StrategyValue, value: string) { return false; } -export class ACLAvailableStrategy { - options: AvailableStrategyOptions; +export const predicate = { + own: { + filter: { + createdById: '{{ ctx.state.currentUser.id }}', + }, + }, + all: {}, +}; - constructor(options: AvailableStrategyOptions) { +export class ACLAvailableStrategy { + acl: ACL; + options: AvailableStrategyOptions; + actionsAsObject: { [key: string]: string }; + + allowConfigure: boolean; + + constructor(acl: ACL, options: AvailableStrategyOptions) { + this.acl = acl; this.options = options; + this.allowConfigure = options.allowConfigure; + + let actions = this.options.actions; + if (lodash.isString(actions) && actions != '*') { + actions = [actions]; + } + + if (lodash.isArray(actions)) { + this.actionsAsObject = actions.reduce((carry, action) => { + const [actionName, predicate] = action.split(':'); + carry[actionName] = predicate; + return carry; + }, {}); + } } matchAction(actionName: string) { - return strategyValueMatched(this.options.actions, actionName); + if (this.options.actions == '*') { + return true; + } + + if (this.actionsAsObject?.hasOwnProperty(actionName)) { + const predicateName = this.actionsAsObject[actionName]; + if (predicateName) { + return predicate[predicateName]; + } + + return true; + } + + return false; } allow(resourceName: string, actionName: string) { - return this.matchAction(this.options.acl.resolveActionAlias(actionName)); + if (this.acl.isConfigResource(resourceName) && this.allowConfigure) { + return true; + } + + return this.matchAction(this.acl.resolveActionAlias(actionName)); } } diff --git a/packages/acl/src/acl-resource.ts b/packages/acl/src/acl-resource.ts index 720f898d2..4e51a3432 100644 --- a/packages/acl/src/acl-resource.ts +++ b/packages/acl/src/acl-resource.ts @@ -43,9 +43,12 @@ export class ACLResource { role: this.role, acl: this.role.acl, params: params || {}, + path: `${this.name}:${name}`, + resourceName: this.name, + actionName: name, }; - this.acl.emit(`${this.name}:${name}.beforeGrantAction`, context); + this.acl.emit('beforeGrantAction', context); this.actions.set(name, context.params); } diff --git a/packages/acl/src/acl.ts b/packages/acl/src/acl.ts index 2d0c2062d..d36ed3fc7 100644 --- a/packages/acl/src/acl.ts +++ b/packages/acl/src/acl.ts @@ -1,13 +1,10 @@ import lodash from 'lodash'; -import { ACLAvailableStrategy, AvailableStrategyOptions } from './acl-available-strategy'; +import { ACLAvailableStrategy, AvailableStrategyOptions, predicate } from './acl-available-strategy'; import { ACLRole, RoleActionParams } from './acl-role'; import { AclAvailableAction, AvailableActionOptions } from './acl-available-action'; import EventEmitter from 'events'; - -interface StrategyOptions { - role: string; - strategy: string; -} +import { Action } from '@nocobase/resourcer'; +const parse = require('json-templates'); interface CanResult { role: string; @@ -18,7 +15,8 @@ interface CanResult { export interface DefineOptions { role: string; - strategy?: string | AvailableStrategyOptions; + allowConfigure?: boolean; + strategy?: string | Omit; actions?: { [key: string]: RoleActionParams; }; @@ -28,11 +26,20 @@ export interface DefineOptions { export interface ListenerContext { acl: ACL; role: ACLRole; + path: string; + actionName: string; + resourceName: string; params: RoleActionParams; } type Listener = (ctx: ListenerContext) => void; +interface CanArgs { + role: string; + resource: string; + action: string; +} + export class ACL extends EventEmitter { protected availableActions = new Map(); protected availableStrategy = new Map(); @@ -41,6 +48,39 @@ export class ACL extends EventEmitter { actionAlias = new Map(); + configResources: string[] = []; + + constructor() { + super(); + + this.beforeGrantAction((ctx) => { + if (lodash.isPlainObject(ctx.params) && ctx.params.own) { + ctx.params = lodash.merge(ctx.params, predicate.own); + } + }); + + this.beforeGrantAction((ctx) => { + const actionName = this.resolveActionAlias(ctx.actionName); + + if (lodash.isPlainObject(ctx.params)) { + if ((actionName === 'create' || actionName === 'update') && ctx.params.fields) { + ctx.params = { + ...lodash.omit(ctx.params, 'fields'), + whitelist: ctx.params.fields, + }; + } + + if (actionName === 'view' && ctx.params.fields) { + const appendFields = ['id', 'createdAt', 'updatedAt']; + ctx.params = { + ...lodash.omit(ctx.params, 'fields'), + fields: [...ctx.params.fields, ...appendFields], + }; + } + } + }); + } + define(options: DefineOptions): ACLRole { const roleName = options.role; const role = new ACLRole(this, roleName); @@ -64,6 +104,22 @@ export class ACL extends EventEmitter { return this.roles.get(name); } + removeRole(name: string) { + return this.roles.delete(name); + } + + registerConfigResources(names: string[]) { + names.forEach((name) => this.registerConfigResource(name)); + } + + registerConfigResource(name: string) { + this.configResources.push(name); + } + + isConfigResource(name: string) { + return this.configResources.includes(name); + } + setAvailableAction(name: string, options: AvailableActionOptions) { this.availableActions.set(name, new AclAvailableAction(name, options)); @@ -75,21 +131,19 @@ export class ACL extends EventEmitter { } } + getAvailableActions() { + return this.availableActions; + } + setAvailableStrategy(name: string, options: Omit) { - this.availableStrategy.set( - name, - new ACLAvailableStrategy({ - ...options, - acl: this, - }), - ); + this.availableStrategy.set(name, new ACLAvailableStrategy(this, options)); } - beforeGrantAction(path: string, listener?: Listener) { - this.addListener(`${path}.beforeGrantAction`, listener); + beforeGrantAction(listener?: Listener) { + this.addListener('beforeGrantAction', listener); } - can({ role, resource, action }: { role: string; resource: string; action: string }): CanResult | null { + can({ role, resource, action }: CanArgs): CanResult | null { if (!this.isAvailableAction(action)) { return null; } @@ -111,16 +165,28 @@ export class ACL extends EventEmitter { } } + if (!aclRole.strategy) { + return null; + } + const roleStrategy = lodash.isString(aclRole.strategy) ? this.availableStrategy.get(aclRole.strategy) - : new ACLAvailableStrategy(aclRole.strategy); + : new ACLAvailableStrategy(this, aclRole.strategy); if (!roleStrategy) { return null; } - if (roleStrategy.allow(resource, this.resolveActionAlias(action))) { - return { role, resource, action }; + const roleStrategyParams = roleStrategy.allow(resource, this.resolveActionAlias(action)); + + if (roleStrategyParams) { + const result = { role, resource, action }; + + if (lodash.isPlainObject(roleStrategyParams)) { + result['params'] = roleStrategyParams; + } + + return result; } return null; @@ -133,4 +199,34 @@ export class ACL extends EventEmitter { public resolveActionAlias(action: string) { return this.actionAlias.get(action) ? this.actionAlias.get(action) : action; } + + middleware() { + const aclInstance = this; + + return async function ACLMiddleware(ctx, next) { + const roleName = ctx.state.currentRole; + const { resourceName, actionName } = ctx.action; + + const resourcerAction: Action = ctx.action; + + ctx.can = (options: Omit) => { + return aclInstance.can({ role: roleName, ...options }); + }; + + const canResult = ctx.can({ resource: resourceName, action: actionName }); + + if (!canResult) { + ctx.throw(403, 'no permission'); + return; + } + + if (lodash.get(canResult, 'params')) { + const template = parse(canResult.params); + + resourcerAction.mergeParams(template({ ctx })); + } + + await next(); + }; + } } diff --git a/packages/database/src/repository.ts b/packages/database/src/repository.ts index 8db37b309..9684499cf 100644 --- a/packages/database/src/repository.ts +++ b/packages/database/src/repository.ts @@ -304,6 +304,10 @@ export class Repository { + let app: MockServer; + let db: Database; + let acl: ACL; + + afterEach(async () => { + await app.destroy(); + }); + + beforeEach(async () => { + app = await prepareApp(); + db = app.db; + const aclPlugin = app.getPlugin('PluginACL'); + + acl = aclPlugin.getACL(); + }); + + it('should works with universal actions', async () => { + await db.getRepository('roles').create({ + values: { + name: 'admin', + title: 'Admin User', + allowConfigure: true, + }, + }); + + const role = await db.getRepository('roles').findOne({ + filter: { + name: 'admin', + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'posts', + action: 'create', + }), + ).toBeNull(); + + // grant universal action + await app + .agent() + .resource('roles') + .update({ + resourceIndex: 'admin', + values: { + strategy: { + actions: ['create'], + }, + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'posts', + action: 'create', + }), + ).toMatchObject({ + role: 'admin', + resource: 'posts', + action: 'create', + }); + }); + + it('should works with resources actions', async () => { + await db.getRepository('roles').create({ + values: { + name: 'admin', + title: 'Admin User', + allowConfigure: true, + }, + }); + + const role = await db.getRepository('roles').findOne({ + filter: { + name: 'admin', + }, + }); + + await db.getRepository('collections').create({ + values: { + name: 'c1', + title: 'table1', + }, + }); + + await db.getRepository('collections').create({ + values: { + name: 'c2', + title: 'table2', + }, + }); + + await app + .agent() + .resource('rolesResourcesScopes') + .create({ + values: { + resourceName: 'c1', + name: 'published', + scope: { + published: true, + }, + }, + }); + + const publishedScope = await db.getRepository('rolesResourcesScopes').findOne(); + + await app + .agent() + .resource('roles.resources') + .create({ + associatedIndex: role.get('name') as string, + values: { + name: 'c1', + usingActionsConfig: true, + actions: [ + { + name: 'create', + scope: publishedScope.get('id'), + }, + { + name: 'view', + fields: ['title', 'age'], + }, + ], + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'c1', + action: 'create', + }), + ).toMatchObject({ + role: 'admin', + resource: 'c1', + action: 'create', + params: { + filter: { published: true }, + fields: [], + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'c1', + action: 'view', + }), + ).toMatchObject({ + role: 'admin', + resource: 'c1', + action: 'view', + params: { + fields: ['title', 'age'], + }, + }); + + // revoke action + const response = await app + .agent() + .resource('roles.resources') + .list({ + associatedIndex: role.get('name') as string, + appends: ['actions'], + }); + + const actions = response.body.data[0].actions; + const resourceId = response.body.data[0].id; + + const viewActionId = actions.find((action) => action.name === 'view').id; + + await app + .agent() + .resource('roles.resources') + .update({ + associatedIndex: role.get('name') as string, + resourceIndex: resourceId, + values: { + name: 'c1', + usingActionsConfig: true, + actions: [ + { + id: viewActionId, + name: 'view', + fields: ['title', 'age'], + }, + ], + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'c1', + action: 'create', + }), + ).toBeNull(); + }); + + it('should revoke actions when not using actions config', async () => { + await db.getRepository('roles').create({ + values: { + name: 'admin', + title: 'Admin User', + allowConfigure: true, + }, + }); + + const role = await db.getRepository('roles').findOne({ + filter: { + name: 'admin', + }, + }); + + await db.getRepository('collections').create({ + values: { + name: 'posts', + title: 'posts', + }, + }); + + await app + .agent() + .resource('roles.resources') + .create({ + associatedIndex: role.get('name') as string, + values: { + name: 'posts', + usingActionsConfig: true, + actions: [ + { + name: 'create', + }, + ], + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'posts', + action: 'create', + }), + ).toMatchObject({ + role: 'admin', + resource: 'posts', + action: 'create', + }); + + await app + .agent() + .resource('roles.resources') + .update({ + associatedIndex: role.get('name') as string, + resourceIndex: ( + await db.getRepository('rolesResources').findOne({ + filter: { + name: 'posts', + roleName: 'admin', + }, + }) + ).get('id') as string, + values: { + usingActionsConfig: false, + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'posts', + action: 'create', + }), + ).toBeNull(); + + await app + .agent() + .resource('roles.resources') + .update({ + associatedIndex: role.get('name') as string, + resourceIndex: ( + await db.getRepository('rolesResources').findOne({ + filter: { + name: 'posts', + roleName: 'admin', + }, + }) + ).get('id') as string, + values: { + usingActionsConfig: true, + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'posts', + action: 'create', + }), + ).toMatchObject({ + role: 'admin', + resource: 'posts', + action: 'create', + }); + }); +}); diff --git a/packages/plugin-acl/src/__tests__/middleware.test.ts b/packages/plugin-acl/src/__tests__/middleware.test.ts new file mode 100644 index 000000000..e43947aa5 --- /dev/null +++ b/packages/plugin-acl/src/__tests__/middleware.test.ts @@ -0,0 +1,239 @@ +import { MockServer } from '@nocobase/test'; +import { changeMockUser, prepareApp } from './prepare'; +import { Database, Model } from '@nocobase/database'; +import { ACL } from '@nocobase/acl'; +import PluginACL from '@nocobase/plugin-acl'; + +describe('middleware', () => { + let app: MockServer; + let role: Model; + let db: Database; + let acl: ACL; + + beforeEach(async () => { + app = await prepareApp(); + db = app.db; + acl = app.getPlugin('PluginACL').getACL(); + + await db.getRepository('roles').create({ + values: { + name: 'admin', + title: 'Admin User', + allowConfigure: true, + }, + }); + + role = await db.getRepository('roles').findOne({ + filter: { + name: 'admin', + }, + }); + + await db.getRepository('collections').create({ + values: { + name: 'posts', + }, + context: {}, + }); + + await db.getRepository('collections.fields', 'posts').create({ + values: { + name: 'title', + type: 'string', + }, + context: {}, + }); + + await db.getRepository('collections.fields', 'posts').create({ + values: { + name: 'description', + type: 'string', + }, + context: {}, + }); + + await db.getRepository('collections.fields', 'posts').create({ + values: { + name: 'createdById', + type: 'integer', + }, + context: {}, + }); + }); + + afterAll(async () => { + await app.destroy(); + }); + + it('should throw 403 when no permission', async () => { + const response = await app.agent().resource('posts').create({ + values: {}, + }); + + expect(response.statusCode).toEqual(403); + }); + + it('should return 200 when role has permission', async () => { + await db.getRepository('roles').update({ + filterByTk: 'admin', + values: { + strategy: { + actions: ['create:all'], + }, + }, + }); + + const response = await app.agent().resource('posts').create({ + values: {}, + }); + + expect(response.statusCode).toEqual(200); + }); + + it('should limit fields on view actions', async () => { + await app + .agent() + .resource('roles.resources') + .create({ + associatedIndex: role.get('name') as string, + values: { + name: 'posts', + usingActionsConfig: true, + actions: [ + { + name: 'create', + fields: ['title', 'description'], + }, + { + name: 'view', + fields: ['title'], + }, + ], + }, + }); + + await app + .agent() + .resource('posts') + .create({ + values: { + title: 'post-title', + description: 'post-description', + }, + }); + + const post = await db.getRepository('posts').findOne(); + expect(post.get('title')).toEqual('post-title'); + expect(post.get('description')).toEqual('post-description'); + + const response = await app.agent().resource('posts').list({}); + expect(response.statusCode).toEqual(200); + + const data = response.body.data[0]; + + expect(data['id']).not.toBeUndefined(); + expect(data['title']).toEqual('post-title'); + expect(data['description']).toBeUndefined(); + }); + + it('should parse template value on action params', async () => { + changeMockUser({ + id: 2, + }); + + await app + .agent() + .resource('rolesResourcesScopes') + .create({ + values: { + name: 'own', + scope: { + createdById: '{{ ctx.state.currentUser.id }}', + }, + }, + }); + + const scope = await db.getRepository('rolesResourcesScopes').findOne(); + + await app + .agent() + .resource('roles.resources') + .create({ + associatedIndex: role.get('name') as string, + values: { + name: 'posts', + usingActionsConfig: true, + actions: [ + { + name: 'create', + fields: ['title', 'description', 'createdById'], + }, + { + name: 'view', + fields: ['title'], + scope: scope.get('id'), + }, + ], + }, + }); + + await app + .agent() + .resource('posts') + .create({ + values: { + title: 't1', + description: 'd1', + createdById: 1, + }, + }); + + await app + .agent() + .resource('posts') + .create({ + values: { + title: 't2', + description: 'p2', + createdById: 2, + }, + }); + + const response = await app.agent().resource('posts').list(); + const data = response.body.data; + expect(data.length).toEqual(1); + }); + + it('should change fields params to whitelist in create action', async () => { + await app + .agent() + .resource('roles.resources') + .create({ + associatedIndex: role.get('name') as string, + values: { + name: 'posts', + usingActionsConfig: true, + actions: [ + { + name: 'create', + fields: ['title'], + }, + ], + }, + }); + + await app + .agent() + .resource('posts') + .create({ + values: { + title: 'post-title', + description: 'post-description', + }, + }); + + const post = await db.getRepository('posts').findOne(); + expect(post.get('title')).toEqual('post-title'); + expect(post.get('description')).toBeNull(); + }); +}); diff --git a/packages/plugin-acl/src/__tests__/prepare.ts b/packages/plugin-acl/src/__tests__/prepare.ts new file mode 100644 index 000000000..c1501c113 --- /dev/null +++ b/packages/plugin-acl/src/__tests__/prepare.ts @@ -0,0 +1,36 @@ +import { mockServer } from '@nocobase/test'; +import PluginUiSchema from '@nocobase/plugin-ui-schema-storage'; +import PluginCollectionManager from '@nocobase/plugin-collection-manager'; +import PluginACL from '../server'; + +let mockRole: string = 'admin'; +let mockUser = {}; + +export function changeMockRole(role: string) { + mockRole = role; +} + +export function changeMockUser(user: any) { + mockUser = user; +} + +export async function prepareApp() { + const app = mockServer({ + registerActions: true, + }); + + await app.cleanDb(); + app.plugin(PluginUiSchema); + app.plugin(PluginCollectionManager); + + app.resourcer.use(async (ctx, next) => { + ctx.state.currentRole = mockRole; + ctx.state.currentUser = mockUser; + await next(); + }); + + app.plugin(PluginACL); + await app.loadAndSync(); + + return app; +} diff --git a/packages/plugin-acl/src/__tests__/role-resource.test.ts b/packages/plugin-acl/src/__tests__/role-resource.test.ts new file mode 100644 index 000000000..ee775581e --- /dev/null +++ b/packages/plugin-acl/src/__tests__/role-resource.test.ts @@ -0,0 +1,142 @@ +import { MockServer } from '@nocobase/test'; +import { Database, Model } from '@nocobase/database'; +import { prepareApp } from './prepare'; +import { CollectionRepository } from '@nocobase/plugin-collection-manager'; + +describe('role resource api', () => { + let app: MockServer; + let db: Database; + let role: Model; + afterEach(async () => { + await app.destroy(); + }); + + beforeEach(async () => { + app = await prepareApp(); + db = app.db; + + await db.getRepository('roles').create({ + values: { + name: 'admin', + title: 'Admin User', + allowConfigure: true, + }, + }); + + role = await db.getRepository('roles').findOne({ + filter: { + name: 'admin', + }, + }); + }); + + it('should grant resource action', async () => { + const collectionManager = db.getRepository('collections') as CollectionRepository; + + await collectionManager.create({ + values: { + name: 'c1', + title: 'table1', + }, + context: {}, + }); + + await collectionManager.create({ + values: { + name: 'c2', + title: 'table2', + }, + context: {}, + }); + + // get collections list + let response = await app + .agent() + .resource('roles.collections') + .list({ + associatedIndex: role.get('name') as string, + }); + + expect(response.statusCode).toEqual(200); + + expect(response.body.data).toMatchObject([ + { + name: 'c1', + title: 'table1', + usingConfig: 'strategy', + }, + { + name: 'c2', + title: 'table2', + usingConfig: 'strategy', + }, + ]); + + // set resource actions + response = await app + .agent() + .resource('roles.resources') + .create({ + associatedIndex: role.get('name') as string, + values: { + name: 'c1', + usingActionsConfig: true, + actions: [ + { + name: 'create', + }, + ], + }, + }); + + expect(response.statusCode).toEqual(200); + + // get collections list + response = await app + .agent() + .resource('roles.collections') + .list({ + associatedIndex: role.get('name') as string, + filter: { + name: 'c1', + }, + }); + + expect(response.body.data[0]['usingConfig']).toEqual('resourceAction'); + + response = await app + .agent() + .resource('roles.resources') + .list({ + associatedIndex: role.get('name') as string, + appends: 'actions', + }); + + expect(response.statusCode).toEqual(200); + const resources = response.body.data; + const resourceAction = resources[0]['actions'][0]; + + expect(resourceAction['name']).toEqual('create'); + + // update resource actions + response = await app + .agent() + .resource('roles.resources') + .update({ + associatedIndex: role.get('name') as string, + values: { + name: 'c1', + usingActionsConfig: true, + actions: [ + { + name: 'view', + }, + ], + }, + }); + + expect(response.statusCode).toEqual(200); + expect(response.body.data[0]['actions'].length).toEqual(1); + expect(response.body.data[0]['actions'][0]['name']).toEqual('view'); + }); +}); diff --git a/packages/plugin-acl/src/__tests__/role.test.ts b/packages/plugin-acl/src/__tests__/role.test.ts new file mode 100644 index 000000000..792618a2b --- /dev/null +++ b/packages/plugin-acl/src/__tests__/role.test.ts @@ -0,0 +1,66 @@ +import { MockServer } from '@nocobase/test'; +import { CollectionRepository } from '@nocobase/plugin-collection-manager'; +import { Database, Model } from '@nocobase/database'; + +import { prepareApp } from './prepare'; + +describe('role api', () => { + let app: MockServer; + let db: Database; + + afterEach(async () => { + await app.destroy(); + }); + + beforeEach(async () => { + app = await prepareApp(); + db = app.db; + }); + + describe('grant', () => { + let role: Model; + + beforeEach(async () => { + await db.getRepository('roles').create({ + values: { + name: 'admin', + title: 'Admin User', + allowConfigure: true, + }, + }); + + role = await db.getRepository('roles').findOne({ + filter: { + name: 'admin', + }, + }); + }); + + it('should list actions', async () => { + const response = await app.agent().resource('availableActions').list(); + expect(response.statusCode).toEqual(200); + }); + + it('should grant universal role actions', async () => { + // grant role actions + const response = await app + .agent() + .resource('roles') + .update({ + values: { + strategy: { + actions: ['create:all', 'view:own'], + }, + }, + }); + + expect(response.statusCode).toEqual(200); + + await role.reload(); + + expect(role.get('strategy')).toMatchObject({ + actions: ['create:all', 'view:own'], + }); + }); + }); +}); diff --git a/packages/plugin-acl/src/__tests__/scope.test.ts b/packages/plugin-acl/src/__tests__/scope.test.ts new file mode 100644 index 000000000..d8de629d8 --- /dev/null +++ b/packages/plugin-acl/src/__tests__/scope.test.ts @@ -0,0 +1,51 @@ +import { prepareApp } from './prepare'; +import { MockServer } from '@nocobase/test'; +import { Database } from '@nocobase/database'; + +describe('scope api', () => { + let app: MockServer; + let db: Database; + + afterEach(async () => { + await app.destroy(); + }); + + beforeEach(async () => { + app = await prepareApp(); + db = app.db; + await db.getRepository('roles').create({ + values: { + name: 'admin', + title: 'Admin User', + allowConfigure: true, + }, + }); + }); + + it('should create scope of resource', async () => { + const response = await app + .agent() + .resource('rolesResourcesScopes') + .create({ + values: { + resourceName: 'posts', + name: 'published posts', + scope: { + published: true, + }, + }, + }); + + expect(response.statusCode).toEqual(200); + + const scope = await db.getRepository('rolesResourcesScopes').findOne({ + filter: { + name: 'published posts', + }, + }); + + expect(scope.get('scope')).toMatchObject({ + published: true, + }); + }); +}); diff --git a/packages/plugin-acl/src/acl/available-action.ts b/packages/plugin-acl/src/acl/available-action.ts new file mode 100644 index 000000000..4969ccaa8 --- /dev/null +++ b/packages/plugin-acl/src/acl/available-action.ts @@ -0,0 +1,29 @@ +import { AvailableActionOptions } from '@nocobase/acl'; + +const availableActions: { + [key: string]: AvailableActionOptions; +} = { + create: { + displayName: 't("create")', + type: 'new-data', + }, + import: { + displayName: 't("import")', + type: 'new-data', + }, + view: { + displayName: 't("view")', + type: 'old-data', + aliases: ['get', 'list'], + }, + update: { + displayName: 't("edit")', + type: 'old-data', + }, + destroy: { + displayName: 't("destroy")', + type: 'old-data', + }, +}; + +export { availableActions }; diff --git a/packages/plugin-acl/src/acl/index.ts b/packages/plugin-acl/src/acl/index.ts new file mode 100644 index 000000000..5e48bd5ef --- /dev/null +++ b/packages/plugin-acl/src/acl/index.ts @@ -0,0 +1,23 @@ +import { ACL } from '@nocobase/acl'; +import { availableActions } from './available-action'; + +const configureResources = [ + 'roles', + 'collections', + 'roles.collections', + 'roles.resources', + 'rolesResourcesScopes', + 'availableActions', +]; + +export function createACL() { + const acl = new ACL(); + + for (const [actionName, actionParams] of Object.entries(availableActions)) { + acl.setAvailableAction(actionName, actionParams); + } + + acl.registerConfigResources(configureResources); + + return acl; +} diff --git a/packages/plugin-acl/src/actions/available-actions.ts b/packages/plugin-acl/src/actions/available-actions.ts new file mode 100644 index 000000000..0563ef8b3 --- /dev/null +++ b/packages/plugin-acl/src/actions/available-actions.ts @@ -0,0 +1,15 @@ +import PluginACL from '@nocobase/plugin-acl'; + +const availableActionResource = { + name: 'availableActions', + actions: { + list(ctx, next) { + const aclPlugin: PluginACL = ctx.app.getPlugin('PluginACL'); + const acl = aclPlugin.getACL(); + const availableActions = acl.getAvailableActions(); + ctx.body = Array.from(availableActions.entries()).map((item) => item[1]); + }, + }, +}; + +export { availableActionResource }; diff --git a/packages/plugin-acl/src/actions/role-collections.ts b/packages/plugin-acl/src/actions/role-collections.ts new file mode 100644 index 000000000..dab7eabf8 --- /dev/null +++ b/packages/plugin-acl/src/actions/role-collections.ts @@ -0,0 +1,39 @@ +import { Database } from '@nocobase/database'; + +type UsingConfigType = 'strategy' | 'resourceAction'; + +const roleCollectionsResource = { + name: 'roles.collections', + actions: { + async list(ctx, next) { + const role = ctx.action.params.associatedIndex; + + const db: Database = ctx.db; + const collectionRepository = db.getRepository('collections'); + const collections = await collectionRepository.find(); + + const roleResources = await db.getRepository('rolesResources').find({ + filter: { + roleName: role, + usingActionsConfig: true, + }, + }); + + const roleResourcesNames = roleResources.map((roleResource) => roleResource.get('name')); + + ctx.body = collections.map((collection) => { + const usingConfig: UsingConfigType = roleResourcesNames.includes(collection.get('name')) + ? 'resourceAction' + : 'strategy'; + + return { + name: collection.get('name'), + title: collection.get('title'), + usingConfig, + }; + }); + }, + }, +}; + +export { roleCollectionsResource }; diff --git a/packages/plugin-acl/src/collections/action-scopes.ts b/packages/plugin-acl/src/collections/action-scopes.ts new file mode 100644 index 000000000..5d2b1d14f --- /dev/null +++ b/packages/plugin-acl/src/collections/action-scopes.ts @@ -0,0 +1,19 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + name: 'rolesResourcesScopes', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'string', + name: 'resourceName', + }, + { + type: 'json', + name: 'scope', + }, + ], +} as CollectionOptions; diff --git a/packages/plugin-acl/src/collections/resouces.ts b/packages/plugin-acl/src/collections/resouces.ts new file mode 100644 index 000000000..c63f27fe8 --- /dev/null +++ b/packages/plugin-acl/src/collections/resouces.ts @@ -0,0 +1,24 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + name: 'rolesResources', + fields: [ + { + type: 'belongsTo', + name: 'role', + }, + { + type: 'string', + name: 'name', + }, + { + type: 'boolean', + name: 'usingActionsConfig', + }, + { + type: 'hasMany', + name: 'actions', + target: 'rolesResourcesActions', + }, + ], +} as CollectionOptions; diff --git a/packages/plugin-acl/src/collections/resource-actions.ts b/packages/plugin-acl/src/collections/resource-actions.ts new file mode 100644 index 000000000..dadff9189 --- /dev/null +++ b/packages/plugin-acl/src/collections/resource-actions.ts @@ -0,0 +1,27 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + name: 'rolesResourcesActions', + fields: [ + { + type: 'belongsTo', + name: 'resource', + foreignKey: 'rolesResourceId', + target: 'rolesResources', + }, + { + type: 'string', + name: 'name', + }, + { + type: 'json', + name: 'fields', + defaultValue: [], + }, + { + type: 'belongsTo', + name: 'scope', + target: 'rolesResourcesScopes', + }, + ], +} as CollectionOptions; diff --git a/packages/plugin-acl/src/collections/roles.ts b/packages/plugin-acl/src/collections/roles.ts new file mode 100644 index 000000000..afcd2a1cc --- /dev/null +++ b/packages/plugin-acl/src/collections/roles.ts @@ -0,0 +1,49 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + name: 'roles', + autoGenId: false, + fields: [ + { + type: 'uid', + name: 'name', + primaryKey: true, + }, + { + type: 'string', + name: 'title', + unique: true, + }, + { + type: 'boolean', + name: 'default', + }, + { + type: 'string', + name: 'description', + }, + { + type: 'json', + name: 'strategy', + }, + { + type: 'boolean', + name: 'allowConfigure', + }, + { + type: 'boolean', + name: 'allowNewMenu', + }, + { + type: 'belongsToMany', + name: 'menuUiSchemas', + target: 'ui_schemas', + }, + { + type: 'hasMany', + name: 'resources', + target: 'rolesResources', + sourceKey: 'name', + }, + ], +} as CollectionOptions; diff --git a/packages/plugin-acl/src/index.ts b/packages/plugin-acl/src/index.ts new file mode 100644 index 000000000..031f5867f --- /dev/null +++ b/packages/plugin-acl/src/index.ts @@ -0,0 +1 @@ +export { default as default } from './server'; diff --git a/packages/plugin-acl/src/server.ts b/packages/plugin-acl/src/server.ts new file mode 100644 index 000000000..7ad25f7af --- /dev/null +++ b/packages/plugin-acl/src/server.ts @@ -0,0 +1,112 @@ +import { Plugin } from '@nocobase/server'; +import { ACL } from '@nocobase/acl'; +import path from 'path'; +import { availableActionResource } from './actions/available-actions'; +import { roleCollectionsResource } from './actions/role-collections'; +import { createACL } from './acl'; + +async function actionModelToParams(actionModel, resourceName) { + const fields = actionModel.get('fields'); + const actionPath = `${resourceName}:${actionModel.get('name')}`; + + const actionParams = { + fields, + }; + + const scope = await actionModel.getScope(); + + if (scope) { + actionParams['filter'] = scope.get('scope'); + } + + return { + actionPath, + actionParams, + }; +} + +export default class PluginACL extends Plugin { + acl: ACL; + + getACL() { + return this.acl; + } + + async load() { + const acl = createACL(); + this.acl = acl; + + await this.app.db.import({ + directory: path.resolve(__dirname, 'collections'), + }); + + this.app.resourcer.define(availableActionResource); + this.app.resourcer.define(roleCollectionsResource); + + this.app.resourcer.use(this.acl.middleware()); + + this.app.db.on('roles.afterSave', (model) => { + const roleName = model.get('name'); + let role = acl.getRole(roleName); + + if (!role) { + role = acl.define({ + role: model.get('name'), + }); + } + + role.setStrategy({ + ...(model.get('strategy') || {}), + allowConfigure: model.get('allowConfigure'), + }); + }); + + this.app.db.on('roles.afterDestroy', (model) => { + const roleName = model.get('name'); + acl.removeRole(roleName); + }); + + this.app.db.on('rolesResources.afterSave', async (model, options) => { + const roleName = model.get('roleName'); + const role = acl.getRole(roleName); + + if (model.usingActionsConfig === true && model._previousDataValues.usingActionsConfig === false) { + const actions = await model.getActions(); + for (const action of actions) { + const { actionPath, actionParams } = await actionModelToParams(action, model.get('name')); + role.grantAction(actionPath, actionParams); + } + } + + if (model._previousDataValues.usingActionsConfig === true && model.usingActionsConfig === false) { + role.revokeResource(model.get('name')); + } + }); + + this.app.db.on('rolesResourcesActions.beforeBulkUpdate', async (options) => { + options.individualHooks = true; + }); + + this.app.db.on('rolesResourcesActions.afterSave', async (model) => { + const resource = await model.getResource(); + if (!resource) { + const previousResource = await this.app.db.getRepository('rolesResources').findOne({ + filter: { + id: model._previousDataValues.rolesResourceId, + }, + }); + + const roleName = previousResource.get('roleName') as string; + const role = acl.getRole(roleName); + role.revokeAction(`${previousResource.get('name')}:${model.get('name')}`); + return; + } + + const roleName = resource.get('roleName'); + const role = acl.getRole(roleName); + + const { actionPath, actionParams } = await actionModelToParams(model, resource.get('name')); + role.grantAction(actionPath, actionParams); + }); + } +} diff --git a/packages/plugin-acl/tsconfig.build.json b/packages/plugin-acl/tsconfig.build.json new file mode 100644 index 000000000..0db344533 --- /dev/null +++ b/packages/plugin-acl/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./lib", + "declaration": true + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"] +} diff --git a/packages/plugin-acl/tsconfig.json b/packages/plugin-acl/tsconfig.json new file mode 100644 index 000000000..67a583a32 --- /dev/null +++ b/packages/plugin-acl/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./esm/*", "./lib/*"] +} diff --git a/packages/plugin-collection-manager/src/index.ts b/packages/plugin-collection-manager/src/index.ts index 427bdcfef..eb7ad477d 100644 --- a/packages/plugin-collection-manager/src/index.ts +++ b/packages/plugin-collection-manager/src/index.ts @@ -2,21 +2,24 @@ import path from 'path'; import { Plugin } from '@nocobase/server'; import { CollectionModel } from './models/collection'; import { FieldModel } from './models/field'; -import { uid } from '@nocobase/utils'; import beforeInitOptions from './hooks/beforeInitOptions'; import { beforeCreateForChildrenCollection } from './hooks/beforeCreateForChildrenCollection'; import { beforeCreateForReverseField } from './hooks/beforeCreateForReverseField'; import { afterCreateForReverseField } from './hooks/afterCreateForReverseField'; +export * from './repositories/collection-repository'; + export default class CollectionManagerPlugin extends Plugin { async load() { this.app.db.registerModels({ CollectionModel, FieldModel, }); + await this.app.db.import({ directory: path.resolve(__dirname, './collections'), }); + // 要在 beforeInitOptions 之前处理 this.app.db.on('fields.beforeCreate', beforeCreateForReverseField(this.app.db)); this.app.db.on('fields.beforeCreate', beforeCreateForChildrenCollection(this.app.db)); @@ -31,13 +34,15 @@ export default class CollectionManagerPlugin extends Plugin { } } this.app.db.on('fields.afterCreate', afterCreateForReverseField(this.app.db)); - this.app.db.on('collections.afterCreate', async (model, options) => { + + this.app.db.on('collections.afterCreateWithAssociations', async (model, options) => { if (options.context) { process.nextTick(async () => { await model.migrate(); }); } }); + this.app.db.on('fields.afterCreate', async (model, options) => { if (options.context) { process.nextTick(async () => { diff --git a/packages/plugin-collection-manager/src/models/collection.ts b/packages/plugin-collection-manager/src/models/collection.ts index 9676e54d8..a653f6e6a 100644 --- a/packages/plugin-collection-manager/src/models/collection.ts +++ b/packages/plugin-collection-manager/src/models/collection.ts @@ -16,7 +16,9 @@ export class CollectionModel extends MagicAttributeModel { async load(loadOptions: LoadOptions = {}) { const { skipExist, skipField } = loadOptions; const name = this.get('name'); + let collection: Collection; + if (this.db.hasCollection(name)) { collection = this.db.getCollection(name); if (skipExist) { @@ -26,6 +28,7 @@ export class CollectionModel extends MagicAttributeModel { } else { collection = this.db.collection(this.get()); } + if (!skipField) { await this.loadFields(); } diff --git a/packages/plugin-collection-manager/src/models/field.ts b/packages/plugin-collection-manager/src/models/field.ts index 8c446a08a..d5503eb18 100644 --- a/packages/plugin-collection-manager/src/models/field.ts +++ b/packages/plugin-collection-manager/src/models/field.ts @@ -12,7 +12,7 @@ export class FieldModel extends MagicAttributeModel { } async load(loadOptions?: LoadOptions) { - const { skipExist } = loadOptions; + const { skipExist = false } = loadOptions || {}; const collectionName = this.get('collectionName'); if (!this.db.hasCollection(collectionName)) { throw new Error(`${collectionName} collection does not exist.`); diff --git a/packages/server/src/application.ts b/packages/server/src/application.ts index 1f4c256a4..3d79dea9e 100644 --- a/packages/server/src/application.ts +++ b/packages/server/src/application.ts @@ -151,6 +151,10 @@ export class Application exten await this.emitAsync('plugins.afterLoad'); } + getPlugin

(name: string) { + return this.plugins.get(name) as P; + } + async emitAsync(event: string | symbol, ...args: any[]): Promise { // @ts-ignore const events = this._events; diff --git a/packages/test/src/mockServer.ts b/packages/test/src/mockServer.ts index 215f1b17f..4ed52690c 100644 --- a/packages/test/src/mockServer.ts +++ b/packages/test/src/mockServer.ts @@ -89,7 +89,7 @@ export class MockServer extends Application { if (params.resourceIndex) { filterByTk = params.resourceIndex; } - let url = prefix; + let url = prefix || ''; if (keys.length > 1) { url += `/${keys[0]}/${resourceOf}/${keys[1]}`; } else { @@ -135,4 +135,6 @@ export function mockServer(options?: ApplicationOptions) { }); } +export function createMockServer() {} + export default mockServer; diff --git a/yarn.lock b/yarn.lock index 262c8ad45..4569e1e09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2844,7 +2844,14 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-dom@^16.9.8", "@types/react-dom@^17.0.0": +"@types/react-dom@^16.9.8": + version "16.9.14" + resolved "https://registry.npmmirror.com/@types/react-dom/download/@types/react-dom-16.9.14.tgz#674b8f116645fe5266b40b525777fc6bb8eb3bcd" + integrity sha512-FIX2AVmPTGP30OUJ+0vadeIFJJ07Mh1m+U0rxfgyW34p3rTlXI+nlenvAxNn4BP36YyI9IJ/+UJ7Wu22N1pI7A== + dependencies: + "@types/react" "^16" + +"@types/react-dom@^17.0.0": version "17.0.11" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q== @@ -2894,7 +2901,7 @@ "@types/history" "*" "@types/react" "*" -"@types/react@*", "@types/react@^16.9.43", "@types/react@^17.0.0": +"@types/react@*", "@types/react@^17.0.0": version "17.0.34" resolved "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102" integrity sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg== @@ -2903,6 +2910,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^16", "@types/react@^16.9.43": + version "16.14.21" + resolved "https://registry.npmmirror.com/@types/react/download/@types/react-16.14.21.tgz#35199b21a278355ec7a3c40003bd6a334bd4ae4a" + integrity sha512-rY4DzPKK/4aohyWiDRHS2fotN5rhBSK6/rz1X37KzNna9HJyqtaGAbq9fVttrEPWF5ywpfIP1ITL8Xi2QZn6Eg== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/sax@^1.2.1": version "1.2.3" resolved "https://registry.npmjs.org/@types/sax/-/sax-1.2.3.tgz#b630ac1403ebd7812e0bf9a10de9bf5077afb348" @@ -5369,6 +5385,11 @@ dedent@^0.7.0: resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= +dedupe@^3.0.2: + version "3.0.2" + resolved "https://registry.npmmirror.com/dedupe/download/dedupe-3.0.2.tgz#c7c9d5534167b69dc07bd21d093882abbe88b0a9" + integrity sha1-x8nVU0Fntp3Ae9IdCTiCq76IsKk= + deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -8805,6 +8826,14 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= +json-templates@^4.2.0: + version "4.2.0" + resolved "https://registry.npmmirror.com/json-templates/download/json-templates-4.2.0.tgz#34fdd6fedbe6955e934d86812a89d30d1df87415" + integrity sha1-NP3W/tvmlV6TTYaBKonTDR34dBU= + dependencies: + dedupe "^3.0.2" + object-path "^0.11.8" + json2mq@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz#b637bd3ba9eabe122c83e9720483aeb10d2c904a" @@ -10519,6 +10548,11 @@ object-keys@^1.0.12, object-keys@^1.1.1: resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== +object-path@^0.11.8: + version "0.11.8" + resolved "https://registry.nlark.com/object-path/download/object-path-0.11.8.tgz?cache=0&sync_timestamp=1631790608134&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fobject-path%2Fdownload%2Fobject-path-0.11.8.tgz#ed002c02bbdd0070b78a27455e8ae01fc14d4742" + integrity sha1-7QAsArvdAHC3iidFXorgH8FNR0I= + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"