featEnable permission (#229)
* feat: acl skip * feat: skip-manager * feat: root user permission skip * fix: test * feat: set user role * fix: code review * feat: setDefaultRole for users
This commit is contained in:
		
							parent
							
								
									accb2a59b9
								
							
						
					
					
						commit
						d98714d9fd
					
				
							
								
								
									
										93
									
								
								packages/acl/src/__tests__/skip.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								packages/acl/src/__tests__/skip.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,93 @@
 | 
			
		||||
import { ACL } from '..';
 | 
			
		||||
 | 
			
		||||
describe('skip', () => {
 | 
			
		||||
  let acl: ACL;
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    acl = new ACL();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should skip action', async () => {
 | 
			
		||||
    const middlewareFunc = acl.middleware();
 | 
			
		||||
    const ctx: any = {
 | 
			
		||||
      state: {},
 | 
			
		||||
      action: {
 | 
			
		||||
        resourceName: 'users',
 | 
			
		||||
        actionName: 'login',
 | 
			
		||||
      },
 | 
			
		||||
      app: {
 | 
			
		||||
        acl,
 | 
			
		||||
      },
 | 
			
		||||
      throw() {},
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const nextFunc = jest.fn();
 | 
			
		||||
 | 
			
		||||
    await middlewareFunc(ctx, nextFunc);
 | 
			
		||||
    expect(nextFunc).toHaveBeenCalledTimes(0);
 | 
			
		||||
 | 
			
		||||
    acl.skip('users', 'login');
 | 
			
		||||
 | 
			
		||||
    await middlewareFunc(ctx, nextFunc);
 | 
			
		||||
    expect(nextFunc).toHaveBeenCalledTimes(1);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should skip action with condition', async () => {
 | 
			
		||||
    const middlewareFunc = acl.middleware();
 | 
			
		||||
    const ctx: any = {
 | 
			
		||||
      state: {},
 | 
			
		||||
      action: {
 | 
			
		||||
        resourceName: 'users',
 | 
			
		||||
        actionName: 'login',
 | 
			
		||||
      },
 | 
			
		||||
      app: {
 | 
			
		||||
        acl,
 | 
			
		||||
      },
 | 
			
		||||
      throw() {},
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const nextFunc = jest.fn();
 | 
			
		||||
 | 
			
		||||
    let skip = false;
 | 
			
		||||
    acl.skip('users', 'login', (ctx) => {
 | 
			
		||||
      return skip;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await middlewareFunc(ctx, nextFunc);
 | 
			
		||||
    expect(nextFunc).toHaveBeenCalledTimes(0);
 | 
			
		||||
 | 
			
		||||
    skip = true;
 | 
			
		||||
    await middlewareFunc(ctx, nextFunc);
 | 
			
		||||
    expect(nextFunc).toHaveBeenCalledTimes(1);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should skip action with registered condition', async () => {
 | 
			
		||||
    const middlewareFunc = acl.middleware();
 | 
			
		||||
 | 
			
		||||
    const conditionFn = jest.fn();
 | 
			
		||||
    acl.skipManager.registerSkipCondition('superUser', () => {
 | 
			
		||||
      conditionFn();
 | 
			
		||||
      return true;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const ctx: any = {
 | 
			
		||||
      state: {},
 | 
			
		||||
      action: {
 | 
			
		||||
        resourceName: 'users',
 | 
			
		||||
        actionName: 'login',
 | 
			
		||||
      },
 | 
			
		||||
      app: {
 | 
			
		||||
        acl,
 | 
			
		||||
      },
 | 
			
		||||
      throw() {},
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const nextFunc = jest.fn();
 | 
			
		||||
 | 
			
		||||
    acl.skip('users', 'login', 'superUser');
 | 
			
		||||
 | 
			
		||||
    await middlewareFunc(ctx, nextFunc);
 | 
			
		||||
    expect(nextFunc).toHaveBeenCalledTimes(1);
 | 
			
		||||
    expect(conditionFn).toHaveBeenCalledTimes(1);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@ -5,6 +5,7 @@ import lodash from 'lodash';
 | 
			
		||||
import { AclAvailableAction, AvailableActionOptions } from './acl-available-action';
 | 
			
		||||
import { ACLAvailableStrategy, AvailableStrategyOptions, predicate } from './acl-available-strategy';
 | 
			
		||||
import { ACLRole, RoleActionParams } from './acl-role';
 | 
			
		||||
import { SkipManager } from './skip-manager';
 | 
			
		||||
const parse = require('json-templates');
 | 
			
		||||
 | 
			
		||||
interface CanResult {
 | 
			
		||||
@ -46,6 +47,8 @@ export class ACL extends EventEmitter {
 | 
			
		||||
  protected availableStrategy = new Map<string, ACLAvailableStrategy>();
 | 
			
		||||
  protected middlewares = [];
 | 
			
		||||
 | 
			
		||||
  public skipManager = new SkipManager(this);
 | 
			
		||||
 | 
			
		||||
  roles = new Map<string, ACLRole>();
 | 
			
		||||
 | 
			
		||||
  actionAlias = new Map<string, string>();
 | 
			
		||||
@ -81,6 +84,8 @@ export class ACL extends EventEmitter {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.middlewares.push(this.skipManager.aclMiddleware());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  define(options: DefineOptions): ACLRole {
 | 
			
		||||
@ -207,6 +212,10 @@ export class ACL extends EventEmitter {
 | 
			
		||||
    this.middlewares.push(fn);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  skip(resourceName: string, actionName: string, condition?: any) {
 | 
			
		||||
    this.skipManager.skip(resourceName, actionName, condition);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  middleware() {
 | 
			
		||||
    const acl = this;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										76
									
								
								packages/acl/src/skip-manager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								packages/acl/src/skip-manager.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,76 @@
 | 
			
		||||
import { ACL } from './acl';
 | 
			
		||||
 | 
			
		||||
type ConditionFunc = (ctx: any) => boolean;
 | 
			
		||||
 | 
			
		||||
export class SkipManager {
 | 
			
		||||
  protected skipActions = new Map<string, Map<string, string | ConditionFunc | true>>();
 | 
			
		||||
 | 
			
		||||
  protected registeredCondition = new Map<string, ConditionFunc>();
 | 
			
		||||
 | 
			
		||||
  constructor(public acl: ACL) {
 | 
			
		||||
    this.registerSkipCondition('logged-in', (ctx) => {
 | 
			
		||||
      return ctx.state.currentUser;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  skip(resourceName: string, actionName: string, condition?: string | ConditionFunc) {
 | 
			
		||||
    const actionMap = this.skipActions.get(resourceName) || new Map<string, string | ConditionFunc>();
 | 
			
		||||
    actionMap.set(actionName, condition || true);
 | 
			
		||||
 | 
			
		||||
    this.skipActions.set(resourceName, actionMap);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getSkippedConditions(resourceName: string, actionName: string): Array<ConditionFunc | true> {
 | 
			
		||||
    const fetchActionSteps: string[] = ['*', resourceName];
 | 
			
		||||
 | 
			
		||||
    const results = [];
 | 
			
		||||
 | 
			
		||||
    for (const fetchActionStep of fetchActionSteps) {
 | 
			
		||||
      const resource = this.skipActions.get(fetchActionStep);
 | 
			
		||||
      if (resource) {
 | 
			
		||||
        const condition = resource.get('*') || resource.get(actionName);
 | 
			
		||||
        if (condition) {
 | 
			
		||||
          results.push(typeof condition === 'string' ? this.registeredCondition.get(condition) : condition);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return results;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  registerSkipCondition(name: string, condition: ConditionFunc) {
 | 
			
		||||
    this.registeredCondition.set(name, condition);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  aclMiddleware() {
 | 
			
		||||
    return async (ctx, next) => {
 | 
			
		||||
      const { resourceName, actionName } = ctx.action;
 | 
			
		||||
      const skippedConditions = ctx.app.acl.skipManager.getSkippedConditions(resourceName, actionName);
 | 
			
		||||
      let skip = false;
 | 
			
		||||
 | 
			
		||||
      for (const skippedCondition of skippedConditions) {
 | 
			
		||||
        if (skippedCondition) {
 | 
			
		||||
          let skipResult = false;
 | 
			
		||||
 | 
			
		||||
          if (typeof skippedCondition === 'function') {
 | 
			
		||||
            skipResult = skippedCondition(ctx);
 | 
			
		||||
          } else if (skippedCondition) {
 | 
			
		||||
            skipResult = true;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (skipResult) {
 | 
			
		||||
            skip = true;
 | 
			
		||||
            break;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (skip) {
 | 
			
		||||
        ctx.permission = {
 | 
			
		||||
          skip: true,
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
      await next();
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -53,13 +53,6 @@ for (const plugin of plugins) {
 | 
			
		||||
  api.plugin(require(plugin).default);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
api.acl.use(async (ctx, next) => {
 | 
			
		||||
  ctx.permission = {
 | 
			
		||||
    skip: true,
 | 
			
		||||
  };
 | 
			
		||||
  await next();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
if (process.argv.length < 3) {
 | 
			
		||||
  // @ts-ignore
 | 
			
		||||
  process.argv.push('start', '--port', process.env.API_PORT || 12302);
 | 
			
		||||
 | 
			
		||||
@ -77,6 +77,8 @@ export class CollectionManagerPlugin extends Plugin {
 | 
			
		||||
      }
 | 
			
		||||
      await next();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.app.acl.skip('collections', 'list', 'logged-in');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async load() {
 | 
			
		||||
 | 
			
		||||
@ -60,6 +60,8 @@ export class UiSchemaStoragePlugin extends Plugin {
 | 
			
		||||
      name: 'uiSchemas',
 | 
			
		||||
      actions: uiSchemaActions,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.app.acl.skip('uiSchemas', '*', 'logged-in');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async load() {
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import Database from '@nocobase/database';
 | 
			
		||||
import Database, { BelongsToManyRepository } from '@nocobase/database';
 | 
			
		||||
import PluginACL from '@nocobase/plugin-acl';
 | 
			
		||||
import { MockServer, mockServer } from '@nocobase/test';
 | 
			
		||||
 | 
			
		||||
@ -69,4 +69,50 @@ describe('role', () => {
 | 
			
		||||
    expect(roles.length).toEqual(1);
 | 
			
		||||
    expect(roles[0].get('name')).toEqual('test2');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should set users default role', async () => {
 | 
			
		||||
    await db.getRepository('roles').create({
 | 
			
		||||
      values: {
 | 
			
		||||
        name: 'test1',
 | 
			
		||||
        title: 'Admin User',
 | 
			
		||||
        allowConfigure: true,
 | 
			
		||||
        default: true,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await db.getRepository('roles').create({
 | 
			
		||||
      values: {
 | 
			
		||||
        name: 'test2',
 | 
			
		||||
        title: 'test2 user',
 | 
			
		||||
        allowConfigure: true,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const user = await db.getRepository('users').create({
 | 
			
		||||
      values: {
 | 
			
		||||
        token: '123',
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const userRolesRepo = db.getRepository<BelongsToManyRepository>('users.roles', user.get('id') as string);
 | 
			
		||||
    await userRolesRepo.add('test1');
 | 
			
		||||
    await userRolesRepo.add('test2');
 | 
			
		||||
 | 
			
		||||
    const response = await api
 | 
			
		||||
      .agent()
 | 
			
		||||
      .post('/users:setDefaultRole')
 | 
			
		||||
      .send({
 | 
			
		||||
        defaultRole: 'test2',
 | 
			
		||||
      })
 | 
			
		||||
      .set({
 | 
			
		||||
        Authorization: `Bearer ${user.get('token')}`,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
    expect(response.statusCode).toEqual(200);
 | 
			
		||||
 | 
			
		||||
    const userRoles = await userRolesRepo.find();
 | 
			
		||||
    const defaultRole = userRoles.find((userRole) => userRole.get('rolesUsers').default);
 | 
			
		||||
 | 
			
		||||
    expect(defaultRole['name']).toEqual('test2');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -148,3 +148,34 @@ export async function changePassword(ctx: Context, next: Next) {
 | 
			
		||||
  ctx.body = ctx.state.currentUser.toJSON();
 | 
			
		||||
  await next();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function setDefaultRole(ctx: Context, next: Next) {
 | 
			
		||||
  const {
 | 
			
		||||
    values: { defaultRole },
 | 
			
		||||
  } = ctx.action.params;
 | 
			
		||||
 | 
			
		||||
  const currentUserId = ctx.state.currentUser.id;
 | 
			
		||||
 | 
			
		||||
  await ctx.db.getRepository('rolesUsers').update({
 | 
			
		||||
    filter: {
 | 
			
		||||
      userId: currentUserId,
 | 
			
		||||
    },
 | 
			
		||||
    values: {
 | 
			
		||||
      default: false,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await ctx.db.getRepository('rolesUsers').update({
 | 
			
		||||
    filter: {
 | 
			
		||||
      userId: currentUserId,
 | 
			
		||||
      roleName: defaultRole,
 | 
			
		||||
    },
 | 
			
		||||
    values: {
 | 
			
		||||
      default: true,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  ctx.body = 'ok';
 | 
			
		||||
 | 
			
		||||
  await next();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								packages/plugin-users/src/collections/roles-users.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								packages/plugin-users/src/collections/roles-users.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'rolesUsers',
 | 
			
		||||
  fields: [{ type: 'boolean', name: 'default' }],
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
@ -6,19 +6,41 @@ import { Context, Next } from '@nocobase/actions';
 | 
			
		||||
// 因为是否提供匿名访问资源是应用决定的,不是使用插件就一定不能匿名访问。
 | 
			
		||||
export function parseToken(options?: any) {
 | 
			
		||||
  return async function parseToken(ctx: Context, next: Next) {
 | 
			
		||||
    const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, '');
 | 
			
		||||
    const User = ctx.db.getCollection('users');
 | 
			
		||||
    const user = await User.repository.findOne({
 | 
			
		||||
      filter: {
 | 
			
		||||
        token,
 | 
			
		||||
      },
 | 
			
		||||
      appends: ['roles'],
 | 
			
		||||
    });
 | 
			
		||||
    const user = await findUserByToken(ctx);
 | 
			
		||||
 | 
			
		||||
    if (user) {
 | 
			
		||||
      ctx.state.currentUser = user;
 | 
			
		||||
      setCurrentRole(ctx, user);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return next();
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setCurrentRole(ctx, user) {
 | 
			
		||||
  const userRoles = user.get('roles');
 | 
			
		||||
  let userRole;
 | 
			
		||||
 | 
			
		||||
  if (userRoles.length == 1) {
 | 
			
		||||
    userRole = userRoles[0].get('name');
 | 
			
		||||
  } else if (userRoles.length > 1) {
 | 
			
		||||
    const defaultRole = userRoles.findIndex((role) => role.get('rolesUsers').default);
 | 
			
		||||
    userRole = defaultRole !== -1 ? userRoles[defaultRole] : userRoles[0];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (userRole) {
 | 
			
		||||
    ctx.state.currentRole = userRole;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function findUserByToken(ctx: Context) {
 | 
			
		||||
  const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, '');
 | 
			
		||||
  const User = ctx.db.getCollection('users');
 | 
			
		||||
  const user = await User.repository.findOne({
 | 
			
		||||
    filter: {
 | 
			
		||||
      token,
 | 
			
		||||
    },
 | 
			
		||||
    appends: ['roles'],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return user;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -59,6 +59,16 @@ export default class UsersPlugin extends Plugin {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.app.resourcer.use(middlewares.parseToken());
 | 
			
		||||
 | 
			
		||||
    const publicActions = ['check', 'signin', 'signup', 'lostpassword', 'resetpassword', 'getUserByResetToken'];
 | 
			
		||||
    const loggedInActions = ['signout', 'updateProfile', 'changePassword', 'setDefaultRole'];
 | 
			
		||||
 | 
			
		||||
    publicActions.forEach((action) => this.app.acl.skip('users', action));
 | 
			
		||||
    loggedInActions.forEach((action) => this.app.acl.skip('users', action, 'logged-in'));
 | 
			
		||||
 | 
			
		||||
    this.app.acl.skip('*', '*', (ctx) => {
 | 
			
		||||
      return ctx.state.currentUser?.id == 1;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async load() {
 | 
			
		||||
@ -67,13 +77,22 @@ export default class UsersPlugin extends Plugin {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async install() {
 | 
			
		||||
  getRootUserInfo() {
 | 
			
		||||
    const {
 | 
			
		||||
      adminNickname = 'Super Admin',
 | 
			
		||||
      adminEmail = 'admin@nocobase.com',
 | 
			
		||||
      adminPassword = 'admin123',
 | 
			
		||||
    } = this.options;
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      adminNickname,
 | 
			
		||||
      adminEmail,
 | 
			
		||||
      adminPassword,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  async install() {
 | 
			
		||||
    const { adminNickname, adminPassword, adminEmail } = this.getRootUserInfo();
 | 
			
		||||
 | 
			
		||||
    const User = this.db.getCollection('users');
 | 
			
		||||
    await User.repository.create({
 | 
			
		||||
      values: {
 | 
			
		||||
 | 
			
		||||
@ -102,6 +102,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    registerMiddlewares(this, options);
 | 
			
		||||
 | 
			
		||||
    if (options.registerActions !== false) {
 | 
			
		||||
      registerActions(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user