With the widespread adoption of frontend frameworks, it has become common to see apps built as standalone client-heavy apps that communicate with a backend API, with this paradigm comes a number of challenges and interesting ways to solve them. One of these challenges is authorization, in this post I will share some ideas on how to approach this as well a plug an open-source library I put together to facilitate this approach, please share your thoughts with me on the merits and demerits of this approach.
Goals
According to the Vue.js docs:
At the core of Vue.js is a system that enables us to declaratively render data to the DOM using straightforward template syntax:
It also offers imperative escape hatches like watch
and lifecycle methods and the most touted point in favour of Vue.js is its approachability.
So we want a solution that is
- Declarative and composable,
- Offers imperative escape hatches and,
- Has an approachable and easy to reason about API.
I promise we will get to the code soon.
The API
To start with, authorization involves granting or denying access to a resource and technically it involves identifying what resources the user should have access to, from these I find that the inputs to the system are requiredPermissions
anduserPermissions
also the output is a boolean true
or false
. It's also possible we want more fine-grained control and so we may allow or disallow access if the userPermissions
include all
of the requiredPermissions
or in other cases, it's okay if they have some
of the requiredPermissions
.
so we have identified a third input - a boolean control all
.
At first I wanted to use VueJS directive like
<SomeComponent v-guard="['is-admin', 'is-shopowner']"/>
But after a few hours of failing to get it to work I stumbled on a thread on Vue Forum where its was suggested that using a directive was ill advised. so I tried a functional component instead.
<v-guard :permissions="['is-admin', 'is-shopowner']">
<SomeComponent/>
</v-guard>
This meets our goal of a declarative API.
For imperative operations like making requests in our methods or providing feedback if a user does not have permission, we can inject methods like
<script>
export default {
methods:{
makeSomeRequest(somedata){
// there is also this.$permitsAll()
If(this.$permitsAny(['add-post', 'update-post']){
// make the request here
}else{
//you need to do pay us in order to do this.
}
}
}
}
</script>
<template>
<!-- in templates -->
<button :disabled="!$permitsAny(['add-post', 'update-post'])>Update this post</button>
</template>
The v-guard
component will not cover disabling it's children/slots as it works on the Virtual DOM layer and completely avoids rendering it's children.
Finally, for routes
we could still use the imperative beforeEnter
hook and check however we can take this one level up by doing so in library code so the userland API is just to mark the routes with the required permissions like this.
const routes = [
{
path: ':id/edit',
name: 'EditPost',
meta: {
guard: {
permissions: ['edit-posts', 'manage-posts'],
all: true
},
}
]
All that remains now is to provide a way for the developer to provide the plugin with the user's permission. For this, we can just require them to provide an option on the root of their component tree, this could be a function or just an array let's call it permissions
(I am terrible at naming things 🤷🏾♂️️) If it's a function it should synchronously return an array of the user's permissions
Finally, the code.
We break the problem into bits and assemble the solutions in a plugin.
Setup
When installing the plugin we would call the permissions function option the developer has implemented in their root component attach it to the Vue prototype so it can be called from any component as a normal member. We can do this in the beforeCreate
lifecycle this is how Vuex makes $store
available in every component.
Vue.mixin({
beforeCreate: permissionsInit
});
function permissionsInit(this: Vue) {
let permFn = getPropFromSelfOrAcenstor("permissions", this.$options);
if (!permFn) {
console.error(
`[v-guard]`,
`To use this plugin please add a "permissions" synchronuous function or object option as high up your component tree as possible.`
);
return;
}
Vue.prototype.$getPermissions =
typeof permFn === "function" ? permFn.bind(this) : () => permFn;
let perms = typeof permFn === "function" ? permFn.call(self) : permFn;
Vue.prototype.$permitsAll = function permitsAll(permissions: Permissions) {
//we will discuss the implementation of isPermitted shortly
return isPermitted(perms, permissions, true);
};
Vue.prototype.$permitsAny = function permitsAll(permissions: Permissions) {
return isPermitted(perms, permissions, false);
};
}
//helper function to recursively get a property from a component or it's parent.
function getPropFromSelfOrAcenstor(
prop: string,
config: ComponentOptions
): Function | null {
if (config[prop]) {
return config[prop];
}
if (config.parent) {
return getPropFromSelfOrAcenstor(prop, config.parent);
}
return null;
}
When the plugin is installed we call permissionsInit
on the beforeCreate of every component, this function takes the component instance and gets the permissions
option (the function or object the client code must implement) from the component or it's parent using a helper function getPropsFromSelfOrAncestor
if this has not been implemented we stop processing and warn the user.
Now having the user's permissions we add the imperative parts of our API $permitsAll
and $permitsAny
this delegate to an isPermitted
function which we would now show.
function isPermitted(
usersPermissions: Array<string>,
permissions: Permissions, // Array | string
all: boolean
) {
if (!permissions || !usersPermissions) {
throw new Error(`isPermitted called without required arguments`);
}
permissions = Array.isArray(permissions)
? permissions
: permissions.trim().split(",");
let intersection = permissions.reduce(
(intersect: Array<string>, perm: string) => {
if (
!usersPermissions.map((s: string) => s.trim()).includes(perm.trim())
) {
return intersect;
}
if (!intersect.includes(perm.trim())) {
intersect.push(perm);
}
return intersect;
},
[]
);
return all
? intersection.length >= permissions.length
: intersection.length > 0;
}
This function takes the user's permissions and the required permissions and determines the common element (intersection) between these. it also takes a third control argument (boolean all
). If all the required permissions are necessary (all = true
) then the common elements array should have the same members as the user's permission, if however not all the required permissions are necessary, (all = false
) we only need to have at least one common element. I know this may seem like too much but I find it's easier to reason about the function as a Set problem that way the mental model is clear.
We also account for passing a comma-separated string as the required permissions, this makes the library more flexible. Finally, there is a lot of trimming to deal with extraneous whitespace characters.
This function could use two major refactors
Use a
Set
for the intersection, that way we don't need to check if it already contains the permission in we are looping over.Memoize the function so we don't recalculate intersections for which we already know the outcome. this is useful when rendering a list of items that are guarded.
I would look into this for a patch to the library I wrote.
V-guard component to conditionally render component trees.
For this, we will use a functional component as they are cheaper to render and we don't really need state so they are sufficient.
Vue.component("v-guard", {
functional: true,
props: {
permissions: {
type: [Array, String],
default: () => []
},
all: {
type: Boolean,
default: false
}
},
render(h, { props, slots, parent }) {
let { $getPermissions } = parent;
if (!$getPermissions) {
console.error(
`[v-guard]`,
`v-guard must be a descendant of a component with a "permissions" options`
);
}
const { permissions, all } = props;
if (
isPermitted($getPermissions() || [], permissions as Permissions, all)
) {
return slots().default;
}
return h();
}
});
Functional components in Vue.js have a context
variable passed to their render
function, this contains the among other things props
, slots
and parent
which we need. from the parent
, we can grab the $getPermissions
which we injected during the plugin installation.
Due to the nature of functional components, the $getPermission
function is not injected into it as it's not an object instance, it's a function.
In the render
function we call the isPermitted
function with the user's permission which we now have access to by calling $getPermissions
and the required permissions which have been passed as props to the v-guard
component.
//permissions here are passed as props.
<v-guard :permissions="['is-admin', 'is-shopowner']">
<SomeComponent/>
</v-guard>
For routes
When installing the plugin the developer can pass as router
option to the plugin, which is a VueRouter instance. (this would also require them to pass an errorRoute
string which is the route to go to for unauthorized actions)
function PermissionPlugin(
Vue: VueConstructor,
options: VueGuardOptions = {}
): void {
if (options.router) {
addRouterGuards(options.router, options.errorRoute);
}
Vue.component("v-guard", {
functional: true,
...// we covered this already
})
function addRouterGuards(router: VueRouter, errorRoute : string) {
router.beforeResolve(
(to: RouteRecord, from: RouteRecord, next: Function) => {
const guard = to.meta && to.meta.guard;
if (!guard) {
return next();
}
const { $getPermissions } = Vue.prototype;
if (!$getPermissions) {
if (errorRoute) {
return next(errorRoute);
}
throw new Error(`You need to pass an "errorRoute" string option `);
}
const usersPermissions = $getPermissions();
const { permissions, all = true } = guard;
if (!isPermitted(usersPermissions, permissions, all)) {
return next(errorRoute);
}
return next();
}
);
}
}
}
Here we use VueRouter's beforeResolve
guard to check if the user is permitted to view the route in which case we proceed to the route, else we redirect them t the errorRoute
.
To use the library now the developer would do something like
//Permissions is the plugin, router is a VueRouter instance,
Vue.use(Permissions, { router, errorRoute: "/403" });
new Vue({
router,
permissions() {
return this.$store.getters.userPermissions;
},
render: h => h(App),
...// other options
}).$mount('#app')
Please share your thoughts and suggestions. thanks.
Top comments (0)