feat: acl provider (#279)

* feat: acl provider

* feat: menu server hook

* feat: improve code

* fix: fields filter
This commit is contained in:
chenos 2022-04-10 19:22:39 +08:00 committed by GitHub
parent e109e2a37e
commit f791d43716
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 294 additions and 54 deletions

View File

@ -1,6 +1,6 @@
import { ACLResource } from './acl-resource';
import { AvailableStrategyOptions } from './acl-available-strategy';
import { ACL, DefineOptions } from './acl';
import { AvailableStrategyOptions } from './acl-available-strategy';
import { ACLResource } from './acl-resource';
export interface RoleActionParams {
fields?: string[];

View File

@ -241,7 +241,7 @@ export class ACL extends EventEmitter {
}
if (!permission.can || typeof permission.can !== 'object') {
ctx.throw(403, 'no permission');
ctx.throw(403, 'No permissions');
return;
}

View File

@ -1,4 +1,5 @@
import {
ACLProvider,
ACLShortcut,
AdminLayout,
AntdConfigProvider,
@ -99,6 +100,7 @@ const providers = [
],
BlockSchemaComponentProvider,
AntdSchemaComponentProvider,
ACLProvider,
ChinaRegionProvider,
WorkflowRouteProvider,
[DocumentTitleProvider, { addonAfter: 'NocoBase' }],

View File

@ -1,13 +1,103 @@
import { useFieldSchema } from '@formily/react';
import { Spin } from 'antd';
import React, { createContext, useContext } from 'react';
import { Redirect } from 'react-router-dom';
import { useRequest } from '../api-client';
import { useCollection } from '../collection-manager';
import { SchemaComponentOptions, useDesignable } from '../schema-component';
export const ACLContext = createContext(null);
export function ACLProvider() {
export const ACLProvider = (props) => {
return (
<ACLContext.Provider value={{}}>
<SchemaComponentOptions
components={{ ACLCollectionFieldProvider, ACLActionProvider, ACLMenuItemProvider, ACLCollectionProvider }}
>
{props.children}
</SchemaComponentOptions>
);
};
</ACLContext.Provider>
)
}
export const ACLRolesCheckProvider = (props) => {
const { setDesignable } = useDesignable();
const result = useRequest(
{
url: 'roles:check',
},
{
onSuccess(data) {
if (!data?.data?.allowConfigure) {
setDesignable(false);
}
},
},
);
if (result.loading) {
return <Spin />;
}
if (result.error) {
return <Redirect to={'/signin'} />;
}
return <ACLContext.Provider value={result}>{props.children}</ACLContext.Provider>;
};
export const useACLRoleContext = () => {
const ctx = useContext(ACLContext);
const data = ctx.data?.data;
return {
...data,
getActionParams(path) {
return data?.actions?.[path];
},
};
};
export const ACLAllowConfigure = (props) => {
const { allowAll, allowConfigure } = useACLRoleContext();
if (allowAll || allowConfigure) {
return <>{props.children}</>;
}
return null;
};
export const ACLCollectionProvider = (props) => {
const { name } = useCollection();
return <>{props.children}</>;
};
export const ACLActionProvider = (props) => {
const { name } = useCollection();
const fieldSchema = useFieldSchema();
const { allowAll, getActionParams } = useACLRoleContext();
if (!name || allowAll) {
return <>{props.children}</>;
}
const actionName = fieldSchema['x-action'];
const params = getActionParams([`${name}:${actionName}`]);
if (!params) {
return null;
}
return <>{props.children}</>;
};
export const ACLCollectionFieldProvider = (props) => {
return <>{props.children}</>;
};
export const ACLMenuItemProvider = (props) => {
const { allowAll, allowMenuItemIds = [] } = useACLRoleContext();
const fieldSchema = useFieldSchema();
if (allowAll) {
return <>{props.children}</>;
}
if (!fieldSchema['x-uid']) {
return <>{props.children}</>;
}
if (allowMenuItemIds.includes(fieldSchema['x-uid'])) {
return <>{props.children}</>;
}
return null;
};
export default ACLProvider;

View File

@ -2,6 +2,7 @@ import { FormItem, FormLayout } from '@formily/antd';
import { ArrayField } from '@formily/core';
import { connect, useField, useForm } from '@formily/react';
import { Checkbox, Table, Tag } from 'antd';
import { isEmpty } from 'lodash';
import React, { createContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useCollectionManager, useCompile, useRecord } from '../..';
@ -13,6 +14,7 @@ const toActionMap = (arr: any[]) => {
arr?.forEach?.((action) => {
if (action.name) {
obj[action.name] = action;
obj[action.name]['scope'] = isEmpty(action.scope) ? null : action.scope;
}
});
return obj;
@ -21,7 +23,16 @@ const toActionMap = (arr: any[]) => {
export const RoleResourceCollectionContext = createContext<any>({});
export const RolesResourcesActions = connect((props) => {
const { onChange } = props;
// const { onChange } = props;
const onChange = (values) => {
const items = values.map((item) => {
return {
...item,
scope: isEmpty(item.scope) ? null : item.scope,
};
});
props.onChange(items);
};
const form = useForm();
const roleCollection = useRecord();
const availableActions = useAvailableActions();
@ -39,7 +50,7 @@ export const RolesResourcesActions = connect((props) => {
return action?.fields?.includes(fieldName);
};
const availableActionsWithFields = availableActions.filter((action) => action.allowConfigureFields);
const fieldPermissions = collection?.fields?.map((field) => {
const fieldPermissions = collection?.fields?.filter(field => field.interface)?.map((field) => {
const permission = { ...field };
for (const action of availableActionsWithFields) {
permission[action.name] = inAction(action.name, field.name);
@ -52,7 +63,7 @@ export const RolesResourcesActions = connect((props) => {
} else {
actionMap[actionName] = {
name: actionName,
fields: collection?.fields?.map?.((item) => item.name),
fields: collection?.fields?.filter(field => field.interface)?.map?.((item) => item.name),
};
}
onChange(Object.values(actionMap));
@ -60,12 +71,15 @@ export const RolesResourcesActions = connect((props) => {
const setScope = (actionName, scope) => {
if (!actionMap[actionName]) {
toggleAction(actionName);
actionMap[actionName]['scope'] = scope;
} else {
actionMap[actionName]['scope'] = scope;
onChange(Object.values(actionMap));
}
actionMap[actionName]['scope'] = scope;
};
const allChecked = {};
for (const action of availableActionsWithFields) {
allChecked[action.name] = collection?.fields?.length === actionMap?.[action.name]?.fields?.length;
allChecked[action.name] = collection?.fields?.filter(field => field.interface)?.length === actionMap?.[action.name]?.fields?.length;
}
return (
<div>
@ -122,7 +136,9 @@ export const RolesResourcesActions = connect((props) => {
let scope = null;
if (actionMap[item.name]) {
enabled = true;
scope = actionMap[item.name]['scope'];
if (!item.onNewRecord) {
scope = actionMap[item.name]['scope'];
}
}
return {
...item,

View File

@ -4,7 +4,7 @@ import { useRequest } from 'ahooks';
import template from 'lodash/template';
import React, { createContext, useContext } from 'react';
import { Link } from 'react-router-dom';
import { TableFieldResource, useAPIClient, useRecord } from '../';
import { ACLCollectionProvider, TableFieldResource, useAPIClient, useRecord } from '../';
import { CollectionProvider, useCollection, useCollectionManager } from '../collection-manager';
import { useRecordIndex } from '../record-provider';
@ -89,7 +89,9 @@ export const useResourceAction = (props, opts = {}) => {
const MaybeCollectionProvider = (props) => {
const { collection } = props;
return collection ? (
<CollectionProvider collection={collection}>{props.children}</CollectionProvider>
<CollectionProvider collection={collection}>
<ACLCollectionProvider>{props.children}</ACLCollectionProvider>
</CollectionProvider>
) : (
<>{props.children}</>
);
@ -183,5 +185,9 @@ export const RecordLink = (props) => {
const record = useRecord();
const { title, to, ...others } = props;
const compiled = template(to || '');
return <Link {...others} to={compiled({ record: record || {} })}>{field.title}</Link>;
return (
<Link {...others} to={compiled({ record: record || {} })}>
{field.title}
</Link>
);
};

View File

@ -3,6 +3,7 @@ import { Layout } from 'antd';
import React, { useRef, useState } from 'react';
import { useHistory, useRouteMatch } from 'react-router-dom';
import {
ACLAllowConfigure, ACLRolesCheckProvider,
CurrentUser,
CurrentUserProvider,
findByUid,
@ -97,16 +98,18 @@ const InternalAdminLayout = (props: any) => {
</div>
</div>
<div style={{ position: 'absolute', top: 0, right: 0 }}>
<PluginManager.Toolbar
items={[
{ component: 'DesignableSwitch', pin: true },
{ component: 'CollectionManagerShortcut', pin: true },
{ component: 'ACLShortcut', pin: true },
{ component: 'WorkflowShortcut', pin: true },
{ component: 'SchemaTemplateShortcut', pin: true },
{ component: 'SystemSettingsShortcut' },
]}
/>
<ACLAllowConfigure>
<PluginManager.Toolbar
items={[
{ component: 'DesignableSwitch', pin: true },
{ component: 'CollectionManagerShortcut', pin: true },
{ component: 'ACLShortcut', pin: true },
{ component: 'WorkflowShortcut', pin: true },
{ component: 'SchemaTemplateShortcut', pin: true },
{ component: 'SystemSettingsShortcut' },
]}
/>
</ACLAllowConfigure>
<CurrentUser />
</div>
</Layout.Header>
@ -137,13 +140,15 @@ const InternalAdminLayout = (props: any) => {
export const AdminLayout = (props) => {
return (
<RemoteSchemaTemplateManagerProvider>
<RemoteCollectionManagerProvider>
<CurrentUserProvider>
<InternalAdminLayout {...props} />
</CurrentUserProvider>
</RemoteCollectionManagerProvider>
</RemoteSchemaTemplateManagerProvider>
<CurrentUserProvider>
<RemoteSchemaTemplateManagerProvider>
<RemoteCollectionManagerProvider>
<ACLRolesCheckProvider>
<InternalAdminLayout {...props} />
</ACLRolesCheckProvider>
</RemoteCollectionManagerProvider>
</RemoteSchemaTemplateManagerProvider>
</CurrentUserProvider>
);
};

View File

@ -78,9 +78,16 @@ const InsertMenuItems = (props) => {
type: 'void',
title,
'x-component': 'Menu.SubMenu',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
},
'x-server-hooks': [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
],
});
}}
/>
@ -112,9 +119,16 @@ const InsertMenuItems = (props) => {
type: 'void',
title,
'x-component': 'Menu.Item',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
},
'x-server-hooks': [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
],
properties: {
page: {
type: 'void',
@ -165,10 +179,17 @@ const InsertMenuItems = (props) => {
type: 'void',
title,
'x-component': 'Menu.URL',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
href,
},
'x-server-hooks': [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
],
});
}}
/>

View File

@ -79,9 +79,16 @@ export const GroupItem = itemWrap((props) => {
type: 'void',
title,
'x-component': 'Menu.SubMenu',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
},
'x-server-hooks': [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
],
});
}}
/>
@ -128,9 +135,16 @@ export const PageMenuItem = itemWrap((props) => {
type: 'void',
title,
'x-component': 'Menu.Item',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
},
'x-server-hooks': [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
],
properties: {
page: {
type: 'void',
@ -197,10 +211,17 @@ export const LinkMenuItem = itemWrap((props) => {
type: 'void',
title,
'x-component': 'Menu.URL',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
href,
},
'x-server-hooks': [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
],
});
}}
/>

View File

@ -4,6 +4,7 @@ import React, { createContext, useContext, useState } from 'react';
import { useTableSelectorProps as useTsp } from '../../../block-provider/TableSelectorProvider';
import { CollectionProvider, useCollection } from '../../../collection-manager';
import { FormProvider, SchemaComponentOptions } from '../../core';
import { useCompile } from '../../hooks';
import { ActionContext, useActionContext } from '../action';
import { useFieldNames } from './useFieldNames';
@ -49,7 +50,7 @@ const useAssociation = (props) => {
return association;
}
return getField(fieldSchema.name);
}
};
export const InputRecordPicker: React.FC<any> = (props) => {
const { value, multiple, onChange } = props;
@ -57,7 +58,14 @@ export const InputRecordPicker: React.FC<any> = (props) => {
const [visible, setVisible] = useState(false);
const fieldSchema = useFieldSchema();
const collectionField = useAssociation(props);
const options = Array.isArray(value) ? value : value ? [value] : [];
const compile = useCompile();
const options = (Array.isArray(value) ? value : value ? [value] : []).map(option => {
const label = option[fieldNames.label];
return {
...option,
[fieldNames.label]: compile(label),
};
});
const [selectedRows, setSelectedRows] = useState(options);
const values = options?.map((option) => option[fieldNames.value]);
return (

View File

@ -11,6 +11,7 @@ export interface RemoteSchemaComponentProps {
scope?: any;
uid?: string;
onSuccess?: any;
components?: any;
schemaTransform?: (schema: Schema) => Schema;
render?: any;
hidden?: any;
@ -20,7 +21,7 @@ export interface RemoteSchemaComponentProps {
const defaultTransform = (s: Schema) => s;
const RequestSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) => {
const { onlyRenderProperties, hidden, scope, uid, onSuccess, schemaTransform = defaultTransform } = props;
const { onlyRenderProperties, hidden, scope, uid, components, onSuccess, schemaTransform = defaultTransform } = props;
const { reset } = useSchemaComponentContext();
const conf = {
url: `/uiSchemas:${onlyRenderProperties ? 'getProperties' : 'getJsonSchema'}/${uid}`,
@ -41,7 +42,7 @@ const RequestSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) =>
}
return (
<FormProvider form={form}>
<SchemaComponent memoized scope={scope} schema={schemaTransform(data?.data || {})} />
<SchemaComponent memoized components={components} scope={scope} schema={schemaTransform(data?.data || {})} />
</FormProvider>
);
};

View File

@ -67,6 +67,7 @@ export const CalendarActionInitializers = {
component: 'CreateActionInitializer',
schema: {
'x-align': 'right',
'x-decorator': 'ACLActionProvider',
},
},
],

View File

@ -44,6 +44,7 @@ export const TableActionColumnInitializers = (props: any) => {
schema: {
'x-component': 'Action.Link',
'x-action': 'view',
'x-decorator': 'ACLActionProvider',
},
},
{
@ -53,6 +54,7 @@ export const TableActionColumnInitializers = (props: any) => {
schema: {
'x-component': 'Action.Link',
'x-action': 'update',
'x-decorator': 'ACLActionProvider',
},
},
{
@ -62,6 +64,7 @@ export const TableActionColumnInitializers = (props: any) => {
schema: {
'x-component': 'Action.Link',
'x-action': 'destroy',
'x-decorator': 'ACLActionProvider',
},
},
],

View File

@ -24,6 +24,7 @@ export const TableActionInitializers = {
component: 'CreateActionInitializer',
schema: {
'x-align': 'right',
'x-decorator': 'ACLActionProvider',
},
},
{
@ -32,6 +33,7 @@ export const TableActionInitializers = {
component: 'BulkDestroyActionInitializer',
schema: {
'x-align': 'right',
'x-decorator': 'ACLActionProvider',
},
},
],

View File

@ -2,14 +2,22 @@ import { useCookieState } from 'ahooks';
import { Menu, Select } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../api-client';
import { useCurrentUserContext } from './CurrentUserProvider';
const useCurrentRoles = () => {
const { data } = useCurrentUserContext();
return data?.data?.roles || [];
return [
...(data?.data?.roles || []),
{
title: 'Anonymous',
name: 'anonymous',
},
];
};
export const SwitchRole = () => {
const api = useAPIClient();
const roles = useCurrentRoles();
const { t } = useTranslation();
const [roleName, setRoleName] = useCookieState('currentRoleName', {
@ -30,8 +38,9 @@ export const SwitchRole = () => {
}}
options={roles}
value={roleName}
onChange={(roleName) => {
onChange={async (roleName) => {
setRoleName(roleName);
await api.resource('users').setDefaultRole({ values: { roleName } });
window.location.reload();
}}
/>

View File

@ -9,9 +9,10 @@ export async function checkAction(ctx, next) {
});
ctx.body = {
role: ctx.app.acl.getRole(currentRole).toJSON(),
...ctx.app.acl.getRole(currentRole).toJSON(),
allowAll: currentRole === 'root',
allowConfigure: roleInstance.get('allowConfigure'),
roleMenuItemIds: roleInstance.get('menuUiSchemas').map((uiSchema) => uiSchema.get('x-uid')),
allowMenuItemIds: roleInstance.get('menuUiSchemas').map((uiSchema) => uiSchema.get('x-uid')),
};
}

View File

@ -48,6 +48,11 @@ export default {
name: 'default',
defaultValue: false,
},
{
type: 'boolean',
name: 'hidden',
defaultValue: false,
},
{
type: 'boolean',
name: 'allowConfigure',

View File

@ -245,9 +245,14 @@ export class PluginACL extends Plugin {
if (plugin.constructor.name !== 'UsersPlugin') {
return;
}
const repository = this.app.db.getRepository('roles');
await repository.createMany({
const roles = this.app.db.getRepository('roles');
await roles.createMany({
records: [
{
name: 'root',
title: 'Root',
hidden: true,
},
{
name: 'admin',
title: 'Admin',
@ -263,8 +268,42 @@ export class PluginACL extends Plugin {
},
],
});
const rolesResourcesScopes = this.app.db.getRepository('rolesResourcesScopes');
await rolesResourcesScopes.createMany({
records: [
{
name: '{{t("All records")}}',
scope: {},
},
{
name: '{{t("Own records")}}',
scope: {
createdById: '{{ ctx.state.currentUser.id }}',
},
},
],
});
});
this.app.acl.skip('roles.menuUiSchemas', 'set', 'logged-in');
this.app.acl.skip('roles.menuUiSchemas', 'toggle', 'logged-in');
this.app.acl.skip('roles.menuUiSchemas', 'list', 'logged-in');
this.app.acl.skip('roles', 'check', 'logged-in');
this.app.acl.skip('*', '*', (ctx) => {
return ctx.state.currentRole === 'root';
});
// root role
this.app.resourcer.use(async (ctx, next) => {
const { actionName, resourceName } = ctx.action.params;
if (actionName === 'list' && resourceName === 'roles') {
ctx.action.mergeParams({
filter: {
'name.$ne': 'root',
},
});
}
await next();
});
}
async install() {

View File

@ -1,7 +1,7 @@
import { bindMenuToRole } from './bind-menu-to-role';
import { hookFactory } from './factory';
import { removeSchema } from './remove-schema';
import { bindMenuToRole } from './bind-menu-to-row';
import { removeParentsIfNoChildren } from './remove-parents-if-no-children';
import { removeSchema } from './remove-schema';
const hooks = [
hookFactory('onCollectionDestroy', 'removeSchema', removeSchema),

View File

@ -1,8 +1,8 @@
import Database, { BelongsToManyRepository } from '@nocobase/database';
import PluginACL from '@nocobase/plugin-acl';
import UsersPlugin from '@nocobase/plugin-users';
import { MockServer, mockServer } from '@nocobase/test';
import { userPluginConfig } from './utils';
import UsersPlugin from '@nocobase/plugin-users';
describe('role', () => {
let api: MockServer;
@ -108,12 +108,12 @@ describe('role', () => {
.agent()
.post('/users:setDefaultRole')
.send({
defaultRole: 'test2',
roleName: 'test2',
})
.set({
Authorization: `Bearer ${userToken}`,
});
expect(response.statusCode).toEqual(200);
const userRoles = await userRolesRepo.find();

View File

@ -1,7 +1,5 @@
import { Context, Next } from '@nocobase/actions';
import { PasswordField } from '@nocobase/database';
import UsersPlugin from '../server';
import crypto from 'crypto';
export async function check(ctx: Context, next: Next) {
@ -154,9 +152,14 @@ export async function changePassword(ctx: Context, next: Next) {
export async function setDefaultRole(ctx: Context, next: Next) {
const {
values: { defaultRole },
values: { roleName },
} = ctx.action.params;
if (roleName == 'anonymous') {
ctx.body = 'ok';
return next();
}
const currentUserId = ctx.state.currentUser.id;
await ctx.db.getRepository('rolesUsers').update({
@ -171,7 +174,7 @@ export async function setDefaultRole(ctx: Context, next: Next) {
await ctx.db.getRepository('rolesUsers').update({
filter: {
userId: currentUserId,
roleName: defaultRole,
roleName,
},
values: {
default: true,

View File

@ -13,6 +13,13 @@ export function parseToken(options?: { plugin: UsersPlugin }) {
}
function setCurrentRole(ctx, user) {
const roleName = ctx.get('X-Role');
if (roleName === 'anonymous') {
ctx.state.currentRole = roleName;
return;
}
const userRoles = user.get('roles');
let userRole;

View File

@ -118,7 +118,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
nickname: adminNickname,
email: adminEmail,
password: adminPassword,
roles: ['admin'],
roles: ['root', 'admin'],
},
});