React-admin: centralized permissions configuration proposal

Created on 30 Sep 2017  路  9Comments  路  Source: marmelab/react-admin

I was trying to use { permission => { }} notation and <WithPermission> in my application and not only they pollute the code - they tend to make it very difficult to configure policy and show clearly what is editable, what actions/buttons are visible, what forms are visible (so that someone doesn't just navigate there), what filters are available, what columns are visible in the list etc.. imagine that for tens of resources with each ten properties and 3-4 roles. A lot of bugs I was trying to think of a better way to organize the policy so that all the components would obey to but it seems really difficult to that now. E.g you would put just one form with what is available and visible for lets say the Admin and then based on the role everything would hide or inputs would change to read-only/fields if read-only etc..
Another way would be to use a factory that gets initialized with role, resource and a configuration and then returns the exact component structure based on action and property.. As a sketch of the idea would be:

let config = {
    resource1: {
        role1: {
            create: {
                prop1: "visible/hidden/editable",
                prop2: "visible/hidden/editable",
            }
            update: {
                prop1: "visible/hidden/editable",
                                prop2: "visible/hidden/editable",
            }
            list: {
                prop1: "visible/hidden",
                                prop2: "visible/hidden",
            },
            filter: {
                prop1: "visible/hidden",
                                prop2: "visible/hidden",
            }
            show: {
                prop1: "visible/hidden",
                                prop2: "visible/hidden",
            },
            actions: {
                create: true/false,
                show: true/false,
                edit: true/false,
                delete: true/false,
                list: true/false
            }

        },

        role2: {
            ...
        },
        ...

        props: {
            prop1: PropTypes.number,
            prop3: PropTypes.string,
            prop2: PropTypes.arrayOf(PropTypes.number),
        },
    },

    ...,

}

new ComponentFactory(permissions, resource, config).create(action, prop) => Component

usage
const factory = new ComponentFactory(permisisons, "resource1", config);
<Create ...>
{factory.create("create","name")}
{factory.create("create","another-prop")}
</Create>

<List ...>
{factory.create("list","name")}
{factory.create("list","another-prop")}
</List>

.create() would have to be implemented for each application for each of the propTypes so that source names are proper etc
Is this something that is possible to implement or there are technical difficulties making it infeasible like passing parent properties in the children created? I would like to hear some feedback on how you use permissions in a big application that needs to scale quickly with new roles and properties, and how a similar approach to the proposed would sound. Thanks!

Most helpful comment

@djhi probably you misunderstood what I wrote -I have read the documentation.. I am using the { permission => { }} notation everywhere and <WithPermission> around MenuItemLinks I am suggesting a feature here that I already started to implement, since the permission notation doesn't scale and makes all the code messy.. I just wanted some feedback from the community. This is the implementation I started using showing just one property of one of my resources:

import React from 'react';
import {
    TextInput,
    NumberField,
    TextField
} from 'admin-on-rest';
export default class Factory {

    static config = {
        companies: {
            name: {
                create: {
                    admin: "editable",
                    client: "editable",
                    staff: "hidden"
                },
                update: {
                    admin: "editable",
                    client: "visible",
                    staff: "hidden"
                },
                list: {
                    admin: "hidden",
                    client: "hidden",
                    staff: "visible"
                },
                filter: {
                    admin: "hidden",
                    client: "hidden",
                    staff: "hidden"
                },
                show: {
                    admin: "visible",
                    client: "visible",
                    staff: "visible"
                },

                editable: (<TextInput label="Name" source="name"/>),
                visible: (<TextField label="Name" source="name"/>),
                hidden: ''

            },
            actions: {
                create: ["admin", "client", "staff"],
                show: ["admin", "client", "staff"],
                edit: ["admin", "client", "staff"],
                delete: ["admin", "client", "staff"],
                list: ["admin", "client", "staff"],
            },
        },
    }



    constructor(resource) {
        this.resource = resource;
    }

    create(action, prop) {
        let role = localStorage.getItem('user_role');
        if (!Factory.config[this.resource] ||
            !Factory.config[this.resource][prop] ||
            !Factory.config[this.resource][prop][action] ||
            !Factory.config[this.resource][prop][action][role]) {
            return '';
        }
        let propPolicy = Factory.config[this.resource][prop][action][role];
        let component = Factory.config[this.resource][prop][propPolicy];
        return component;
    }
}

All 9 comments

As specified in the documentation:

Tip: Do not use the WithPermission component inside the others admin-on-rest components. It is only meant to be used in custom pages or components.

For all the main aor components, follow the Authorization documentation and use the function as children pattern.

@djhi probably you misunderstood what I wrote -I have read the documentation.. I am using the { permission => { }} notation everywhere and <WithPermission> around MenuItemLinks I am suggesting a feature here that I already started to implement, since the permission notation doesn't scale and makes all the code messy.. I just wanted some feedback from the community. This is the implementation I started using showing just one property of one of my resources:

import React from 'react';
import {
    TextInput,
    NumberField,
    TextField
} from 'admin-on-rest';
export default class Factory {

    static config = {
        companies: {
            name: {
                create: {
                    admin: "editable",
                    client: "editable",
                    staff: "hidden"
                },
                update: {
                    admin: "editable",
                    client: "visible",
                    staff: "hidden"
                },
                list: {
                    admin: "hidden",
                    client: "hidden",
                    staff: "visible"
                },
                filter: {
                    admin: "hidden",
                    client: "hidden",
                    staff: "hidden"
                },
                show: {
                    admin: "visible",
                    client: "visible",
                    staff: "visible"
                },

                editable: (<TextInput label="Name" source="name"/>),
                visible: (<TextField label="Name" source="name"/>),
                hidden: ''

            },
            actions: {
                create: ["admin", "client", "staff"],
                show: ["admin", "client", "staff"],
                edit: ["admin", "client", "staff"],
                delete: ["admin", "client", "staff"],
                list: ["admin", "client", "staff"],
            },
        },
    }



    constructor(resource) {
        this.resource = resource;
    }

    create(action, prop) {
        let role = localStorage.getItem('user_role');
        if (!Factory.config[this.resource] ||
            !Factory.config[this.resource][prop] ||
            !Factory.config[this.resource][prop][action] ||
            !Factory.config[this.resource][prop][action][role]) {
            return '';
        }
        let propPolicy = Factory.config[this.resource][prop][action][role];
        let component = Factory.config[this.resource][prop][propPolicy];
        return component;
    }
}

This is a great idea in my opinion.
This kind of factory could also be used to retrieve dynamic permissions stored server side.
Are you planning to release this as an aor plugin ?
Do you have a repo where this conversation can carry on ?

@Phocea Thanks for the kind words! no I don't have a repository for that yet but I am planning to release it - in the mean time I am sending my latest implementation (the config and the factory are still coupled and need to be separated) the way I use it and we can discuss the details of how to make it a plugin :) pff the indentation is f*d up!

example usage:

import Factory from './factory';
const factory = new Factory("companies");

it can be used in List, Edit, Filter, Show and Menu

separate fields creation :

<List title="All companies" {...props} filters={<CompanyFilter/>} actions={<Actions />} sort={{field: 'id', order: 'DESC'}} perPage={5}>
        <Datagrid>
            {factory.create("list","id")}
            {factory.create("list","name")}
            {factory.create("list","afm")}
            {factory.create("list","doy")}
            {factory.createEditButton()}
            {factory.createDeleteButton()}
        </Datagrid>
    </List>

or create all of them based on the order found in the configuration:

<List title="All companies" {...props} filters={<CompanyFilter/>} actions={<Actions />} sort={{field: 'id', order: 'DESC'}} perPage={5}>
        <Datagrid>
            {factory.createAll("list")}
            {factory.createEditButton()}
            {factory.createDeleteButton()}
        </Datagrid>
    </List>

or

    <Edit title={<CompanyTitle />} {...props}>
        <SimpleForm>
            {factory.createAll("edit")}
        </SimpleForm>
    </Edit>

it also hides edit/delete/create buttons and menu items accordingly based on the action attribute in each of the actions under a role

how to conditionally hide create button:

    <CardActions style={cardActionStyle}>
        {filters && factory.canFilter() && React.cloneElement(filters, { resource, showFilter, displayedFilters, filterValues, context: 'button' }) }
        {factory.createCreateButton(basePath)}
        <FlatButton primary label="refresh" onClick={refresh} icon={<NavigationRefresh />} />
    </CardActions>

in Menu:

 {new Factory("companies").canSeeMenuLink() &&
            <MenuItemLink
                key="companies"
                to={`/companies`}
                primaryText={translate(`resources.companies.name`, { smart_count: 2 })}
                leftIcon={<CompanyIcon color="#fff" />}
                onClick={onMenuTap}
                style={{color: "#fff"}}
            />}

It might still have bugs and corner cases and future work includes flexibility on hiding properties for mobile in a cleaner way.

Source:

import React from 'react';
import {
    ImageInput,
    ImageField,
    DateInput,
    DateField,
    TextInput,
    LongTextInput,
    TextField,
    NumberField,
    BooleanInput,
    BooleanField,
    ReferenceInput,
    ReferenceArrayInput,
    ReferenceField,
    ReferenceArrayField,
    SelectArrayInput,
    SelectInput,
    SingleFieldList,
    ChipField,
    CreateButton,
    EditButton,
    DeleteButton,

} from 'admin-on-rest';
export default class Factory {

    scrollableAutoComplete = { overflow: 'auto', maxHeight: 200 };
    static config = {
        companies: {
            props: {
                id: {
                    input: (<TextField source="id"/>),
                    field: (<TextField source="id"/>),
                },
                name: {
                    input: (<TextInput label="Name" source="name"/>),
                    field: (<TextField label="Name" source="name"/>),
                },
                doy: {
                    input: (<TextInput label="Doy" source="doy"/>),
                    field: (<TextField label="Doy" source="doy"/>),
                },
                afm: {
                    input: (<TextInput label="afm" source="afm"/>),
                    field: (<TextField label="afm" source="afm"/>),
                },
                client: {
                    input: (
                        <ReferenceInput label="Client" source="client" reference="clients" allowEmpty>
                            <SelectInput options={{ listStyle: this.scrollableAutoComplete}} optionText="name" translate={false}/>
                        </ReferenceInput>
                    ),
                    field: (
                        <ReferenceArrayField label="Client" source="client" reference="clients" sortable={false}>
                            <SingleFieldList>
                                <ChipField source="name"/>
                            </SingleFieldList>
                        </ReferenceArrayField>
                    ),
                }
            },

            admin: {
                create: {
                    props: ["name","afm","doy", "client"],
                    action: true
                },
                edit: {
                    props: [{name: "id", type: "field"},
                            {name: "name", type: "input"},
                            {name: "afm", type: "input"},
                            {name: "doy", type: "input"},
                            {name: "client", type: "input"}]
                    ,

                    input: ["name", "afm", "doy", "client"],
                    action: true
                },
                list: {
                    props: ["id", "name", "doy", "client"],
                    action: true
                },
                filter: {
                    props: ["client"],
                    action: true
                },
                show: {
                    props: ["id", "name", "doy", "client"],
                    action: true
                },
                search: {
                    action: true
                },
                delete: {
                    action: true
                },

            },
            client: {
                create: {
                    props: [],
                    action: false
                },
                edit: {
                    props: [],
                    action: false
                },
                list: {
                    props: ["id", "name", "doy", "afm"],
                    action: true
                },
                filter: {
                    props: ["id"],
                    action: true
                },
                show: {
                    props: ["id", "name", "doy", "afm"],
                    action: true
                },
                search: {
                    action: true
                },
                delete: {
                    action: false
                }
            },
            staff: {
                create: {
                    props: [],
                    action: false
                },
                edit: {
                    props: [],
                    action: false
                },
                list: {
                    props: [],
                    action: false
                },
                filter: {
                    props: [],
                    action: false
                },
                show: {
                    props: [],
                    action: false
                },
                search: {
                    action: false
                },
                delete: {
                    action: false
                }
            },
        },
    }
    constructor(resource) {
        this.resource = resource;
    }


    create(action, prop, propPolicy) {
        let role = localStorage.getItem('user_role');
        if (!propPolicy) {
            propPolicy = this.getPropertyPolicy(prop, role, action);
        }
        let actionPolicy = this.getActionPolicy(role, action);
        if (!actionPolicy || "hidden" === propPolicy) {
            return '';
        }
        if (!Factory.config[this.resource]["props"][prop] || !Factory.config[this.resource]["props"][prop][propPolicy]) {
            return '';
        }
        let component = Factory.config[this.resource]["props"][prop][propPolicy];
        return component;
    }

    createCreateButton(basePath) {
        let role = localStorage.getItem('user_role');
        let createPolicy = this.getActionPolicy(role, "create");
        if (createPolicy) {
            return (<CreateButton basePath={basePath} translate={true}/>);
        }
        else {
            return '';
        }
    }

    createEditButton() {
        let role = localStorage.getItem('user_role');
        let editPolicy = this.getActionPolicy(role, "edit");
        if (editPolicy) {
            return (<EditButton translate={true}/>);
        }
        else {
            return '';
        }
    }

    createDeleteButton() {
        let role = localStorage.getItem('user_role');
        let deletePolicy = this.getActionPolicy(role, "delete");
        if (deletePolicy) {
            return (<DeleteButton translate={true}/>);
        }
        else {
            return '';
        }
    }

    canFilter() {
        let role = localStorage.getItem('user_role');
        let filterPolicy = this.getActionPolicy(role, "filter");
        return filterPolicy;
    }

    canSeeMenuLink() {
        let role = localStorage.getItem('user_role');
        let filterPolicy = this.getActionPolicy(role, "list");
        return filterPolicy;
    }

    createAll(action) {
        let i = 0;
        let components =  this.getCollectionOfProperties(action).map((p) => {
            let property = p.prop;
            let propPolicy = p.type;
            let comp = this.create(action, property, propPolicy);
            return React.cloneElement(comp, {key: i++});
        });
        if (components.length === 0) {
            return '';
        }
        return components;
    }

    getCollectionOfProperties(action) {
         let role = localStorage.getItem('user_role');
         if (!Factory.config[this.resource] ||
            !Factory.config[this.resource][role] ||
            !Factory.config[this.resource][role][action]) {
                return [];
            }
        let props = Factory.config[this.resource][role][action]["props"];
        let allProps = [];
        let type = "field";
        if (action === "create" || action === "filter") {
            type = "input";
        }
        if (Array.isArray(props)) {
            if (action === "edit") {
                for (let prop of props) {
                    allProps.push({type: prop.type, prop: prop.name});
                }
            }
            else {
                for (let prop of props) {
                    allProps.push({type: type, prop: prop});
                }
            }
        }
        return allProps;
    }

    getActionPolicy(role, action) {
        if (!Factory.config[this.resource] ||
            !Factory.config[this.resource][role] ||
            !Factory.config[this.resource][role][action] ||
            !Factory.config[this.resource][role][action]["action"]) {
            return false;
        }
        let policy = Factory.config[this.resource][role][action]["action"];
        return policy;
    }

    getPropertyPolicy(prop, role, action) {
        if (!Factory.config[this.resource] ||
            !Factory.config[this.resource][role] ||
            !Factory.config[this.resource][role][action] ||
            !Factory.config[this.resource][role][action]["props"]) {
            return 'hidden';
        }
        let props = Factory.config[this.resource][role][action]["props"];

        if (Array.isArray(props) && props.indexOf(prop) > -1) {
            if (action === "filter" || action === "create") {
                return "input"
            }
            else if (action !== "edit") {
                return "field";
            }
        }

        if (action === "edit" && Array.isArray(props)) {
            for (let property of props) {
                if (property.name === prop) {
                    return property.type;
                }
            }
        }

        return 'hidden';
    }
}

Looks neat and simple enough.

I guess as far as making it a plugin, if you allow/force the config part to come from an external source, it should then be as simple as create an aor- and give it's use as you did just now .
So maybe require an URL in the constructor and this can be fectched from a file or generated by a backend server.

The only part which raise a question on my part is to be forced to move the resource configuration inside the factory (in your example the company.props config). With many resources it is going to become quiet messy no ?

It would be better to differentiate the resource config from the permissions part, so the factory simply add the permission (as specified in the documentation) to a component.
Could this be achieve with a Higher Order Component?

We don't want to force users to use factories for simple usage (it obfuscates the code). We've followed this path with ng-admin, and that's the part that I'm the least proud of.

React makes it easy to use your own component instead of the ones we provide - that includes a custom Factory component. This can be achieved in userland, nothing prevents you from doing so.

But that won't be added to the core.

@fzaninotto agreed!
The goal does not seems to add this to the core. We are just using this thread as a discussion since @zifnab87 as not set up a independent repo for this yet. Sorry for piggybacking :)

Your point is similar to mine. I can see the added value to have a mixin/decorator/hoc, however you want to call it, which let externalize the permissions. But i would not want this component to contain my resource description.
With such a component you could allow an administrator to define its own roles and associate permissions to it, without hard-coding them into your resource configuration.

@Phocea @fzaninotto we are definitely talking about a plugin and not a core feature. In the near future I am going to decouple config from factory. I am also going to make it work with separate configs one per resource so it is not a a mess. an important thing is the resolution of #1116 since I saw factory being called 20+ times!!

@Phocea here it is: https://github.com/zifnab87/ra-component-factory in a bit crude form still but I added a lot of enhancements and made it way more usable than it was.

@fzaninotto I would appreciate it if you could list it with other plugins/contributions in the ecosystem page! Thanks

Was this page helpful?
0 / 5 - 0 ratings

Related issues

ilaif picture ilaif  路  3Comments

pixelscripter picture pixelscripter  路  3Comments

alukito picture alukito  路  3Comments

aserrallerios picture aserrallerios  路  3Comments

kdabir picture kdabir  路  3Comments