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 { export interface AvailableActionOptions {
aliases?: string[] | string; /**
* @deprecated
*/
type: 'new-data' | 'old-data'; type: 'new-data' | 'old-data';
displayName?: string; displayName?: string;
aliases?: string[] | string;
resource?: string; resource?: string;
// 对新数据进行操作
onNewRecord?: boolean; onNewRecord?: boolean;
// 允许配置字段
allowConfigureFields?: 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() { getAvailableActions() {
return this.availableActions; return this.availableActions;
} }
@ -216,13 +221,21 @@ export class ACL extends EventEmitter {
this.skipManager.skip(resourceName, actionName, condition); this.skipManager.skip(resourceName, actionName, condition);
} }
parseJsonTemplate(json: any, ctx: any) {
return parse(json)({
ctx: {
state: JSON.parse(JSON.stringify(ctx.state)),
},
});
}
middleware() { middleware() {
const acl = this; const acl = this;
const filterParams = (ctx, resourceName, params) => { const filterParams = (ctx, resourceName, params) => {
if (params?.filter?.createdById) { if (params?.filter?.createdById) {
const collection = ctx.db.getCollection(resourceName); const collection = ctx.db.getCollection(resourceName);
if (!collection.getField('createdById')) { if (collection && !collection.getField('createdById')) {
return lodash.omit(params, 'filter.createdById'); return lodash.omit(params, 'filter.createdById');
} }
} }
@ -266,7 +279,7 @@ export class ACL extends EventEmitter {
if (params) { if (params) {
const filteredParams = filterParams(ctx, resourceName, params); const filteredParams = filterParams(ctx, resourceName, params);
const parsedParams = parse(filteredParams)({ ctx: ctxToObject(ctx) }); const parsedParams = acl.parseJsonTemplate(filteredParams, ctx);
resourcerAction.mergeParams(parsedParams); resourcerAction.mergeParams(parsedParams);
} }

View File

@ -5,6 +5,7 @@ import React, { createContext, useContext } from 'react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { useRequest } from '../api-client'; import { useRequest } from '../api-client';
import { useCollection } from '../collection-manager'; import { useCollection } from '../collection-manager';
import { useRecordIsOwn } from '../record-provider';
import { SchemaComponentOptions, useDesignable } from '../schema-component'; import { SchemaComponentOptions, useDesignable } from '../schema-component';
export const ACLContext = createContext(null); export const ACLContext = createContext(null);
@ -50,24 +51,50 @@ export const useRoleRecheck = () => {
const ctx = useContext(ACLContext); const ctx = useContext(ACLContext);
const { allowAll, allowConfigure } = useACLRoleContext(); const { allowAll, allowConfigure } = useACLRoleContext();
return () => { return () => {
if (allowAll) { if (allowAll || allowConfigure) {
return; return;
} }
ctx.refresh(); ctx.refresh();
} };
} };
export const useACLContext = () => { export const useACLContext = () => {
return useContext(ACLContext); return useContext(ACLContext);
} };
export const useACLRoleContext = () => { export const useACLRoleContext = () => {
const ctx = useContext(ACLContext); const ctx = useContext(ACLContext);
const data = ctx.data?.data; const data = ctx.data?.data;
return { return {
...data, ...data,
getActionParams(path) { getActionParams(path: string, { skipOwnCheck, isOwn }) {
return data?.actions?.[path]; 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; return null;
}; };
export const ACLCollectionProvider = (props) => { const ACLActionParamsContext = createContext<any>({});
const { name } = useCollection();
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) => { export const ACLActionProvider = (props) => {
const { name } = useCollection(); const { name } = useCollection();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const { allowAll, getActionParams } = useACLRoleContext(); const isOwn = useRecordIsOwn();
if (!name || allowAll) { const { allowAll, allowConfigure, getActionParams } = useACLRoleContext();
if (!name || allowAll || allowConfigure) {
return <>{props.children}</>; return <>{props.children}</>;
} }
const actionName = fieldSchema['x-action']; 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) { if (!params) {
return null; return null;
} }
return <>{props.children}</>; return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
}; };
export const ACLCollectionFieldProvider = (props) => { export const ACLCollectionFieldProvider = (props) => {
@ -106,9 +151,9 @@ export const ACLCollectionFieldProvider = (props) => {
}; };
export const ACLMenuItemProvider = (props) => { export const ACLMenuItemProvider = (props) => {
const { allowAll, allowMenuItemIds = [] } = useACLRoleContext(); const { allowAll, allowConfigure, allowMenuItemIds = [] } = useACLRoleContext();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
if (allowAll) { if (allowAll || allowConfigure) {
return <>{props.children}</>; return <>{props.children}</>;
} }
if (!fieldSchema['x-uid']) { if (!fieldSchema['x-uid']) {

View File

@ -1,4 +1,5 @@
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext } from 'react';
import { useCurrentUserContext } from '../user';
export const RecordContext = createContext({}); export const RecordContext = createContext({});
export const RecordIndexContext = createContext(null); export const RecordIndexContext = createContext(null);
@ -21,3 +22,12 @@ export function useRecord<D = any>() {
export function useRecordIndex() { export function useRecordIndex() {
return useContext(RecordIndexContext); 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, RemoteCollectionManagerProvider,
RemoteSchemaComponent, RemoteSchemaComponent,
RemoteSchemaTemplateManagerProvider, RemoteSchemaTemplateManagerProvider,
useACLRoleContext,
useDocumentTitle, useDocumentTitle,
useRoute, useRoute,
useSystemSettings useSystemSettings
} from '../../../'; } from '../../../';
import { PoweredBy } from '../../../powered-by'; 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 InternalAdminLayout = (props: any) => {
const route = useRoute(); const route = useRoute();
const history = useHistory(); const history = useHistory();
@ -26,6 +46,7 @@ const InternalAdminLayout = (props: any) => {
const sideMenuRef = useRef(); const sideMenuRef = useRef();
const defaultSelectedUid = match.params.name; const defaultSelectedUid = match.params.name;
const [schema, setSchema] = useState({}); const [schema, setSchema] = useState({});
const ctx = useACLRoleContext();
const onSelect = ({ item }) => { const onSelect = ({ item }) => {
const schema = item.props.schema; const schema = item.props.schema;
console.log('onSelect', 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'] = data['x-component-props'] || {};
data['x-component-props']['defaultSelectedUid'] = defaultSelectedUid; data['x-component-props']['defaultSelectedUid'] = defaultSelectedUid;
filterByACL(data, ctx);
return data; return data;
}} }}
onSuccess={(data) => { onSuccess={(data) => {

View File

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

View File

@ -12,13 +12,14 @@ const RecordPickerContext = createContext(null);
const useTableSelectorProps = () => { const useTableSelectorProps = () => {
const { multiple, value, setSelectedRows, selectedRows } = useContext(RecordPickerContext); const { multiple, value, setSelectedRows, selectedRows } = useContext(RecordPickerContext);
const { onRowSelectionChange, ...others } = useTsp(); const { onRowSelectionChange, rowKey, ...others } = useTsp();
return { return {
...others, ...others,
rowKey,
rowSelection: { rowSelection: {
type: multiple ? 'checkbox' : 'radio', type: multiple ? 'checkbox' : 'radio',
defaultSelectedRowKeys: selectedRows?.map((item) => item.id), defaultSelectedRowKeys: selectedRows?.map((item) => item[rowKey||'id']),
selectedRowKeys: selectedRows?.map((item) => item.id), selectedRowKeys: selectedRows?.map((item) => item[rowKey||'id']),
}, },
onRowSelectionChange(selectedRowKeys, selectedRows) { onRowSelectionChange(selectedRowKeys, selectedRows) {
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 { toArr } from '@formily/shared';
import { Space } from 'antd'; import { Space } from 'antd';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { BlockAssociationContext } from '../../../block-provider';
import { CollectionProvider, useCollection } from '../../../collection-manager'; import { CollectionProvider, useCollection } from '../../../collection-manager';
import { RecordProvider } from '../../../record-provider'; import { RecordProvider } from '../../../record-provider';
import { FormProvider } from '../../core'; import { FormProvider } from '../../core';
@ -18,31 +19,33 @@ export const ReadPrettyRecordPicker: React.FC = observer((props: any) => {
const collectionField = getField(fieldSchema.name); const collectionField = getField(fieldSchema.name);
return ( return (
<div> <div>
<CollectionProvider name={collectionField.target}> <BlockAssociationContext.Provider value={`${collectionField.collectionName}.${collectionField.name}`}>
<ActionContext.Provider value={{ visible, setVisible, openMode: 'drawer' }}> <CollectionProvider name={collectionField.target}>
<Space size={0} split={<span style={{ marginRight: 4, color: '#aaa' }}>, </span>}> <ActionContext.Provider value={{ visible, setVisible, openMode: 'drawer' }}>
{toArr(field.value).map((record, index) => { <Space size={0} split={<span style={{ marginRight: 4, color: '#aaa' }}>, </span>}>
return ( {toArr(field.value).map((record, index) => {
<span> return (
<a <span>
onClick={() => { <a
console.log('setVisible'); onClick={() => {
setVisible(true); console.log('setVisible');
}} setVisible(true);
> }}
{record?.[fieldNames?.label || 'label']} >
</a> {record?.[fieldNames?.label || 'label']}
<RecordProvider record={record}> </a>
<FormProvider> <RecordProvider record={record}>
<RecursionField schema={fieldSchema} onlyRenderProperties /> <FormProvider>
</FormProvider> <RecursionField schema={fieldSchema} onlyRenderProperties />
</RecordProvider> </FormProvider>
</span> </RecordProvider>
); </span>
})} );
</Space> })}
</ActionContext.Provider> </Space>
</CollectionProvider> </ActionContext.Provider>
</CollectionProvider>
</BlockAssociationContext.Provider>
</div> </div>
); );
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,7 +43,7 @@ export const CurrentUser = () => {
</Menu> </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} {data?.data?.nickname || data?.data?.email}
</span> </span>
</Dropdown> </Dropdown>

View File

@ -92,9 +92,8 @@ describe('middleware', () => {
it('should limit fields on view actions', async () => { it('should limit fields on view actions', async () => {
await app await app
.agent() .agent()
.resource('roles.resources') .resource('roles.resources', role.get('name'))
.create({ .create({
associatedIndex: role.get('name') as string,
values: { values: {
name: 'posts', name: 'posts',
usingActionsConfig: true, usingActionsConfig: true,
@ -140,7 +139,7 @@ describe('middleware', () => {
id: 2, id: 2,
}); });
await app const res = await app
.agent() .agent()
.resource('rolesResourcesScopes') .resource('rolesResourcesScopes')
.create({ .create({
@ -152,13 +151,10 @@ describe('middleware', () => {
}, },
}); });
const scope = await db.getRepository('rolesResourcesScopes').findOne();
await app await app
.agent() .agent()
.resource('roles.resources') .resource('roles.resources', role.get('name'))
.create({ .create({
associatedIndex: role.get('name') as string,
values: { values: {
name: 'posts', name: 'posts',
usingActionsConfig: true, usingActionsConfig: true,
@ -170,7 +166,7 @@ describe('middleware', () => {
{ {
name: 'view', name: 'view',
fields: ['title'], 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 () => { it('should change fields params to whitelist in create action', async () => {
await app await app
.agent() .agent()
.resource('roles.resources') .resource('roles.resources', role.get('name'))
.create({ .create({
associatedIndex: role.get('name') as string,
values: { values: {
name: 'posts', name: 'posts',
usingActionsConfig: true, 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) { export async function checkAction(ctx, next) {
const currentRole = ctx.state.currentRole; const currentRole = ctx.state.currentRole;
if (currentRole) { if (currentRole) {
@ -8,8 +16,12 @@ export async function checkAction(ctx, next) {
appends: ['menuUiSchemas'], appends: ['menuUiSchemas'],
}); });
const role = ctx.app.acl.getRole(currentRole);
ctx.body = { ctx.body = {
...ctx.app.acl.getRole(currentRole).toJSON(), ...role.toJSON(),
resources: [...role.resources.keys()],
actionAlias: map2obj(ctx.app.acl.actionAlias),
allowAll: currentRole === 'root', allowAll: currentRole === 'root',
allowConfigure: roleInstance.get('allowConfigure'), allowConfigure: roleInstance.get('allowConfigure'),
allowMenuItemIds: roleInstance.get('menuUiSchemas').map((uiSchema) => uiSchema.get('x-uid')), allowMenuItemIds: roleInstance.get('menuUiSchemas').map((uiSchema) => uiSchema.get('x-uid')),

View File

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

View File

@ -1,5 +1,5 @@
import { Database, Model } from '@nocobase/database';
import { ACL, ACLRole } from '@nocobase/acl'; import { ACL, ACLRole } from '@nocobase/acl';
import { Database, Model } from '@nocobase/database';
import { AssociationFieldAction, AssociationFieldsActions, GrantHelper } from '../server'; import { AssociationFieldAction, AssociationFieldsActions, GrantHelper } from '../server';
export class RoleResourceActionModel extends Model { export class RoleResourceActionModel extends Model {
@ -27,6 +27,7 @@ export class RoleResourceActionModel extends Model {
const scope = await this.getScope(); const scope = await this.getScope();
if (scope) { if (scope) {
actionParams['own'] = scope.get('key') === 'own';
actionParams['filter'] = scope.get('scope'); 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 { Plugin } from '@nocobase/server';
import { resolve } from 'path'; import { resolve } from 'path';
import { availableActionResource } from './actions/available-actions'; import { availableActionResource } from './actions/available-actions';
@ -260,10 +262,15 @@ export class PluginACL extends Plugin {
{ {
name: 'admin', name: 'admin',
title: 'Admin', title: 'Admin',
allowConfigure: true,
allowNewMenu: true,
strategy: { actions: ['create', 'export', 'view', 'update', 'destroy'] },
}, },
{ {
name: 'member', name: 'member',
title: 'Member', title: 'Member',
allowNewMenu: true,
strategy: { actions: ['view', 'update:own', 'destroy:own', 'create'] },
default: true, default: true,
}, },
{ {
@ -276,10 +283,12 @@ export class PluginACL extends Plugin {
await rolesResourcesScopes.createMany({ await rolesResourcesScopes.createMany({
records: [ records: [
{ {
key: 'all',
name: '{{t("All records")}}', name: '{{t("All records")}}',
scope: {}, scope: {},
}, },
{ {
key: 'own',
name: '{{t("Own records")}}', name: '{{t("Own records")}}',
scope: { scope: {
createdById: '{{ ctx.state.currentUser.id }}', createdById: '{{ ctx.state.currentUser.id }}',
@ -295,8 +304,7 @@ export class PluginACL extends Plugin {
this.app.acl.skip('*', '*', (ctx) => { this.app.acl.skip('*', '*', (ctx) => {
return ctx.state.currentRole === 'root'; return ctx.state.currentRole === 'root';
}); });
// root role
// root role
this.app.resourcer.use(async (ctx, next) => { this.app.resourcer.use(async (ctx, next) => {
const { actionName, resourceName } = ctx.action.params; const { actionName, resourceName } = ctx.action.params;
if (actionName === 'list' && resourceName === 'roles') { if (actionName === 'list' && resourceName === 'roles') {
@ -308,6 +316,62 @@ export class PluginACL extends Plugin {
} }
await next(); 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() { async install() {

View File

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

View File

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

View File

@ -51,6 +51,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
dataType: 'integer', dataType: 'integer',
dataIndex: 'state.currentUser.id', dataIndex: 'state.currentUser.id',
createOnly: true, createOnly: true,
visible: true,
}); });
collection.setField('createdBy', { collection.setField('createdBy', {
type: 'belongsTo', type: 'belongsTo',
@ -64,6 +65,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
type: 'context', type: 'context',
dataType: 'integer', dataType: 'integer',
dataIndex: 'state.currentUser.id', dataIndex: 'state.currentUser.id',
visible: true,
}); });
collection.setField('updatedBy', { collection.setField('updatedBy', {
type: 'belongsTo', type: 'belongsTo',
@ -85,10 +87,6 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
publicActions.forEach((action) => this.app.acl.skip('users', action)); publicActions.forEach((action) => this.app.acl.skip('users', action));
loggedInActions.forEach((action) => this.app.acl.skip('users', action, 'logged-in')); loggedInActions.forEach((action) => this.app.acl.skip('users', action, 'logged-in'));
this.app.acl.skip('*', '*', (ctx) => {
return ctx.state.currentUser?.id == 1;
});
} }
async load() { async load() {