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 <chenlinxh@gmail.com>
This commit is contained in:
		
							parent
							
								
									a2f3e1904e
								
							
						
					
					
						commit
						bd285e0ba9
					
				@ -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',
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -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));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -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<AvailableStrategyOptions, 'acl'>;
 | 
			
		||||
  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<string, AclAvailableAction>();
 | 
			
		||||
  protected availableStrategy = new Map<string, ACLAvailableStrategy>();
 | 
			
		||||
@ -41,6 +48,39 @@ export class ACL extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
  actionAlias = new Map<string, string>();
 | 
			
		||||
 | 
			
		||||
  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<AvailableStrategyOptions, 'acl'>) {
 | 
			
		||||
    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<CanArgs, 'role'>) => {
 | 
			
		||||
        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();
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -304,6 +304,10 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
 | 
			
		||||
      transaction,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (options.hooks !== false) {
 | 
			
		||||
      await this.database.emitAsync(`${this.collection.name}.afterCreateWithAssociations`, instance, options);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return instance;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -44,6 +44,7 @@ export function transactionWrapperBuilder(transactionGenerator) {
 | 
			
		||||
            return results;
 | 
			
		||||
          } catch (err) {
 | 
			
		||||
            await transaction.rollback();
 | 
			
		||||
            console.error({ err });
 | 
			
		||||
            throw err;
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										21
									
								
								packages/plugin-acl/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								packages/plugin-acl/package.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "@nocobase/plugin-acl",
 | 
			
		||||
  "version": "0.6.0-alpha.0",
 | 
			
		||||
  "description": "",
 | 
			
		||||
  "license": "MIT",
 | 
			
		||||
  "main": "./lib/index.js",
 | 
			
		||||
  "types": "./lib/index.d.ts",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "build": "rimraf -rf lib esm dist && npm run build:cjs && npm run build:esm",
 | 
			
		||||
    "build:cjs": "tsc --project tsconfig.build.json",
 | 
			
		||||
    "build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "json-templates": "^4.2.0"
 | 
			
		||||
  },
 | 
			
		||||
  "repository": {
 | 
			
		||||
    "type": "git",
 | 
			
		||||
    "url": "git+https://github.com/nocobase/nocobase.git",
 | 
			
		||||
    "directory": "packages/plugin-acl"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										317
									
								
								packages/plugin-acl/src/__tests__/acl.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										317
									
								
								packages/plugin-acl/src/__tests__/acl.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,317 @@
 | 
			
		||||
import { MockServer } from '@nocobase/test';
 | 
			
		||||
import { prepareApp } from './prepare';
 | 
			
		||||
import { Database } from '@nocobase/database';
 | 
			
		||||
import { ACL } from '@nocobase/acl';
 | 
			
		||||
import PluginACL from '../server';
 | 
			
		||||
 | 
			
		||||
describe('acl', () => {
 | 
			
		||||
  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>('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',
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										239
									
								
								packages/plugin-acl/src/__tests__/middleware.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								packages/plugin-acl/src/__tests__/middleware.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -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>('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();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										36
									
								
								packages/plugin-acl/src/__tests__/prepare.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								packages/plugin-acl/src/__tests__/prepare.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										142
									
								
								packages/plugin-acl/src/__tests__/role-resource.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								packages/plugin-acl/src/__tests__/role-resource.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -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');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										66
									
								
								packages/plugin-acl/src/__tests__/role.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								packages/plugin-acl/src/__tests__/role.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -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'],
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										51
									
								
								packages/plugin-acl/src/__tests__/scope.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								packages/plugin-acl/src/__tests__/scope.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -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,
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										29
									
								
								packages/plugin-acl/src/acl/available-action.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								packages/plugin-acl/src/acl/available-action.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 };
 | 
			
		||||
							
								
								
									
										23
									
								
								packages/plugin-acl/src/acl/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/plugin-acl/src/acl/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								packages/plugin-acl/src/actions/available-actions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/plugin-acl/src/actions/available-actions.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 };
 | 
			
		||||
							
								
								
									
										39
									
								
								packages/plugin-acl/src/actions/role-collections.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								packages/plugin-acl/src/actions/role-collections.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 };
 | 
			
		||||
							
								
								
									
										19
									
								
								packages/plugin-acl/src/collections/action-scopes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/plugin-acl/src/collections/action-scopes.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
							
								
								
									
										24
									
								
								packages/plugin-acl/src/collections/resouces.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								packages/plugin-acl/src/collections/resouces.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
							
								
								
									
										27
									
								
								packages/plugin-acl/src/collections/resource-actions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								packages/plugin-acl/src/collections/resource-actions.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
							
								
								
									
										49
									
								
								packages/plugin-acl/src/collections/roles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								packages/plugin-acl/src/collections/roles.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
							
								
								
									
										1
									
								
								packages/plugin-acl/src/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								packages/plugin-acl/src/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
			
		||||
export { default as default } from  './server';
 | 
			
		||||
							
								
								
									
										112
									
								
								packages/plugin-acl/src/server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								packages/plugin-acl/src/server.ts
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								packages/plugin-acl/tsconfig.build.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								packages/plugin-acl/tsconfig.build.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": "../../tsconfig.build.json",
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "outDir": "./lib",
 | 
			
		||||
    "declaration": true
 | 
			
		||||
  },
 | 
			
		||||
  "include": ["./src/**/*.ts", "./src/**/*.tsx"],
 | 
			
		||||
  "exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										5
									
								
								packages/plugin-acl/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								packages/plugin-acl/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": "../../tsconfig.json",
 | 
			
		||||
  "include": ["./src/**/*.ts", "./src/**/*.tsx"],
 | 
			
		||||
  "exclude": ["./esm/*", "./lib/*"]
 | 
			
		||||
}
 | 
			
		||||
@ -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 () => {
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -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.`);
 | 
			
		||||
 | 
			
		||||
@ -151,6 +151,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
 | 
			
		||||
    await this.emitAsync('plugins.afterLoad');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getPlugin<P extends Plugin>(name: string) {
 | 
			
		||||
    return this.plugins.get(name) as P;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async emitAsync(event: string | symbol, ...args: any[]): Promise<boolean> {
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    const events = this._events;
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										38
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										38
									
								
								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"
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user