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:
ChengLei Shao 2022-03-11 10:10:57 +08:00 committed by GitHub
parent accb2a59b9
commit d98714d9fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 318 additions and 18 deletions

View 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);
});
});

View File

@ -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;

View 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();
};
}
}

View File

@ -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);

View File

@ -77,6 +77,8 @@ export class CollectionManagerPlugin extends Plugin {
}
await next();
});
this.app.acl.skip('collections', 'list', 'logged-in');
}
async load() {

View File

@ -60,6 +60,8 @@ export class UiSchemaStoragePlugin extends Plugin {
name: 'uiSchemas',
actions: uiSchemaActions,
});
this.app.acl.skip('uiSchemas', '*', 'logged-in');
}
async load() {

View File

@ -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');
});
});

View File

@ -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();
}

View File

@ -0,0 +1,6 @@
import { CollectionOptions } from '@nocobase/database';
export default {
name: 'rolesUsers',
fields: [{ type: 'boolean', name: 'default' }],
} as CollectionOptions;

View File

@ -6,6 +6,33 @@ import { Context, Next } from '@nocobase/actions';
// 因为是否提供匿名访问资源是应用决定的,不是使用插件就一定不能匿名访问。
export function parseToken(options?: any) {
return async function parseToken(ctx: Context, next: Next) {
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({
@ -15,10 +42,5 @@ export function parseToken(options?: any) {
appends: ['roles'],
});
if (user) {
ctx.state.currentUser = user;
}
return next();
};
return user;
}

View File

@ -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: {

View File

@ -102,6 +102,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
});
registerMiddlewares(this, options);
if (options.registerActions !== false) {
registerActions(this);
}