Feature: add permission plugin api (#57)

* fix: update method of permission

* 页面访问权限

* feat: add tabs update in role

* refactor: change main action permission check into single file

* feat: add pluginLoaded event listener for init page permissions

* refactor: change to can api

* refactor: use constants for role types

* refactor: can api

* 只处理 actions 表里的权限,其余跳过

* 注释掉 fields

* bugfix

* add: 详情模块编辑按钮权限判断

* test: add cases

* fix: add association permission judgment

* fix: add disabled property to drawer select

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2021-01-20 08:33:47 +08:00 committed by GitHub
parent eff2353954
commit bd756a6a5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1013 additions and 328 deletions

View File

@ -28,7 +28,7 @@ const api = Api.create({
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
},
logging: false,
logging: process.env.DB_LOG_SQL === 'on' ? console.log : false,
define: {},
sync,
},

View File

@ -7,7 +7,7 @@ api.resourcer.use(async (ctx: actions.Context, next) => {
// ctx.state.developerMode = {[Op.not]: null};
ctx.state.developerMode = false;
if (table && table.hasField('developerMode') && ctx.state.developerMode === false) {
ctx.action.mergeParams({ filter: { developerMode: ctx.state.developerMode } }, { filter: 'and' });
ctx.action.mergeParams({ filter: { "developerMode.$isFalsy": true } }, { filter: 'and' });
}
if (table && ['get', 'list'].includes(actionName)) {
const except = [];

View File

@ -0,0 +1,36 @@
import api from '../app';
import Database from '@nocobase/database';
(async () => {
await api.loadPlugins();
const database: Database = api.database;
await api.database.sync({
// tables: ['roles'],
});
const [Collection, User, Role] = database.getModels(['collections', 'users', 'roles']);
const tables = database.getTables();
for (let table of tables) {
console.log(table.getName());
await Collection.import(table.getOptions(), { update: true, migrate: false });
}
let user = await User.findOne({
where: {
username: "admin",
},
});
const role = await Role.findOne({
where: {
title: '系统开发组',
}
});
role.set('type', -1);
await role.save();
await user.setRoles([role]);
})();

View File

@ -138,13 +138,13 @@ const data = [
await Collection.import(table.getOptions(), { update: true, migrate: false });
}
await Page.import(data);
const user = await User.findOne({
let user = await User.findOne({
where: {
username: "admin",
},
});
if (!user) {
await User.create({
user = await User.create({
nickname: "超级管理员",
password: "admin",
username: "admin",
@ -180,11 +180,18 @@ const data = [
}
const Role = database.getModel('roles');
const roles = await Role.bulkCreate([
{ title: '系统开发组' },
{ title: '数据管理组' },
{ title: '普通用户组' },
{ title: '未登录用户组' },
{ title: '系统开发组', type: -1 },
{ title: '匿名用户组', type: 0 },
]);
await roles[0].updateAssociations({
users: user
});
const Action = database.getModel('actions');
// 全局
await Action.bulkCreate([
]);
await database.getModel('collections').import(require('./collections/example').default);
await database.getModel('collections').import(require('./collections/authors').default);
await database.getModel('collections').import(require('./collections/books').default);

View File

@ -1,6 +1,7 @@
.action-buttons {
display: flex;
justify-content: flex-end;
min-height: 32px;
.action-button {
margin-right: 8px;
&:last-child {

View File

@ -28,7 +28,7 @@ function transform({value, multiple, labelField, valueField = 'id'}) {
}
function DrawerSelectComponent(props) {
const { target, multiple, associatedName, labelField, valueField = 'id', value, onChange } = props;
const { disabled, target, multiple, associatedName, labelField, valueField = 'id', value, onChange } = props;
const [selectedKeys, selectedValue] = transform({value, multiple, labelField, valueField });
const [visible, setVisible] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState(multiple ? selectedKeys : [selectedKeys]);
@ -38,6 +38,7 @@ function DrawerSelectComponent(props) {
return (
<>
<Select
disabled={disabled}
open={false}
mode={multiple ? 'tags' : undefined}
labelInValue
@ -61,7 +62,9 @@ function DrawerSelectComponent(props) {
}
}}
onClick={() => {
setVisible(true);
if (!disabled) {
setVisible(true);
}
}}
></Select>
<Drawer

View File

@ -85,10 +85,10 @@ Permissions.Actions = connect({
if (index === -1) {
values.push({
name: `${resourceKey}:${record.name}`,
scope: data,
scope_id: data,
});
} else {
set(values, [index, 'scope'], data);
set(values, [index, 'scope_id'], data);
}
console.log('valvalvalvalval', {values})
onChange(values);

View File

@ -101,8 +101,11 @@ export function BooleanField(props: any) {
if (editable) {
return <Checkbox defaultChecked={value} onChange={async (e) => {
await api.resource(resource).update({
associatedKey: data.id,
associatedKey: data.associatedKey,
resourceKey: data.id,
tableName: data.tableName||'pages',
values: {
tableName: data.tableName||'pages',
[name]: e.target.checked,
},
});

View File

@ -48,6 +48,7 @@ export default function ViewFactory(props: ViewProps) {
associatedKey,
resourceName,
resourceTarget,
resourceKey,
resourceFieldName,
viewName,
mode,
@ -59,6 +60,7 @@ export default function ViewFactory(props: ViewProps) {
const params = {
resourceKey: viewName,
values: {
resourceKey,
associatedKey: associatedKey,
associatedName: associatedName,
resourceFieldName: resourceName,

View File

@ -7,6 +7,7 @@
"@nocobase/server": "^0.3.0-alpha.0",
"@nocobase/database": "^0.3.0-alpha.0",
"@nocobase/resourcer": "^0.3.0-alpha.0",
"@nocobase/actions": "^0.3.0-alpha.0",
"@nocobase/client": "^0.3.0-alpha.0",
"react": "16.14.0"
}

View File

@ -9,6 +9,9 @@ const transforms = {
if (!field.get('component.showInTable')) {
continue;
}
if (!context.listFields.includes(field.id)) {
continue;
}
arr.push({
...field.get(),
sorter: field.get('sortable'),
@ -25,6 +28,9 @@ const transforms = {
if (!field.get('component.showInForm')) {
continue;
}
if (!ctx.listFields.includes(field.id)) {
continue;
}
const interfaceType = field.get('interface');
const type = field.get('component.type') || 'string';
const prop: any = {
@ -32,6 +38,13 @@ const transforms = {
title: field.title||field.name,
...(field.component||{}),
}
if (ctx.formMode === 'update') {
if (!ctx.updateFields.includes(field.id)) {
set(prop, 'x-component-props.disabled', true);
}
} else if (!ctx.createFields.includes(field.id)) {
set(prop, 'x-component-props.disabled', true);
}
if (field.get('name') === 'interface' && ctx.state.developerMode === false) {
const dataSource = field.get('dataSource').filter(item => item.key !== 'developerMode');
field.set('dataSource', dataSource);
@ -144,6 +157,9 @@ const transforms = {
if (!get(field.component, 'showInDetail')) {
continue;
}
if (!context.listFields.includes(field.id)) {
continue;
}
const props = {};
if (field.get('interface') === 'subTable') {
const children = await field.getChildren({
@ -163,7 +179,7 @@ const transforms = {
filter: {
type: 'filter',
'x-component-props': {
fields: fields.filter(field => field.get('filterable')),
fields: fields.filter(field => ctx.listFields.includes(field.id) && field.get('filterable')),
},
},
}
@ -184,7 +200,33 @@ export default async (ctx, next) => {
// },
}));
let throughName;
const { associatedName, resourceFieldName, associatedKey } = values;
const { resourceKey: resourceKey2, associatedName, resourceFieldName, associatedKey } = values;
const permissions = await ctx.can(resourceName).permissions();
ctx.listFields = [];
ctx.createFields = [];
ctx.updateFields = [];
ctx.allowedActions = [];
for (const action of permissions.actions) {
ctx.allowedActions.push(action.name);
}
console.log(ctx.allowedActions);
for (const permissionField of permissions.fields) {
const pfc = permissionField.actions;
if (pfc.includes(`${resourceName}:list`)) {
ctx.listFields.push(permissionField.field_id);
}
if (pfc.includes(`${resourceName}:create`)) {
ctx.createFields.push(permissionField.field_id);
}
if (pfc.includes(`${resourceName}:update`)) {
ctx.updateFields.push(permissionField.field_id);
}
}
console.log({
listFields: ctx.listFields,
createFields: ctx.createFields,
updateFields: ctx.updateFields,
})
if (associatedName) {
const table = ctx.db.getTable(associatedName);
const resourceField = table.getField(resourceFieldName);
@ -260,6 +302,7 @@ export default async (ctx, next) => {
// view.setDataValue('viewCollectionName', view.collection_name);
let title = collection.get('title');
const mode = get(ctx.action.params, ['values', 'mode'], ctx.action.params.mode);
ctx.formMode = mode;
if (mode === 'update') {
title = `编辑${title}`;
} else {
@ -337,7 +380,7 @@ export default async (ctx, next) => {
},
{
"title": "访问权限",
"name": "accessable",
"name": "accessible",
"interface": "boolean",
"type": "boolean",
"parent_id": null,
@ -349,7 +392,7 @@ export default async (ctx, next) => {
"type": "boolean",
"showInTable": true,
},
"dataIndex": ["accessable"]
"dataIndex": ["accessible"]
}
],
};
@ -452,13 +495,22 @@ export default async (ctx, next) => {
},
};
} else {
let allowedUpdate = false;
if (view.type === 'details' && await ctx.can(resourceName).act('update').one(resourceKey2)) {
allowedUpdate = true;
}
ctx.body = {
...view.get(),
title,
actionDefaultParams,
original: fields,
fields: await (transforms[view.type]||transforms.table)(fields, ctx),
actions: actions.filter(action => actionNames.includes(action.name)).map(action => ({
actions: actions.filter(action => {
if (view.type === 'details' && action.name === 'update') {
return allowedUpdate;
}
return actionNames.includes(action.name) && ctx.allowedActions.includes(`${resourceName}:${action.name}`);
}).map(action => ({
...action.toJSON(),
...action.options,
// viewCollectionName: action.collection_name,

View File

@ -0,0 +1,111 @@
import { actions } from '@nocobase/actions';
import Database from '@nocobase/database';
import { flatToTree } from '../utils';
import { Op } from 'sequelize';
export async function list(ctx: actions.Context, next: actions.Next) {
const database: Database = ctx.db;
const { associatedKey } = ctx.action.params;
const Page = database.getModel('pages');
const Collection = database.getModel('collections');
let pages = await Page.findAll(Page.parseApiJson(ctx.state.developerMode ? {
filter: {
'parent_id.$notNull': true,
},
sort: ['sort'],
} : {
filter: {
'parent_id.$notNull': true,
developerMode: {'$isFalsy': true},
},
sort: ['sort'],
}));
const items = [];
for (const page of pages) {
items.push({
id: page.id,
key: `page-${page.id}`,
title: page.title,
tableName: 'pages',
parent_id: `page-${page.parent_id}`,
associatedKey,
accessible: false, // TODO 对接权限
});
if (page.get('path') === '/collections') {
const collections = await Collection.findAll(Collection.parseApiJson(ctx.state.developerMode ? {
filter: {
showInDataMenu: true,
},
sort: ['sort'],
}: {
filter: {
developerMode: {'$isFalsy': true},
showInDataMenu: true,
},
sort: ['sort'],
}));
for (const collection of collections) {
items.push({
associatedKey,
id: collection.id,
key: `collection-${collection.id}`,
tableName: 'collections',
title: collection.get('title'),
parent_id: `page-${page.id}`,
accessible: false, // TODO 对接权限
});
const views = await collection.getViews({
where: {
[Op.or]: [
{ showInDataMenu: true },
{ default: true }
]
},
order: [['sort', 'asc']]
});
if (views.length > 1) {
for (const view of views) {
items.push({
associatedKey,
id: view.id,
tableName: 'views',
title: view.title,
key: `view-${view.id}`,
parent_id: `collection-${collection.id}`,
accessible: false, // TODO 对接权限
});
}
}
}
}
}
const data = flatToTree(items, {
id: 'key',
parentId: 'parent_id',
children: 'children',
});
ctx.body = data;
// TODO: 暂时 action 中间件就这么写了
// ctx.action.mergeParams({associated: null});
// const { associatedKey } = ctx.action.params;
// ctx.action.mergeParams({
// filter: {
// 'parent_id.$notNull': true,
// }
// })
// const done = async () => {
// ctx.body.rows = ctx.body.rows.map(row => {
// row.setDataValue('tableName', 'pages');
// row.setDataValue('associatedKey', parseInt(associatedKey));
// return row.get();
// });
// console.log(ctx.body.rows);
// await next();
// }
// return actions.common.list(ctx, done);
}
export async function update(ctx: actions.Context, next: actions.Next) {
ctx.body = {};
await next();
}

View File

@ -5,6 +5,7 @@ import getCollection from './actions/getCollection';
import getView from './actions/getView';
import getRoutes from './actions/getRoutes';
import getPageInfo from './actions/getPageInfo';
import * as rolesPagesActions from './actions/roles.pages';
export default async function (options = {}) {
const database: Database = this.database;
@ -18,6 +19,10 @@ export default async function (options = {}) {
resourcer.registerActionHandler('getView', getView);
resourcer.registerActionHandler('getPageInfo', getPageInfo);
resourcer.registerActionHandler('pages:getRoutes', getRoutes);
Object.keys(rolesPagesActions).forEach(actionName => {
resourcer.registerActionHandler(`roles.pages:${actionName}`, rolesPagesActions[actionName]);
});
/*
const [Collection, Page, View] = database.getModels(['collections', 'pages', 'views']);

View File

@ -0,0 +1,319 @@
import { Op } from 'sequelize';
import { ROLE_TYPE_ANONYMOUS, ROLE_TYPE_ROOT, ROLE_TYPE_USER } from './constants';
function getPermissions(roles) {
return roles.reduce((permissions, role) => permissions.concat(role.get('permissions')), []);
}
function getActionPermissions(permissions) {
const actionsMap = new Map<string, any>();
permissions.forEach(permission => {
permission.get('actions').forEach(action => {
// 如果没有同名 action
if (!actionsMap.has(action.name)) {
actionsMap.set(action.name, action);
return;
}
const existedScope = actionsMap.get(action.name).get('scope');
// 如果之前的同名 action 没有 scope 或 filter 为空
if (!existedScope || !existedScope.get('filter') || !Object.keys(existedScope.get('filter')).length) {
actionsMap.set(action.name, action);
return;
}
const newScope = action.get('scope');
// 如果新 action 没有 scope 或 filter 为空
if (!newScope || !newScope.get('filter') || !Object.keys(newScope.get('filter')).length) {
return;
}
// 以 or 关心合并两个 scope 中的 filter
existedScope.set('filter', { 'or': [existedScope.get('filter'), newScope.get('filter')] });
});
});
return Array.from(actionsMap.values());
}
function getFieldPermissions(permissions) {
const fieldsMap = new Map<string, any>();
permissions.forEach(permission => {
permission.get('fields_permissions').forEach(field => {
if (!fieldsMap.has(field.field_id)) {
fieldsMap.set(field.field_id, field);
return;
}
const existedActions = fieldsMap.get(field.field_id).get('actions');
if (!existedActions || !existedActions.length) {
fieldsMap.set(field.field_id, field);
return;
}
const newActions = field.get('actions');
if (!newActions || !newActions.length) {
return;
}
const actions = new Set(existedActions);
newActions.forEach(item => actions.add(item));
fieldsMap.get(field.field_id).set('actions', Array.from(actions));
});
});
return Array.from(fieldsMap.values());
}
function getTabPemissions(permissions) {
const tabs = new Set();
permissions.forEach(permission => {
permission.get('tabs_permissions').forEach(tabPermission => tabs.add(tabPermission.tab_id));
});
return Array.from(tabs);
}
export type CollectionPermissions = {
actions: any[];
fields: any[];
tabs: any[]
};
export type PermissionParams = true | null | {
filter: any,
fields: any[]
};
// ctx.can = new AccessController(ctx).can;
// ctx.can('collection').permissions()
// => [{ name: 'list', scope: { id: 1, filter: {} } }]
// ctx.can('collection').act('list').any()
// => { filter: {}, fields: [] }
// ctx.can('collection').act('update').any()
// ctx.can('collection').act('update').one(resourceKey)
// ctx.can('collection').act('get').one(resourceKey)
export default class AccessController {
context;
resourceName: string | null = null;
actionName: string | null = null;
constructor(ctx) {
this.context = ctx;
}
can = (resourceName: string | null) => {
this.resourceName = resourceName;
this.actionName = null;
return this;
};
act(name: string | null) {
this.actionName = name;
return this;
}
async permissions(): Promise<CollectionPermissions> {
const roles = await this.getRolesWithPermissions();
if (roles.some(role => role.type === ROLE_TYPE_ROOT)) {
return this.getRootPermissions();
}
const permissions = getPermissions(roles);
return {
actions: getActionPermissions(permissions),
fields: getFieldPermissions(permissions),
tabs: getTabPemissions(permissions)
};
}
async any(): Promise<PermissionParams> {
const roles = await this.getRolesWithPermissions();
if (roles.some(role => role.type === ROLE_TYPE_ROOT)) {
return true;
}
// 只处理 actions 表里的权限,其余跳过
const getActionNames = await this.getActionNames();
if (!getActionNames.includes(this.actionName)) {
console.log(`skip ${this.resourceName}:${this.actionName}`);
return true;
}
const permissions = getPermissions(roles);
const actionPermissions = getActionPermissions(permissions);
if (!actionPermissions.length) {
// 如果找不到可用的 action 记录
// 则认为没有权限
return null;
}
const filters = actionPermissions
.filter(item => Boolean(item.scope) && Object.keys(item.scope.filter).length)
.map(item => item.scope.filter);
const fields = getFieldPermissions(permissions);
return {
filter: filters.length
? filters.length > 1 ? { or: filters } : filters[0]
: {},
fields
};
}
async one(resourceKey): Promise<PermissionParams> {
const any = await this.any();
if (!any || any === true) {
return any;
}
const { filter } = any;
const Collection = this.context.db.getModel(this.resourceName);
const existed = await Collection.count({
where: {
...Collection.parseApiJson({ filter }).where,
[Collection.primaryKeyAttribute]: resourceKey
}
});
return existed ? any : null;
}
async getRolesWithPermissions() {
const { context, resourceName, actionName = null } = this;
if (!resourceName) {
throw new Error('resource name must be set first by `can(resourceName)`');
}
const Role = context.db.getModel('roles');
const permissionInclusion = {
association: 'permissions',
where: {
collection_name: resourceName
},
required: true,
include: [
{
association: 'actions',
where: actionName ? {
name: `${resourceName}:${actionName}`
} : {},
required: true,
// 对 hasMany 关系可以进行拆分查询,避免联表过多标识符超过 PG 的 64 字符限制
separate: true,
include: [
{
association: 'scope'
}
]
},
{
association: 'fields_permissions',
include: [
{
association: 'field'
}
],
separate: true,
},
{
association: 'tabs_permissions',
separate: true,
}
],
};
let userRoles = [];
// 获取登入用户的角色及权限
const { currentUser } = context.state;
if (currentUser) {
const rootRoles = await currentUser.getRoles({
where: {
type: ROLE_TYPE_ROOT
}
});
if (rootRoles.length) {
return rootRoles;
}
userRoles = await currentUser.getRoles({
where: {
type: ROLE_TYPE_USER
},
include: [
permissionInclusion
]
});
}
// 获取匿名用户的角色及权限
const anonymousRoles = await Role.findAll({
where: {
type: ROLE_TYPE_ANONYMOUS
},
include: [
permissionInclusion
]
});
return [...anonymousRoles, ...userRoles];
}
async getActionNames() {
const { context, resourceName = null } = this;
const Action = context.db.getModel('actions');
const actions = await Action.findAll({
where: {
collection_name: { [Op.or]: [resourceName, null] }
}
});
return actions.map(action => action.name);
}
async getRootPermissions(): Promise<CollectionPermissions> {
const { context, resourceName = null } = this;
const Action = context.db.getModel('actions');
const actions = await Action.findAll({
where: {
collection_name: { [Op.or]: [resourceName, null] }
}
});
const Field = context.db.getModel('fields');
const fields = await Field.findAll({
where: {
collection_name: resourceName
}
});
const Tab = context.db.getModel('tabs');
const tabs = await Tab.findAll({
where: {
collection_name: resourceName
},
attribute: [Tab.primaryKeyAttribute]
});
const actionsNames = actions.map(action => ({
name: `${resourceName}:${action.name}`
}));
return {
actions: actionsNames,
fields: fields.map(field => ({
field,
field_id: field.id,
actions: actionsNames.map(({ name }) => name)
})),
tabs: tabs.map(tab => tab[Tab.primaryKeyAttribute])
}
}
}

View File

@ -0,0 +1,75 @@
import { getApp } from '.';
import AccessController from '../AccessController';
describe('AccessController', () => {
let app;
let db;
const ac: any = {};
beforeEach(async () => {
app = await getApp();
db = app.database;
ac.anonymous = new AccessController({
state: {},
db
});
const User = db.getModel('users');
const user = await User.findByPk(1);
ac.user = new AccessController({
state: { currentUser: user },
db
});
const root = await User.findByPk(4);
ac.root = new AccessController({
state: { currentUser: root },
db
});
});
afterEach(() => db.close());
describe('can().permissions()', () => {
it('anonymous', async () => {
const result = await ac.anonymous.can('posts').permissions();
expect(result).toMatchObject({
actions: [
{ name: 'posts:list', scope: { filter: { status: 'published' }, collection_name: 'posts' } }
],
fields: [
{ actions: ['posts:list'] }
],
tabs: []
});
});
});
describe('can().act()', () => {
it('normal user invoke can().permissions() invoked after .act()', async () => {
const actionResult = await ac.user.can('posts').act('list').any();
expect(actionResult.filter).toEqual({
or: [
{ status: 'published' },
{ status: 'draft', 'created_by_id.$currentUser': true }
]
});
const permissionsResult = await ac.user.can('posts').permissions();
expect(permissionsResult).toMatchObject({
actions: [
{
name: 'posts:list',
scope: { filter: { or: [{ status: 'published' }, { status: 'draft', 'created_by_id.$currentUser': true }] } }
},
{
name: 'posts:update',
scope: { filter: { status: 'draft', 'created_by_id.$currentUser': true }, collection_name: 'posts' },
}
],
tabs: []
});
});
});
});

View File

@ -8,6 +8,7 @@ import { actions, middlewares } from '@nocobase/actions';
import { Application } from '@nocobase/server';
import middleware from '@nocobase/server/src/middleware';
import plugin from '../server';
import seed from './seed';
function getTestKey() {
const { id } = require.main;
@ -65,7 +66,7 @@ export async function getApp() {
});
try {
await app.database.sync();
}catch(err) {
} catch(err) {
console.error(err);
}
@ -74,6 +75,9 @@ export async function getApp() {
// 创建和更新里面仍会再次创建 fields导致创建相关的数据重复数据库报错。
await app.database.getModel('collections').import(table.getOptions(), { update: true, migrate: false });
}
await seed(app.database);
app.context.db = app.database;
app.use(bodyParser());
app.use(middleware({

View File

@ -1,6 +1,7 @@
import { getApp, getAgent, getAPI } from '.';
import AccessController from '../AccessController';
describe.skip('list', () => {
describe('list', () => {
let app;
let agent;
let api;
@ -13,12 +14,7 @@ describe.skip('list', () => {
db = app.database;
const User = db.getModel('users');
const users = await User.bulkCreate([
{ username: 'user1', token: 'token1' },
{ username: 'user2', token: 'token2' },
{ username: 'user3', token: 'token3' },
{ username: 'user4', token: 'token4' },
]);
const users = await User.findAll({ order: [['id', 'ASC']] });
userAgents = users.map(user => {
const userAgent = getAgent(app);
@ -26,142 +22,6 @@ describe.skip('list', () => {
return userAgent;
});
const Role = db.getModel('roles');
const roles = await Role.bulkCreate([
{ title: '匿名用户', type: 0 },
{ title: '普通用户' },
{ title: '编辑' },
{ title: '管理员', type: -1 },
]);
const Field = db.getModel('fields');
const postTitleField = await Field.findOne({
where: {
name: 'title',
collection_name: 'posts'
}
});
const postStatusField = await Field.findOne({
where: {
name: 'status',
collection_name: 'posts'
}
});
const postCategoryField = await Field.findOne({
where: {
name: 'category',
collection_name: 'posts'
}
});
// 匿名用户
await roles[0].updateAssociations({
permissions: [
{
collection_name: 'posts',
actions_permissions: [
{
name: 'list',
scope: { filter: { status: 'published' }, collection_name: 'posts' },
}
],
fields: [
{
id: postTitleField.id,
fields_permissions: { actions: ['posts:list'] }
}
]
},
{
collection_name: 'categories',
actions_permissions: [
{ name: 'list' }
]
}
]
});
// 普通用户
await roles[1].updateAssociations({
users: [users[0], users[3]],
permissions: [
{
collection_name: 'posts',
actions_permissions: [
{
name: 'list',
// TODO(bug): 字段应使用 'created_by' 名称,通过程序映射成外键
scope: { filter: { status: 'draft', 'created_by_id.$currentUser': true }, collection_name: 'posts' },
},
{
name: 'update',
scope: { filter: { status: 'draft', 'created_by_id.$currentUser': true }, collection_name: 'posts' },
}
],
fields: [
{
id: postTitleField.id,
fields_permissions: { actions: ['posts:list', 'posts:create', 'posts:update'] }
},
{
id: postStatusField.id,
fields_permissions: { actions: ['posts:list'] }
},
{
id: postCategoryField.id,
fields_permissions: { actions: ['posts:list'] }
},
]
}
]
});
// 编辑
await roles[2].updateAssociations({
users: [users[1], users[3]],
permissions: [
{
collection_name: 'posts',
actions_permissions: [
{
name: 'update'
}
],
fields: [
{
id: postTitleField.id,
fields_permissions: { actions: ['posts:update'] }
},
{
id: postStatusField.id,
fields_permissions: { actions: ['posts:update'] }
},
{
id: postCategoryField.id,
fields_permissions: { actions: ['posts:update'] }
},
]
},
{
collection_name: 'categories',
actions_permissions: [
{ name: 'create' },
{ name: 'update' },
{ name: 'destroy' },
]
}
]
});
// 管理员
const Post = db.getModel('posts');
await Post.bulkCreate([
{ title: 'title1', created_by_id: users[0].id },
{ title: 'title2', created_by_id: users[0].id },
{ title: 'title3', created_by_id: users[1].id },
{ title: 'title4', created_by_id: users[3].id },
]);
});
afterEach(() => db.close());
@ -174,16 +34,15 @@ describe.skip('list', () => {
});
});
// TODO(bug): 单独执行可以通过。
// 由于 app.database.getModel('collections').import() 有 bug无法正确的完成数据构造。
describe('normal user', () => {
it('user could get posts created by self and limited fields', async () => {
const response = await userAgents[0].get('/api/posts?sort=title');
expect(response.body.count).toBe(2);
expect(response.body.rows).toEqual([
{ title: 'title1', status: 'draft', category: null },
{ title: 'title2', status: 'draft', category: null }
]);
// TODO(bug): fields 过滤暂时先跳过了
// expect(response.body.rows).toEqual([
// { title: 'title1', status: 'draft', category: null },
// { title: 'title2', status: 'draft', category: null }
// ]);
});
});
});

View File

@ -0,0 +1,148 @@
import { ROLE_TYPE_ANONYMOUS, ROLE_TYPE_ROOT, ROLE_TYPE_USER } from '../constants';
export default async function(db) {
const User = db.getModel('users');
const users = await User.bulkCreate([
{ username: 'user1', token: 'token1' },
{ username: 'user2', token: 'token2' },
{ username: 'user3', token: 'token3' },
{ username: 'user4', token: 'token4' },
]);
const Role = db.getModel('roles');
const roles = await Role.bulkCreate([
{ title: '匿名用户', type: ROLE_TYPE_ANONYMOUS },
{ title: '普通用户' },
{ title: '编辑' },
{ title: '管理员', type: ROLE_TYPE_ROOT },
]);
const Field = db.getModel('fields');
const postTitleField = await Field.findOne({
where: {
name: 'title',
collection_name: 'posts'
}
});
const postStatusField = await Field.findOne({
where: {
name: 'status',
collection_name: 'posts'
}
});
const postCategoryField = await Field.findOne({
where: {
name: 'category',
collection_name: 'posts'
}
});
// 匿名用户
await roles[0].updateAssociations({
permissions: [
{
collection_name: 'posts',
actions: [
{
name: 'posts:list',
scope: { filter: { status: 'published' }, collection_name: 'posts' },
}
],
fields_permissions: [
{
field_id: postTitleField[Field.primaryKeyAttribute],
actions: ['posts:list']
}
]
},
{
collection_name: 'categories',
actions: [
{ name: 'posts:list' }
]
}
]
}, { migrate: false });
// 普通用户
await roles[1].updateAssociations({
users: [users[0], users[3]],
permissions: [
{
collection_name: 'posts',
actions: [
{
name: 'posts:list',
// TODO(bug): 字段应使用 'created_by' 名称,通过程序映射成外键
scope: { filter: { status: 'draft', 'created_by_id.$currentUser': true }, collection_name: 'posts' },
},
{
name: 'posts:update',
scope: { filter: { status: 'draft', 'created_by_id.$currentUser': true }, collection_name: 'posts' },
}
],
fields: [
{
id: postTitleField.id,
fields_permissions: { actions: ['posts:list', 'posts:create', 'posts:update'] }
},
{
id: postStatusField.id,
fields_permissions: { actions: ['posts:list'] }
},
{
id: postCategoryField.id,
fields_permissions: { actions: ['posts:list'] }
},
]
}
]
}, { migrate: false });
// 编辑
await roles[2].updateAssociations({
users: [users[1], users[3]],
permissions: [
{
collection_name: 'posts',
actions: [
{
name: 'posts:update'
}
],
fields_permissions: [
{
field_id: postTitleField[Field.primaryKeyAttribute],
actions: ['posts:update']
},
{
field_id: postStatusField[Field.primaryKeyAttribute],
actions: ['posts:update']
},
{
field_id: postCategoryField[Field.primaryKeyAttribute],
actions: ['posts:update']
},
]
},
{
collection_name: 'categories',
actions: [
{ name: 'posts:create' },
{ name: 'posts:update' },
{ name: 'posts:destroy' },
]
}
]
}, { migrate: false });
// 管理员
const Post = db.getModel('posts');
await Post.bulkCreate([
{ title: 'title1', created_by_id: users[0].id },
{ title: 'title2', created_by_id: users[0].id },
{ title: 'title3', created_by_id: users[1].id },
{ title: 'title4', created_by_id: users[3].id },
]);
}

View File

@ -1,3 +1,4 @@
import { Op } from 'sequelize';
import { actions } from '@nocobase/actions';
export async function list(ctx: actions.Context, next: actions.Next) {
@ -18,29 +19,33 @@ export async function get(ctx: actions.Context, next: actions.Next) {
},
include: [
{
association: 'actions_permissions',
association: 'actions',
// 对 hasMany 关系可以进行拆分查询,避免联表过多标识符超过 PG 的 64 字符限制
separate: true,
include: [
{
association: 'scope',
attribute: ['filter']
},
association: 'scope'
}
]
},
{
association: 'fields_permissions',
separate: true,
},
{
association: 'tabs_permissions',
separate: true,
}
],
distinct: true,
limit: 1
});
const result = permission
? {
actions: permission.actions_permissions || [],
actions: permission.actions || [],
fields: permission.fields_permissions || [],
tabs: permission.tabs_permissions || [],
tabs: (permission.tabs_permissions || []).map(item => item.tab_id),
}
: {
actions: [],
@ -60,26 +65,114 @@ export async function update(ctx: actions.Context, next: actions.Next) {
values
} = ctx.action.params;
const transaction = await ctx.db.sequelize.transaction();
// role.getPermissions
let [permission] = await associated.getPermissions({
where: {
collection_name: resourceKey
}
},
transaction
});
if (!permission) {
// 不存在则创建
permission = await associated.createPermission({
collection_name: resourceKey,
description: values.description
});
}, { transaction });
} else {
await permission.update({ description: values.description });
// 存在则更新描述
await permission.update({ description: values.description }, { transaction });
}
await permission.updateAssociations({
actions_permissions: values.actions,
fields_permissions: values.fields,
tabs: values.tabs
// 查找对应 collection 下所有“可用”的 actions
const ActionModel = ctx.db.getModel('actions');
const availableActions = await ActionModel.findAll({
where: {
// null 代表全局 action
collection_name: {
[Op.or]: [resourceKey, null]
},
...(ctx.state.developerMode ? {} : { developerMode: false })
},
transaction
});
// 需要移除的 = 所有可用的 - 要添加的
const toRemoveActionNames = new Set<string>();
availableActions.forEach(action => {
const name = `${action.collection_name || resourceKey}:${action.name}`;
if (!values.actions.find(item => item.name === name)) {
toRemoveActionNames.add(name);
}
});
// 移除没有设置的
// 因为只知道 name不知道 id所以只能从 Model 上调用移除
const ActionPermission = ctx.db.getModel('actions_permissions');
await ActionPermission.destroy({
where: {
permission_id: permission.id,
name: Array.from(toRemoveActionNames)
},
transaction
});
// 更新或创建
const existedActions = await permission.getActions({ transaction });
for (const actionItem of values.actions) {
const action = existedActions.find(item => item.name === actionItem.name);
if (action) {
await action.update(actionItem, { transaction });
} else {
await permission.createAction(actionItem, { transaction });
}
};
const fieldsValue = values.fields.filter(field => field.actions && field.actions.length);
const existedFields = await permission.getFields_permissions({
include: [
{ association: 'field' }
],
transaction
});
const toRemoveFields = [];
const fieldsLeft = [];
existedFields.forEach(field => {
if (
!fieldsValue.find(({ field_id }) => field_id === field.field_id)
&& !(field.field.developerMode ^ ctx.state.developerMode)
) {
toRemoveFields.push(field.field);
} else {
fieldsLeft.push(field);
}
});
if (toRemoveFields.length) {
await permission.removeFields(toRemoveFields, { transaction });
}
for (const fieldItem of fieldsValue) {
const field = fieldsLeft.find(item => item.field_id === fieldItem.field_id);
if (field) {
await field.update(fieldItem, { transaction });
} else {
await permission.createFields_permission(fieldItem, { transaction });
}
}
const TabModel = ctx.db.getModel('tabs');
const existedTabs = await permission.getTabs({ transaction });
const toRemoveTabs = existedTabs.filter(tab => (
// 如果没找到
!values.tabs.find(id => tab[TabModel.primaryKeyAttribute] === id)
// 且开发者模式匹配
&& !(tab.developerMode ^ ctx.state.developerMode)));
await permission.removeTabs(toRemoveTabs, { transaction });
await permission.addTabs(values.tabs, { transaction });
await transaction.commit();
ctx.body = permission;
await next();
}

View File

@ -3,7 +3,22 @@ import { actions } from '@nocobase/actions';
export async function list(ctx: actions.Context, next: actions.Next) {
// TODO: 暂时 action 中间件就这么写了
ctx.action.mergeParams({associated: null});
return actions.common.list(ctx, next);
const { associatedKey } = ctx.action.params;
ctx.action.mergeParams({
filter: {
'parent_id.$notNull': true,
}
})
const done = async () => {
ctx.body.rows = ctx.body.rows.map(row => {
row.setDataValue('tableName', 'pages');
row.setDataValue('associatedKey', parseInt(associatedKey));
return row.get();
});
console.log(ctx.body.rows);
await next();
}
return actions.common.list(ctx, done);
}
export async function update(ctx: actions.Context, next: actions.Next) {

View File

@ -20,6 +20,7 @@ export default {
{
type: 'belongsTo',
name: 'permission',
onDelete: 'CASCADE'
},
],
} as TableOptions;

View File

@ -33,7 +33,8 @@ export default {
{
type: 'belongsTo',
name: 'collection',
targetKey: 'name'
targetKey: 'name',
onDelete: 'CASCADE'
}
],
} as TableOptions;

View File

@ -37,7 +37,8 @@ export default {
{
comment: '允许的操作集',
type: 'hasMany',
name: 'actions_permissions',
name: 'actions',
target: 'actions_permissions'
},
{
type: 'belongsToMany',
@ -59,17 +60,17 @@ export default {
// type: 'hasMany',
// name: 'views_permissions',
// },
// {
// comment: '允许的 tabs 列表',
// type: 'belongsToMany',
// name: 'tabs',
// through: 'tabs_permissions'
// },
// {
// comment: '标签页集(方便访问)',
// type: 'hasMany',
// name: 'tabs_permissions',
// },
{
comment: '允许的 tabs 列表',
type: 'belongsToMany',
name: 'tabs',
through: 'tabs_permissions'
},
{
comment: '标签页集(方便访问)',
type: 'hasMany',
name: 'tabs_permissions',
},
],
views: [
{

View File

@ -1,4 +1,5 @@
import { TableOptions } from '@nocobase/database';
import { ROLE_TYPE_ROOT, ROLE_TYPE_USER, ROLE_TYPE_ANONYMOUS } from '../constants';
export default {
name: 'roles',
@ -19,11 +20,16 @@ export default {
},
},
{
interface: 'boolean',
comment: '支持匿名用户',
type: 'boolean',
name: 'anonymous',
defaultValue: false
interface: 'select',
title: '角色类型',
type: 'integer',
name: 'type',
dataSource: [
{ value: ROLE_TYPE_ROOT, label: '系统角色' },
{ value: ROLE_TYPE_ANONYMOUS, label: '匿名角色' },
{ value: ROLE_TYPE_USER, label: '自定义角色' },
],
defaultValue: ROLE_TYPE_USER
},
// TODO(feature): 用户组后续考虑
// TODO(feature): 用户表应通过插件配置关联,考虑到今后会有多账户系统的情况

View File

@ -0,0 +1,18 @@
import { TableOptions } from '@nocobase/database';
export default {
name: 'tabs_permissions',
title: '标签页访问权限',
developerMode: true,
internal: true,
fields: [
{
type: 'belongsTo',
name: 'permission',
},
{
type: 'belongsTo',
name: 'tab'
},
],
} as TableOptions;

View File

@ -0,0 +1,3 @@
export const ROLE_TYPE_ROOT = -1;
export const ROLE_TYPE_ANONYMOUS = 0;
export const ROLE_TYPE_USER = 1;

View File

@ -1,40 +1,26 @@
import path from 'path';
import { Op } from 'sequelize';
import { Application } from '@nocobase/server';
import Database, { Operator } from '@nocobase/database';
import Resourcer from '@nocobase/resourcer';
import { Operator } from '@nocobase/database';
import * as collectionsRolesActions from './actions/collections.roles';
import * as rolesCollectionsActions from './actions/roles.collections';
import * as rolesPagesActions from './actions/roles.pages';
import AccessController from './AccessController';
// API
// const permissions = ctx.app.getPluginInstance('permissions');
// const result: boolean = permissions.check(ctx);
// const result: boolean = permissions.can(key, options);
class Permissions {
export class Permissions {
readonly app: Application;
readonly options: any;
static check(roles) {
return Boolean(this.getActionPermissions(roles).length);
}
static getActionPermissions(roles) {
const permissions = roles.reduce((permissions, role) => permissions.concat(role.get('permissions')), []);
return permissions.reduce((actions, permission) => actions.concat(permission.get('actions_permissions')), []);
}
static getFieldPermissions(roles) {
const permissions = roles.reduce((permissions, role) => permissions.concat(role.get('permissions')), []);
return permissions.reduce((fields, permission) => fields.concat(permission.get('fields_permissions')), []);
}
constructor(app: Application, options) {
this.app = app;
this.options = options;
const database: Database = app.database;
const resourcer: Resourcer = app.resourcer;
const { database, resourcer } = app;
database.import({
directory: path.resolve(__dirname, 'collections'),
@ -48,10 +34,6 @@ class Permissions {
resourcer.registerActionHandler(`roles.collections:${actionName}`, rolesCollectionsActions[actionName]);
});
Object.keys(rolesPagesActions).forEach(actionName => {
resourcer.registerActionHandler(`roles.pages:${actionName}`, rolesPagesActions[actionName]);
});
const defaultScopes = [
{
title: '全部数据',
@ -84,120 +66,57 @@ class Permissions {
// 针对“自己创建的” scope 添加特殊的操作符以生成查询条件
if (!Operator.has('$currentUser')) {
Operator.register('$currentUser', (value, { ctx }) => {
const user = this.getCurrentUser(ctx);
const user = ctx.state.currentUser;
return { [Op.eq]: user[user.constructor.primaryKeyAttribute] };
});
}
// resourcer.use(this.middleware());
resourcer.use(this.injection);
resourcer.use(this.middleware);
}
middleware() {
return async (ctx, next) => {
const {
resourceName,
actionName
} = ctx.action.params;
injection = async (ctx, next) => {
ctx.can = new AccessController(ctx).can;
const roles = await this.getRolesWithPermissions(ctx);
const actionPermissions = Permissions.getActionPermissions(roles);
return next();
};
if (!actionPermissions.length) {
return ctx.throw(404);
}
const filters = actionPermissions
.filter(item => Boolean(item.scope) && Object.keys(item.scope.filter).length)
.map(item => item.scope.filter);
const fields = new Set();
const fieldPermissions = Permissions.getFieldPermissions(roles);
fieldPermissions.forEach(item => {
const actions = item.get('actions');
if (actions && actions.includes(`${resourceName}:${actionName}`)) {
fields.add(item.get('field').get('name'));
}
});
ctx.action.mergeParams({
...(filters.length
? { filter: filters.length > 1 ? { or: filters } : filters[0] }
: {}),
fields: Array.from(fields)
});
return next();
};
}
getCurrentUser(ctx) {
// TODO: 获取当前用户应调用当前依赖用户插件的相关方法
return ctx.state.currentUser;
}
async getRolesWithPermissions(ctx) {
// TODO: 还未定义关联数据的权限如何表达
middleware = async (ctx, next) => {
const {
resourceName,
associated,
associatedName,
associatedKey,
resourceField,
resourceName,
actionName
} = ctx.action.params;
const Role = ctx.db.getModel('roles');
const permissionInclusion = {
association: 'permissions',
where: {
collection_name: resourceName
},
required: true,
include: [
{
association: 'actions_permissions',
where: {
name: actionName
},
required: true,
// 对 hasMany 关系可以进行拆分查询,避免联表过多标识符超过 PG 的 64 字符限制
separate: true,
include: [
{
association: 'scope',
attribute: ['filter']
}
]
},
{
association: 'fields_permissions',
include: [
{
association: 'field',
attributes: ['name']
}
],
separate: true,
}
],
};
// 获取匿名用户的角色及权限
const anonymousRoles = await Role.findAll({
where: {
anonymous: true
},
include: [
permissionInclusion
]
});
// 获取登入用户的角色及权限
const currentUser = this.getCurrentUser(ctx);
const userRoles = currentUser ? await currentUser.getRoles({
include: [
permissionInclusion
]
}) : [];
let result = null;
return [...anonymousRoles, ...userRoles];
// 关系数据的权限
if (associatedName && resourceField) {
result = await ctx.can(resourceField.options.target).act(actionName).any();
} else {
result = await ctx.can(resourceName).act(actionName).any();
}
if (!result) {
return this.reject(ctx);
}
if (result === true) {
return next();
}
ctx.action.mergeParams({
filter: result.filter,
// TODO: 在 fields 改进之前,先注释掉
// fields: result.fields.map(item => item.get('field').get('name'))
});
return next();
};
reject(ctx) {
ctx.throw(404);
}
}

View File

@ -3,8 +3,8 @@ import { TableOptions } from '@nocobase/database';
export default {
name: 'users',
title: '用户',
developerMode: true,
internal: true,
// developerMode: true,
// internal: true,
fields: [
{
interface: 'string',

View File

@ -8,6 +8,7 @@ export interface ApplicationOptions {
}
export class Application extends Koa {
// static const EVENT_PLUGINS_LOADED = Symbol('pluginsLoaded');
public readonly database: Database;
@ -45,7 +46,8 @@ export class Application extends Koa {
}
async loadPlugins() {
for (const plugin of this.plugins.values()) {
const allPlugins = this.plugins.values();
for (const plugin of allPlugins) {
plugin.instance = await this.loadPlugin(plugin);
}
}