Using Bouncer with Laravel and Vue
Our goal was to find a way to control what actions a user is allowed to perform, and to have these permissions stored in one central location (Laravel API) while being consumed by a Vue.js frontend. We can do this by utilising Bouncer.
About Bouncer
Bouncer is a package made by JosephSilber that integrates with laravel to provide a way manage a user's roles & permissions in your app. Check out the github page for instructions on how to setup Bouncer.
Using Bouncer with Laravel
After running Bouncer's migrations you can easily provide abilities to your users in a variety of ways:
Giving a user a simple named ability
Bouncer::allow($user)->to('archive-customers');
Relating an ability to a model
Bouncer::allow($user)->to('archive', Customer::class);
You can also specify the model instance you want them to have this ability on:
Bouncer::allow($user)->to('archive', $customer);
Giving ownership of a model
Bouncer::allow($user)->toOwn(Customer::class);
// Pass an array to only allow certain actions to performed on our Customer
Bouncer::allow($user)->toOwn(Customer::class)->to(['archive', 'edit']);
By using toOwn
, this allows our user or role to only be able to perform actions on the customer's that they own,
which is determined by the customer's user_id
which is checked against the authenticated user's id
. (this can be modified)
Allowing users to manage a model
Bouncer::allow($user)->toManage(Customer::class)
This is similar to toOwn
except in that it doesn't care about who owns the model.
So essentially a user who can manage a customer can do anything to that model that isn't explicitly forbidden.
Using Bouncer on an app with a Vue frontend
So using Bouncer's docs we can figure out how to use Bouncer with Laravel easily enough. However we create a lot of application's where the frontend is using Vue and there are often cases where we may want to change a frontend components based on a user's permissions, e.g., hiding a navigation bar item based on whether a user has access to that page or not.
Sending the abilities to the frontend
So how can we check on our frontend if a user is allowed to perform certain actions?
First we need to return our abilities to the frontend when we retrieve our logged in user. Bouncer provides a couple of ways to do this:
Abilities can be retrieved through the abilities
relationship
return $user->abilities;
This will return a collection of Silber\Bouncer\Database\Ability
objects and the json will look somewhat similar to the following:
[
{
"id": 3,
"name": "edit",
"title": "Edit Customers".
"entity_id": 24,
"entity_type": "customer"
"only_owned": false,
"pivot": {
"ability_id": 3,
"entity_id": 36,
"entity_type": "App\User\User",
"forbidden": 0
}
}
]
In this example we are using Laravel's morphMap()
so we can use a simpler name
for the customer model rather than the full namespace, so the 'entity_type'
is
'customer'
rather than 'App\Customer\Customer'
The problem with returning abilities via the relationship is that it doesn't return any abilities granted to the user through any roles they have assigned. So if you are using roles in your app, this won't be sufficient.
Bouncer also provides two methods for getting the abilities of a user and their roles.
// Get all abilities the user has.
$user->getAbilities();
// Get the abilities explicitly forbidden
$user->getForbiddenAbilities();
However it would be nice to get all of these abilities in one collection. A user
may have been given ability and then later had that ability forbidden. The
results of getAbilities()
however doesn't have anything to indicate that this
ability is forbidden as all it has is the old ability they were originally
given.
So on my User model I made a function to return all the user's abilities with a flag to indicate whether this ability was forbidden or not.
/**
* Return all the user's abilities and if they have explicitly been
* forbidden from having an ability, then we return with a forbidden flag
* set to true. Unfortunately bouncer doesn't have a method for returning
* all abilities, forbidden or not
*/
public function getUserAbilities()
{
$abilities = $this->getAbilities()->merge($this->getForbiddenAbilities());
$abilities->each(function ($ability) {
$ability->forbidden = $this->getForbiddenAbilities()->contains($ability);
});
return $abilities;
}
So at this point we should be able to return these abilities with our user and the frontend will have access to all of these when it's checking a user's permissions.
Checking abilities on the frontend
The next thing we need to do is create a way to check on the frontend if the logged in user has the required abilities to perform an action. So in our solution we have a global mixin that adds some getters we can use to check abilities.
import Vue from 'vue'
import { mapState, mapGetters } from 'vuex'
Vue.mixin({
computed: {
...mapState('auth', {
$user: 'user'
}),
...mapGetters('auth', {
$can: 'can',
$cannot: 'cannot',
$isA: 'isA',
$isNotA: 'isNotA'
})
}
})
These getters are defined in our auth.js
module which is where our logged
in user is defined.
import Bouncer from 'utils/bouncer'
export default {
namespaced: true,
state: {
user: {}
},
getters: {
bouncer: (state) => new Bouncer(state.user),
can: (state, getters) => getters.bouncer.can.bind(getters.bouncer),
cannot: (state, getters) => getters.bouncer.cannot.bind(getters.bouncer),
isA: (state, getters) => getters.bouncer.isA.bind(getters.bouncer),
isNotA: (state, getters) => getters.bouncer.isNotA.bind(getters.bouncer)
}
}
And as you can see we have a third file we've called bouncer.js
which is where
the methods for checking roles and abilities are stored.
The constructor:
import { map, pick, find } from 'lodash'
export default class Bouncer {
constructor (user) {
if (!user) {
this.id = null
this.abilities = []
this.roles = []
return
}
const abilityMapper = (ability) => {
return pick(ability, [
'id',
'entity_id',
'entity_type',
'name',
'forbidden',
'only_owned',
'title'
])
}
this.id = user.id
this.roles = map(user.roles, role => pick(role, ['name', 'title']))
this.abilities = map(user.abilities || [], abilityMapper)
}
}
You may not need to map the roles and abilities depending on how you return them from the api.
Here is our can()
method that does all our permission checking. It's basically a
bunch of if statements to filter down to the abilities related to the one we are
checking and then return a true
boolean if it's not forbidden.
// Find the abilities that give the user permission to do the ability we are
// checking and if we have one and the ability isn't one that forbids them
// then we return true.
can (abilityName, entityType = null, entity = null) {
// Filter abilities to only ones that might be relevant to the given ability name.
let abilities = this.abilities.filter((ability) => {
if (abilityName === ability.name || ability.name === '*') {
if (ability.entity_type === '*') {
return true
}
// if the ability has only_owned set to true entities to be allowed to be accessed
// then we need to check that the entity's user_id matches the id of our
// user
if (ability.only_owned) {
if (entity === null || entityType === null) {
return false
}
if (entityType === ability.entity_type && entity.user_id !== this.id) {
return false
}
}
if (ability.entity_type && entityType !== ability.entity_type) {
return false
}
if (ability.entity_id) {
if (!entity) {
return false
}
if (entity.id !== ability.entity_id) {
return false
}
}
return true
}
return false
})
// if there are no relevant abilities or some of the relevant abilities are
// forbidden then return false
if (abilities.length === 0 || abilities.some(ability => ability.forbidden)) {
return false
}
return true
}
We also have an isA()
method that checks that the user is a particular role.
// Determine if the user's roles contain any of the roles we are looking for
isA (roles) {
roles = typeof roles === 'string' ? Array.from(arguments) : roles
return roles.some((name) => {
return find(this.roles, { name })
})
}
And to check the opposites we have a cannot()
and isNotA
cannot (ability, entityType = null, entity = null) {
return !this.can(ability, entityType, entity)
}
isNotA (roles) {
return !this.isA(roles)
}
Then when you need to check on our Vue component simply call the $can()
or
$isA()
and pass in the ability you want to check, and any relevant entity
name (this is why I used morphMap
) and the relevant entity id if your are
checking a specific entity
<router-link v-show="$can('archive', 'customer')" :to="{ name: 'customer-archive' }">
<a class="nav-link">
<i class="fa fal-id-badge"></i> Customer Archive
</a>
</router-link>
<!-- or -->
<button v-if="$can('archive', 'customer', customerObject)" type="button">
Archive
</button>
computed: {
showLabel () {
return $isA('admin')
},
disableButton () {
return $can('archive-customers')
}
}
Checking permissions on the Router
We may also want to check a user's permissions when they change routes, to prevent access to routes that the user should be able to access. A way to do this is using Vue Router's navigation guards.
router.beforeEach((to, from, next) => {
const bouncer = store.getters['auth/bouncer']
if (has(to.meta, 'permissions') || has(to.meta, 'roles')) {
if (typeof to.meta.permissions === 'function') {
if (!to.meta.permissions(bouncer, to, from)) {
// Push notification to inform user they do not have permission
// redirect to a universal page they will have access to
next({ name: 'home', replace: true })
return
}
} else if ((has(to.meta, 'permissions') && bouncer.cannot(to.meta.permissions)) || (has(to.meta, 'roles') && bouncer.isNotA(to.meta.roles))) {
// Push notification to inform user they do not have permission
// redirect to a universal page they will have access to
next({ name: 'home', replace: true })
return
}
}
// they either have permissions or no permissions are required so continue on
// to the intended route
next()
})
So here we are using Lodash's has
to see if the route meta
object has permissions
or roles
defined on it and if a permissions
function was defined we run that function to determine if the user has access to
the route. If not then we just check using our bouncer utility's cannot
or
isNotA
and in the case they don't have access to this route then we can
redirect to some other page, otherwise we'll continue on to the next route.
On our actual routes we will define our permissions like so:
{
path: '/users/create',
name: 'users.create',
component: UsersCreate,
meta: {
permissions: 'create-users',
roles: ['admin']
}
},
or in this case we are checking they can edit a specific user and they came from a certain route:
{
path: '/users/:id',
name: 'users.edit',
component: UsersEdit,
meta: {
permissions (bouncer, to, from) {
return bouncer.can('edit', 'user', { id: to.params.id } )
&& from.name === 'users.create'
}
}
}
Summary
- So at this point we've got our user's abilities in one nice collection from the api and are returning it with our logged in user's resource.
- We have created our
can
andisNotA
methods to check the user's abilities and roles - We are using the methods on our Vue components
- We are checking before each route change that user's has permission to the route