feat: improve acl module (#283)

* feat: improve code

* fix: rowKey

* fix: ctx.state.currentUser

* fix: improve code

* fix: menu item permission

* fix: x-acl-action

* fix: skipScopeCheck

* feat: relationship resource permission

* fix: createdById
This commit is contained in:
chenos 2022-04-12 12:02:58 +08:00 committed by GitHub
parent 118899887c
commit 271e91b452
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 271 additions and 74 deletions

View File

@ -1,9 +1,14 @@
export interface AvailableActionOptions {
aliases?: string[] | string;
/**
* @deprecated
*/
type: 'new-data' | 'old-data';
displayName?: string;
aliases?: string[] | string;
resource?: string;
// 对新数据进行操作
onNewRecord?: boolean;
// 允许配置字段
allowConfigureFields?: boolean;
}

View File

@ -138,6 +138,11 @@ export class ACL extends EventEmitter {
}
}
getAvailableAction(name: string) {
const actionName = this.actionAlias.get(name) || name;
return this.availableActions.get(actionName);
}
getAvailableActions() {
return this.availableActions;
}
@ -216,13 +221,21 @@ export class ACL extends EventEmitter {
this.skipManager.skip(resourceName, actionName, condition);
}
parseJsonTemplate(json: any, ctx: any) {
return parse(json)({
ctx: {
state: JSON.parse(JSON.stringify(ctx.state)),
},
});
}
middleware() {
const acl = this;
const filterParams = (ctx, resourceName, params) => {
if (params?.filter?.createdById) {
const collection = ctx.db.getCollection(resourceName);
if (!collection.getField('createdById')) {
if (collection && !collection.getField('createdById')) {
return lodash.omit(params, 'filter.createdById');
}
}
@ -266,7 +279,7 @@ export class ACL extends EventEmitter {
if (params) {
const filteredParams = filterParams(ctx, resourceName, params);
const parsedParams = parse(filteredParams)({ ctx: ctxToObject(ctx) });
const parsedParams = acl.parseJsonTemplate(filteredParams, ctx);
resourcerAction.mergeParams(parsedParams);
}

View File

@ -5,6 +5,7 @@ import React, { createContext, useContext } from 'react';
import { Redirect } from 'react-router-dom';
import { useRequest } from '../api-client';
import { useCollection } from '../collection-manager';
import { useRecordIsOwn } from '../record-provider';
import { SchemaComponentOptions, useDesignable } from '../schema-component';
export const ACLContext = createContext(null);
@ -50,24 +51,50 @@ export const useRoleRecheck = () => {
const ctx = useContext(ACLContext);
const { allowAll, allowConfigure } = useACLRoleContext();
return () => {
if (allowAll) {
if (allowAll || allowConfigure) {
return;
}
ctx.refresh();
}
}
};
};
export const useACLContext = () => {
return useContext(ACLContext);
}
};
export const useACLRoleContext = () => {
const ctx = useContext(ACLContext);
const data = ctx.data?.data;
return {
...data,
getActionParams(path) {
return data?.actions?.[path];
getActionParams(path: string, { skipOwnCheck, isOwn }) {
const [resourceName, act] = path.split(':');
const currentAction = data?.actionAlias?.[act] || act;
const hasResource = data?.resources?.includes(resourceName);
const params = data?.actions?.[`${resourceName}:${currentAction}`];
if (hasResource) {
if (!skipOwnCheck && params?.own) {
return isOwn ? params : null;
}
return params;
}
const strategyActions = data?.strategy?.actions || [];
const strategyAction = strategyActions?.find((action) => {
const [value] = action.split(':');
return value === currentAction;
});
if (!strategyAction) {
return;
}
if (skipOwnCheck) {
return {};
}
const [, actionScope] = strategyAction.split(':');
if (actionScope === 'own') {
return isOwn;
}
return {};
},
};
};
@ -80,25 +107,43 @@ export const ACLAllowConfigure = (props) => {
return null;
};
export const ACLCollectionProvider = (props) => {
const { name } = useCollection();
const ACLActionParamsContext = createContext<any>({});
return <>{props.children}</>;
export const ACLCollectionProvider = (props) => {
const { allowAll, allowConfigure, getActionParams } = useACLRoleContext();
const fieldSchema = useFieldSchema();
const isOwn = useRecordIsOwn();
if (allowAll || allowConfigure) {
return <>{props.children}</>;
}
const path = fieldSchema['x-acl-action'];
const skipScopeCheck = fieldSchema['x-acl-action-props']?.skipScopeCheck;
if (!path) {
return <>{props.children}</>;
}
const params = getActionParams(path, { isOwn, skipOwnCheck: skipScopeCheck === false ? false : true });
if (!params) {
return null;
}
return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
};
export const ACLActionProvider = (props) => {
const { name } = useCollection();
const fieldSchema = useFieldSchema();
const { allowAll, getActionParams } = useACLRoleContext();
if (!name || allowAll) {
const isOwn = useRecordIsOwn();
const { allowAll, allowConfigure, getActionParams } = useACLRoleContext();
if (!name || allowAll || allowConfigure) {
return <>{props.children}</>;
}
const actionName = fieldSchema['x-action'];
const params = getActionParams([`${name}:${actionName}`]);
const path = fieldSchema['x-acl-action'] || `${name}:${actionName}`;
const skipScopeCheck = fieldSchema['x-acl-action-props']?.skipScopeCheck;
const params = getActionParams(path, { skipOwnCheck: skipScopeCheck, isOwn });
if (!params) {
return null;
}
return <>{props.children}</>;
return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
};
export const ACLCollectionFieldProvider = (props) => {
@ -106,9 +151,9 @@ export const ACLCollectionFieldProvider = (props) => {
};
export const ACLMenuItemProvider = (props) => {
const { allowAll, allowMenuItemIds = [] } = useACLRoleContext();
const { allowAll, allowConfigure, allowMenuItemIds = [] } = useACLRoleContext();
const fieldSchema = useFieldSchema();
if (allowAll) {
if (allowAll || allowConfigure) {
return <>{props.children}</>;
}
if (!fieldSchema['x-uid']) {

View File

@ -1,4 +1,5 @@
import React, { createContext, useContext } from 'react';
import { useCurrentUserContext } from '../user';
export const RecordContext = createContext({});
export const RecordIndexContext = createContext(null);
@ -21,3 +22,12 @@ export function useRecord<D = any>() {
export function useRecordIndex() {
return useContext(RecordIndexContext);
}
export const useRecordIsOwn = () => {
const record = useRecord();
const ctx = useCurrentUserContext();
if (!record?.createdById) {
return false;
}
return record?.createdById === ctx?.data?.data?.id;
};

View File

@ -12,12 +12,32 @@ import {
RemoteCollectionManagerProvider,
RemoteSchemaComponent,
RemoteSchemaTemplateManagerProvider,
useACLRoleContext,
useDocumentTitle,
useRoute,
useSystemSettings
} from '../../../';
import { PoweredBy } from '../../../powered-by';
const filterByACL = (schema, options) => {
const { allowAll, allowConfigure, allowMenuItemIds = [] } = options;
if (allowAll || allowConfigure) {
return schema;
}
const filterSchema = (s) => {
for (const key in s.properties) {
if (Object.prototype.hasOwnProperty.call(s.properties, key)) {
const element = s.properties[key];
if (element['x-uid'] && !allowMenuItemIds.includes(element['x-uid'])) {
delete s.properties[key];
}
}
}
};
filterSchema(schema);
return schema;
};
const InternalAdminLayout = (props: any) => {
const route = useRoute();
const history = useHistory();
@ -26,6 +46,7 @@ const InternalAdminLayout = (props: any) => {
const sideMenuRef = useRef();
const defaultSelectedUid = match.params.name;
const [schema, setSchema] = useState({});
const ctx = useACLRoleContext();
const onSelect = ({ item }) => {
const schema = item.props.schema;
console.log('onSelect', schema);
@ -75,6 +96,7 @@ const InternalAdminLayout = (props: any) => {
}
data['x-component-props'] = data['x-component-props'] || {};
data['x-component-props']['defaultSelectedUid'] = defaultSelectedUid;
filterByACL(data, ctx);
return data;
}}
onSuccess={(data) => {

View File

@ -3,21 +3,16 @@ import { SchemaOptionsContext } from '@formily/react';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaComponent, SchemaComponentOptions } from '../../..';
import { useRoleRecheck } from '../../../../acl';
import { SchemaInitializer } from '../../../../schema-initializer';
export const MenuItemInitializers = (props: any) => {
const { t } = useTranslation();
const recheck = useRoleRecheck();
return (
<SchemaInitializer.Button
insertPosition={'beforeEnd'}
icon={'PlusOutlined'}
insert={props.insert}
style={props.style}
onSuccess={() => {
recheck?.();
}}
{...props}
items={[
{

View File

@ -12,13 +12,14 @@ const RecordPickerContext = createContext(null);
const useTableSelectorProps = () => {
const { multiple, value, setSelectedRows, selectedRows } = useContext(RecordPickerContext);
const { onRowSelectionChange, ...others } = useTsp();
const { onRowSelectionChange, rowKey, ...others } = useTsp();
return {
...others,
rowKey,
rowSelection: {
type: multiple ? 'checkbox' : 'radio',
defaultSelectedRowKeys: selectedRows?.map((item) => item.id),
selectedRowKeys: selectedRows?.map((item) => item.id),
defaultSelectedRowKeys: selectedRows?.map((item) => item[rowKey||'id']),
selectedRowKeys: selectedRows?.map((item) => item[rowKey||'id']),
},
onRowSelectionChange(selectedRowKeys, selectedRows) {
onRowSelectionChange?.(selectedRowKeys, selectedRows);

View File

@ -3,6 +3,7 @@ import { observer, RecursionField, useField, useFieldSchema } from '@formily/rea
import { toArr } from '@formily/shared';
import { Space } from 'antd';
import React, { useState } from 'react';
import { BlockAssociationContext } from '../../../block-provider';
import { CollectionProvider, useCollection } from '../../../collection-manager';
import { RecordProvider } from '../../../record-provider';
import { FormProvider } from '../../core';
@ -18,31 +19,33 @@ export const ReadPrettyRecordPicker: React.FC = observer((props: any) => {
const collectionField = getField(fieldSchema.name);
return (
<div>
<CollectionProvider name={collectionField.target}>
<ActionContext.Provider value={{ visible, setVisible, openMode: 'drawer' }}>
<Space size={0} split={<span style={{ marginRight: 4, color: '#aaa' }}>, </span>}>
{toArr(field.value).map((record, index) => {
return (
<span>
<a
onClick={() => {
console.log('setVisible');
setVisible(true);
}}
>
{record?.[fieldNames?.label || 'label']}
</a>
<RecordProvider record={record}>
<FormProvider>
<RecursionField schema={fieldSchema} onlyRenderProperties />
</FormProvider>
</RecordProvider>
</span>
);
})}
</Space>
</ActionContext.Provider>
</CollectionProvider>
<BlockAssociationContext.Provider value={`${collectionField.collectionName}.${collectionField.name}`}>
<CollectionProvider name={collectionField.target}>
<ActionContext.Provider value={{ visible, setVisible, openMode: 'drawer' }}>
<Space size={0} split={<span style={{ marginRight: 4, color: '#aaa' }}>, </span>}>
{toArr(field.value).map((record, index) => {
return (
<span>
<a
onClick={() => {
console.log('setVisible');
setVisible(true);
}}
>
{record?.[fieldNames?.label || 'label']}
</a>
<RecordProvider record={record}>
<FormProvider>
<RecursionField schema={fieldSchema} onlyRenderProperties />
</FormProvider>
</RecordProvider>
</span>
);
})}
</Space>
</ActionContext.Provider>
</CollectionProvider>
</BlockAssociationContext.Provider>
</div>
);
});

View File

@ -1,6 +1,7 @@
import { observer, useField, useFieldSchema, useForm } from '@formily/react';
import React, { useEffect } from 'react';
import { useCollection } from '../../../collection-manager';
import { useCompile } from '../../hooks';
import { ActionBar } from '../action';
export const TableField: any = observer((props) => {
@ -8,9 +9,10 @@ export const TableField: any = observer((props) => {
const { getField } = useCollection();
const field = useField();
const collectionField = getField(fieldSchema.name);
const compile = useCompile();
useEffect(() => {
if (!field.title) {
field.title = collectionField?.uiSchema?.title;
field.title = compile(collectionField?.uiSchema?.title);
}
}, []);
return <div>{props.children}</div>;

View File

@ -68,6 +68,9 @@ export const CalendarActionInitializers = {
schema: {
'x-align': 'right',
'x-decorator': 'ACLActionProvider',
'x-acl-action-props': {
skipScopeCheck: true,
},
},
},
],

View File

@ -16,6 +16,7 @@ export const ReadPrettyFormActionInitializers = {
component: 'UpdateActionInitializer',
schema: {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
'x-component-props': {
type: 'primary',
},
@ -27,6 +28,7 @@ export const ReadPrettyFormActionInitializers = {
component: 'DestroyActionInitializer',
schema: {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
},
},
],

View File

@ -25,6 +25,9 @@ export const TableActionInitializers = {
schema: {
'x-align': 'right',
'x-decorator': 'ACLActionProvider',
'x-acl-action-props': {
skipScopeCheck: true,
},
},
},
{
@ -34,6 +37,9 @@ export const TableActionInitializers = {
schema: {
'x-align': 'right',
'x-decorator': 'ACLActionProvider',
'x-acl-action-props': {
skipScopeCheck: true,
},
},
},
],

View File

@ -280,14 +280,21 @@ export const createFormBlockSchema = (options) => {
collection,
resource,
association,
action,
...others
} = options;
const resourceName = resource || association || collection;
const schema: ISchema = {
type: 'void',
'x-acl-action-props': {
skipScopeCheck: !action,
},
'x-acl-action': action ? `${resourceName}:update` : `${resourceName}:create`,
'x-decorator': 'FormBlockProvider',
'x-decorator-props': {
...others,
resource: resource || association || collection,
action,
resource: resourceName,
collection,
association,
// action: 'get',
@ -338,11 +345,13 @@ export const createReadPrettyFormBlockSchema = (options) => {
resource,
...others
} = options;
const resourceName = resource || association || collection;
const schema: ISchema = {
type: 'void',
'x-acl-action': `${resourceName}:get`,
'x-decorator': 'FormBlockProvider',
'x-decorator-props': {
resource: resource || association || collection,
resource: resourceName,
collection,
association,
readPretty: true,
@ -391,6 +400,7 @@ export const createTableBlockSchema = (options) => {
const schema: ISchema = {
type: 'void',
'x-decorator': 'TableBlockProvider',
'x-acl-action': `${resource || collection}:list`,
'x-decorator-props': {
collection,
resource: resource || collection,
@ -461,6 +471,7 @@ export const createTableSelectorSchema = (options) => {
const { collection, resource, rowKey, ...others } = options;
const schema: ISchema = {
type: 'void',
'x-acl-action': `${resource || collection}:list`,
'x-decorator': 'TableSelectorProvider',
'x-decorator-props': {
collection,
@ -491,7 +502,6 @@ export const createTableSelectorSchema = (options) => {
'x-initializer': 'TableColumnInitializers',
'x-component': 'TableV2.Selector',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
@ -509,6 +519,7 @@ export const createCalendarBlockSchema = (options) => {
const { collection, resource, fieldNames, ...others } = options;
const schema: ISchema = {
type: 'void',
'x-acl-action': `${resource || collection}:list`,
'x-decorator': 'CalendarBlockProvider',
'x-decorator-props': {
collection: collection,
@ -595,6 +606,7 @@ export const createKanbanBlockSchema = (options) => {
const { collection, resource, groupField, ...others } = options;
const schema: ISchema = {
type: 'void',
'x-acl-action': `${resource || collection}:list`,
'x-decorator': 'KanbanBlockProvider',
'x-decorator-props': {
collection: collection,

View File

@ -43,7 +43,7 @@ export const CurrentUser = () => {
</Menu>
}
>
<span style={{ border: 0, padding: '16px', color: 'rgba(255, 255, 255, 0.65)' }}>
<span style={{ cursor: 'pointer', border: 0, padding: '16px', color: 'rgba(255, 255, 255, 0.65)' }}>
{data?.data?.nickname || data?.data?.email}
</span>
</Dropdown>

View File

@ -92,9 +92,8 @@ describe('middleware', () => {
it('should limit fields on view actions', async () => {
await app
.agent()
.resource('roles.resources')
.resource('roles.resources', role.get('name'))
.create({
associatedIndex: role.get('name') as string,
values: {
name: 'posts',
usingActionsConfig: true,
@ -140,7 +139,7 @@ describe('middleware', () => {
id: 2,
});
await app
const res = await app
.agent()
.resource('rolesResourcesScopes')
.create({
@ -152,13 +151,10 @@ describe('middleware', () => {
},
});
const scope = await db.getRepository('rolesResourcesScopes').findOne();
await app
.agent()
.resource('roles.resources')
.resource('roles.resources', role.get('name'))
.create({
associatedIndex: role.get('name') as string,
values: {
name: 'posts',
usingActionsConfig: true,
@ -170,7 +166,7 @@ describe('middleware', () => {
{
name: 'view',
fields: ['title'],
scope: scope.get('id'),
scope: res.body.data.id,
},
],
},
@ -206,9 +202,8 @@ describe('middleware', () => {
it('should change fields params to whitelist in create action', async () => {
await app
.agent()
.resource('roles.resources')
.resource('roles.resources', role.get('name'))
.create({
associatedIndex: role.get('name') as string,
values: {
name: 'posts',
usingActionsConfig: true,

View File

@ -1,3 +1,11 @@
const map2obj = (map: Map<string, string>) => {
const obj = {};
for(let [key, value] of map){
obj[key] = value;
}
return obj;
}
export async function checkAction(ctx, next) {
const currentRole = ctx.state.currentRole;
if (currentRole) {
@ -8,8 +16,12 @@ export async function checkAction(ctx, next) {
appends: ['menuUiSchemas'],
});
const role = ctx.app.acl.getRole(currentRole);
ctx.body = {
...ctx.app.acl.getRole(currentRole).toJSON(),
...role.toJSON(),
resources: [...role.resources.keys()],
actionAlias: map2obj(ctx.app.acl.actionAlias),
allowAll: currentRole === 'root',
allowConfigure: roleInstance.get('allowConfigure'),
allowMenuItemIds: roleInstance.get('menuUiSchemas').map((uiSchema) => uiSchema.get('x-uid')),

View File

@ -3,6 +3,10 @@ import { CollectionOptions } from '@nocobase/database';
export default {
name: 'rolesResourcesScopes',
fields: [
{
type: 'uid',
name: 'key',
},
{
type: 'string',
name: 'name',

View File

@ -1,5 +1,5 @@
import { Database, Model } from '@nocobase/database';
import { ACL, ACLRole } from '@nocobase/acl';
import { Database, Model } from '@nocobase/database';
import { AssociationFieldAction, AssociationFieldsActions, GrantHelper } from '../server';
export class RoleResourceActionModel extends Model {
@ -27,6 +27,7 @@ export class RoleResourceActionModel extends Model {
const scope = await this.getScope();
if (scope) {
actionParams['own'] = scope.get('key') === 'own';
actionParams['filter'] = scope.get('scope');
}

View File

@ -1,3 +1,5 @@
import { Context } from '@nocobase/actions';
import { Collection } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { availableActionResource } from './actions/available-actions';
@ -260,10 +262,15 @@ export class PluginACL extends Plugin {
{
name: 'admin',
title: 'Admin',
allowConfigure: true,
allowNewMenu: true,
strategy: { actions: ['create', 'export', 'view', 'update', 'destroy'] },
},
{
name: 'member',
title: 'Member',
allowNewMenu: true,
strategy: { actions: ['view', 'update:own', 'destroy:own', 'create'] },
default: true,
},
{
@ -276,10 +283,12 @@ export class PluginACL extends Plugin {
await rolesResourcesScopes.createMany({
records: [
{
key: 'all',
name: '{{t("All records")}}',
scope: {},
},
{
key: 'own',
name: '{{t("Own records")}}',
scope: {
createdById: '{{ ctx.state.currentUser.id }}',
@ -295,8 +304,7 @@ export class PluginACL extends Plugin {
this.app.acl.skip('*', '*', (ctx) => {
return ctx.state.currentRole === 'root';
});
// root role
// root role
this.app.resourcer.use(async (ctx, next) => {
const { actionName, resourceName } = ctx.action.params;
if (actionName === 'list' && resourceName === 'roles') {
@ -308,6 +316,62 @@ export class PluginACL extends Plugin {
}
await next();
});
this.app.acl.use(async (ctx: Context, next) => {
const { actionName, resourceName } = ctx.action;
if (actionName === 'get' || actionName === 'list') {
if (!Array.isArray(ctx?.permission?.can?.params?.fields)) {
return next();
}
let collection: Collection;
if (resourceName.includes('.')) {
const [collectionName, associationName] = resourceName.split('.');
const field = ctx.db.getCollection(collectionName)?.getField?.(associationName);
if (field.target) {
collection = ctx.db.getCollection(field.target);
}
} else {
collection = ctx.db.getCollection(resourceName);
}
if (collection && collection.hasField('createdById')) {
ctx.permission.can.params.fields.push('createdById');
}
}
return next();
});
const parseJsonTemplate = this.app.acl.parseJsonTemplate;
this.app.acl.use(async (ctx: Context, next) => {
const { actionName, resourceName, resourceOf } = ctx.action;
if (resourceName.includes('.') && resourceOf) {
if (!ctx?.permission?.can?.params) {
return next();
}
// 关联数据去掉 filter
delete ctx.permission.can.params.filter;
// 关联数据能不能处理取决于 source 是否有权限
const [collectionName] = resourceName.split('.');
const action = ctx.can({ resource: collectionName, action: actionName });
const availableAction = this.app.acl.getAvailableAction(actionName);
if (availableAction?.options?.onNewRecord) {
if (action) {
ctx.permission.skip = true;
} else {
ctx.permission.can = false;
}
} else {
const filter = parseJsonTemplate(action?.params?.filter || {}, ctx);
const sourceInstance = await ctx.db.getRepository(collectionName).findOne({
filterByTk: resourceOf,
filter,
});
if (!sourceInstance) {
ctx.permission.can = false;
}
}
}
await next();
});
}
async install() {

View File

@ -35,6 +35,8 @@ export default class PluginFileManager extends Plugin {
if (process.env.NOCOBASE_ENV !== 'production') {
await getStorageConfig(STORAGE_TYPE_LOCAL).middleware(this.app);
}
this.app.acl.skip('attachments', 'upload', 'logged-in');
}
getName(): string {

View File

@ -5,6 +5,8 @@ export default {
title: '{{t("Users")}}',
sortable: 'sort',
model: 'UserModel',
createdBy: true,
updatedBy: true,
fields: [
{
interface: 'input',

View File

@ -51,6 +51,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
dataType: 'integer',
dataIndex: 'state.currentUser.id',
createOnly: true,
visible: true,
});
collection.setField('createdBy', {
type: 'belongsTo',
@ -64,6 +65,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
type: 'context',
dataType: 'integer',
dataIndex: 'state.currentUser.id',
visible: true,
});
collection.setField('updatedBy', {
type: 'belongsTo',
@ -85,10 +87,6 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
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() {