Route-Based Permissions
One of the projects I’m working on is an operations app for a coworking space. It helps staff manage subscriptions, members, tours… you get the idea. Though the app’s primary goal is to support the staff, it also allows members to manage their accounts and view a rolodex of members.
User actions typically relate directly to roles: staff, admins (higher-level staff), members, and account managers (members who manage things like credit card info). However, some users don’t fit squarely into a role and need the ability to perform other actions.
To support these outliers, I needed to build a role-based system that verifies individual user permissions. Thankfully, Spatie / Freek Van der Herten wrote a handy package, perfect for the job.
With the package, I assign permissions to roles and roles to users, but I can assign permissions directly to users when needed.
Route-based permissions, conditionally
I decided to name permissions after routes. This allows me to rely on simple middleware to check permissions on each request with little effort.
Here’s the middleware for the curious:
My routes and permissions have CRUDdy names like teams.index
, teams.show
, and teams.update
.
With this system, how can I restrict a member to updating only xyr own team?
I built a system that allows comparing the logged in user’s attributes to route params. It helps with things like giving access to teams.update
, but only to the user’s own team. It works great for exactly that, but has drawbacks:
Conditional strings complicate route names.
Basically, I append comma-separated conditions to the route names.
// The colon indicates the start of conditions.
// Route param on the left; user attribute on the right.
teams.update:team.id=team_id// Multiple conditions are possible, but this is a fake example:
locationTeamBilling.show:team.id=team_id,location.id=location_id
This isn’t too bad when tucked out of sight, but it ties me to my decision to use route model binding. I have mixed feelings about it, though. Sure, I could probably work around the issue by replacing .
with _
in the check if I decide to drop route model binding later, but that feels gross to me.
Conditional strings complicate templates.
As explained before, roles serve only as a shortcut to mass assign permissions. This means conditionally accessible content should always test against permissions, never roles.
@includeWhen($user->hasPermissionTo('teams.update:team.id=team_id'))
Why, if permissions are route-based, should I hide from users something displayed at accessible routes? Users may not have permission to access related content typically accessed at another route. For example, a user may be permitted to see xyr own team page, but not the team’s payment source information.
Conditional strings require lots of rows in the database.
For every conditional variant used, I’d need a new record in the database. Plus I’d need a system to prevent unwitting non-identical “duplication” lest template conditionals become burdensome.
locationTeamBilling.show:team.id=team_id,location.id=location_id
// vs
locationTeamBilling.show:location.id=location_id,team.id=team_id
These would pass the same test, but if both ended up in the database, templates would need both in conditionals to properly provide access.
Enter the simple solution.
After working through what conditionals on route-based permissions require, I realized it’s more complex than I like. I decided to stick with simple permission names and create more routes.
Instead of teams.update:team.id=team_id
, I just use myTeam.update
. The controller uses auth()->user()->team
to prevent updating other teams.
Look at how much simpler the check is. Here’s what route-based permissions with conditionals need (I’m sure I could simplify it, but not as much as dropping it altogether does):
Without conditions, I can pass the route name in the middleware and drop it down to a single line. Even the comment is simpler: