ABAC in Laravel: permissions that look at your data (tutorial step by step)

Download the sample code: https://github.com/wnikk/-laravel-access-example

This is the second part. The first one, How to use Access Control Rules and CRUD in Laravel (tutorial step by step), built roles and permissions: RBAC.
This one goes further: permissions that look at the data. That is ABAC, Attribute-Based Access Control, and it arrived in version 3.x of "wnikk/laravel-access-rules".

You do not need the first part to follow this one. Everything is built from scratch.

What we had in 2.x

A short reminder, in case you skipped part one:

  • a rule is a name of something that can be permitted, orders.view;
  • an owner holds permissions: a user, a role, a group;
  • owners inherit from each other, which is how roles work;
  • an option splits one rule into several, profile.update.email;
  • the magic suffix .self means "only the author of the record".

All of it still works in 3.x, with the same tables and the same methods.

And here is what it could not say:

  • "a manager sees orders over 100 with fewer than 3 items";
  • "only orders of my department";
  • "everything, except clients from Paris";
  • and the hardest one: "show me the list of what I am allowed to see".

In 2.x each of these meant a policy class and a hand-written query, and the two had to agree forever.
In 3.x each of them is one line.

$role->addPermission('orders.view', when: 'order.cost > 100 && order.items.count < 3'); $user->can('orders.view', $order); // one record Order::allowedTo('orders.view')->paginate(); // the list, filtered by the database 

Where will we start?

We build a tiny back office of a shop:

  1. Orders with items, a client, a category and products.
  2. Two users, Ann and Bob, a role and two teams.
  3. Eighteen short examples. Each adds one idea and shows its result on the same six orders.

The data is small on purpose: you can check every result by eye.

Step 1: Create Laravel application

Version 3 works on Laravel 13 and PHP 8.4 or after.

composer create-project laravel/laravel abac-example 

Step 2: Install the package

composer require wnikk/laravel-access-rules php artisan vendor:publish --tag=access-config --tag=access-migrations 

Step 3: Tell the package who and what

File config/access.php. Three lists matter today:

// who can hold permissions 'owner_types' => [ App\Models\User::class, 'Role', 'Team', ], // models that conditions may read, by the name they have in conditions 'resources' => [ 'order' => App\Models\Order::class, 'client' => App\Models\Client::class, 'item' => App\Models\Item::class, 'category' => App\Models\Category::class, 'product' => App\Models\Product::class, ], // owner types that work as a tenant, see Example 9 'tenant_types' => ['Team'], 

resources is a whitelist. A condition can come from an admin panel, and it can only touch models listed here.

Step 4: Migrations

php artisan make:migration create_shop_tables 
public function up(): void { Schema::table('users', function (Blueprint $table) { $table->integer('department_id')->default(1); $table->integer('approval_limit')->default(0); }); Schema::create('clients', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('city')->nullable(); $table->integer('team_id'); }); Schema::create('categories', function (Blueprint $table) { $table->id(); $table->integer('parent_id')->nullable(); $table->string('name'); }); Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('name'); $table->boolean('restricted')->default(false); }); Schema::create('orders', function (Blueprint $table) { $table->id(); $table->string('number'); $table->integer('client_id')->nullable(); $table->integer('user_id'); // the author $table->integer('department_id'); $table->integer('category_id'); $table->integer('cost'); $table->integer('budget')->nullable(); $table->string('status'); $table->boolean('locked')->default(false); $table->dateTime('created_at'); }); Schema::create('items', function (Blueprint $table) { $table->id(); $table->integer('order_id'); $table->integer('price'); }); Schema::create('order_product', function (Blueprint $table) { $table->integer('order_id'); $table->integer('product_id'); $table->integer('quantity')->default(1); }); } 
php artisan migrate 

Step 5: Models

The user gets the usual trait:

use Wnikk\LaravelAccessRules\Traits\HasPermissions; class User extends Authenticatable { use HasPermissions; 

The order gets a new one. It adds allowedTo(), the filter of lists:

use Wnikk\LaravelAccessRules\Traits\HasAccessScope; class Order extends Model { use HasAccessScope; public $timestamps = false; protected $guarded = []; protected $casts = ['locked' => 'boolean']; public function client(): BelongsTo { return $this->belongsTo(Client::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function items(): HasMany { return $this->hasMany(Item::class); } public function products(): BelongsToMany { return $this->belongsToMany(Product::class)->withPivot('quantity'); } } 

Important: relations declare their return type. That is how the package knows client is a relation. It never calls a method to find out, because the method could be delete().

Client, Item, Product are plain models. Category points at itself:

class Category extends Model { public function parent(): BelongsTo { return $this->belongsTo(self::class, 'parent_id'); } } 

Step 6: Data

Seed it any way you like. This is what the examples expect. "Today" is a Friday, 12:00.

Users

id name department_id approval_limit
1 Ann 1 500
2 Bob 2 100

Clients: 1 Acme (Berlin, team 1), 2 Bolt (Paris, team 1), 3 Core (Berlin, team 2), 4 Dune (no city, team 2)

Categories: 1 Electronics → 2 Phones, 3 Laptops; 4 Furniture

Products: 1 Phone, 2 Charger, 3 Battery pack (restricted)

Orders

id number client author dept category cost budget status locked created items products × quantity
1 EU-1001 Acme Ann 1 Phones 150 200 draft today 08:00 100, 50 Phone ×1, Charger ×2
2 EU-1002 Bolt Ann 1 Laptops 900 800 review 10 days ago 300, 300, 200, 100 Battery pack ×5
3 US-2001 Core Bob 2 Furniture 50 100 paid today 11:00 50
4 EU-1003 Dune Bob 1 Electronics 450 600 draft yes yesterday 450 Phone ×1, Battery pack ×1
5 US-2002 Acme Bob 2 Phones 120 draft 20 days ago
6 EU-1004 none Ann 2 Furniture 700 700 review today 00:30 400, 200, 100 Charger ×10

Step 7: Rules, a role and two teams

php artisan make:seeder AccessSeeder 
use Wnikk\LaravelAccessRules\Facades\Access; public function run(): void { // "resource" says which model the rule is about Access::newRule('orders.view', 'View orders', resource: 'order'); Access::newRule('orders.approve', 'Approve orders', resource: 'order'); Access::for('Role', 'manager')->create('Managers'); Access::for('Team', 1)->create('North'); Access::for('Team', 2)->create('South'); User::find(1)->inheritPermissionFrom('Team', 1); // Ann is in team North User::find(2)->inheritPermissionFrom('Team', 2); // Bob is in team South } 

Access::for(...) is new in 3.x: a short way to work with an owner that has no model.

One helper route, for this tutorial only, to switch users:

Route::get('/login-as/{id}', fn ($id) => Auth::loginUsingId($id) ? redirect('/orders') : abort(404)); 

Let's move on to the most interesting part ☕

Every example gives Ann or Bob one permission and shows which of the six orders it opens.
To try the next one, take the previous permission away: $user->remPermission('orders.view');

Example 1

The first condition. A value of a column and a count of related records:

$ann->addPermission('orders.view', when: 'order.cost > 100 && order.items.count < 3'); 

Check one record, the usual Laravel way:

class OrderController extends Controller { public function show(Order $order) { Gate::authorize('orders.view', $order); return Response::json($order); } } 

Result: orders 1, 4, 5 open. Order 2 costs enough but has four items. Order 3 is too cheap.

A mistake in a condition does not wait for a user to find it. It stops you when you save:

$ann->addPermission('orders.view', when: 'order.itms.count < 3'); // InvalidConditionException: "itms" in "order.itms.count" is not a relation of App\Models\Order // the same for an unknown model (invoice.cost) and an unknown function (yesterday()) 

A mistyped column is the one thing saving lets through, because a model may have it as an accessor. Example 15 catches those.

Example 2

Now the list. No new rule, no new query, the same permission:

public function index() { return Response::json(Order::allowedTo('orders.view')->orderBy('id')->paginate()); } 

The condition became a part of WHERE:

select * from "orders" where ( "orders"."cost" > ? and (select count(*) from "items" where "orders"."id" = "items"."order_id") < ? ) 

One query. Pagination, sorting and your own where() work as always.

The list and the single check always agree. You will never show a row that answers 403 when clicked.

The list of orders as Ann: orders 1, 4 and 5, filtered by the condition of her permission

Example 3

Compare a record with the user:

$ann->addPermission('orders.view', when: 'order.department_id == user.department_id'); 

Ann (department 1) sees 1, 2, 4. Give Bob the same line and he sees 3, 5, 6. One permission, a different result for each user. So it belongs on a role:

Access::for('Role', 'manager')->allow('orders.approve', when: 'order.cost <= user.approval_limit && order.user_id != user.id'); $ann->inheritPermissionFrom('Role', 'manager'); 

"Approve up to your limit, but never your own order." Ann (limit 500) may approve 3, 4, 5.

user. is any attribute of your User model. Plus user.id, user.tenant (Example 9) and user.guest.

Example 4

Walk through a relation:

$ann->addPermission('orders.view', when: "order.client.city != 'Paris'"); 

Result: 1, 3, 5. Order 2 is from Paris, and orders 4 and 6 are missing too.

Client of order 4 has no city. Order 6 has no client. A comparison with NULL is unknown, as in SQL, and a permission with an unknown condition does not apply.

Say it out loud when you mean such records too:

$ann->addPermission('orders.view', when: "order.client.city == null || order.client.city != 'Paris'"); 

Result: 1, 3, 4, 5, 6.

Example 5

Prohibitions, and who wins. The priority has five steps, from weak to strong:

  1. nothing is said: not permitted
  2. inherited permission
  3. inherited prohibition
  4. own permission
  5. own prohibition

Watch it work, line by line:

Access::for('Role', 'manager')->allow('orders.view'); // Ann sees 1 2 3 4 5 6 Access::for('Role', 'manager')->deny('orders.view', when: 'order.locked'); // Ann sees 1 2 3 5 6 $ann->addPermission('orders.view', when: 'order.cost > 400'); // Ann sees 1 2 3 4 5 6 $ann->addProhibition('orders.view', when: "order.status == 'paid'"); // Ann sees 1 2 4 5 6 

Look at the third line. The role hides locked orders from all its members. Ann's own permission is stronger, so she gets order 4 back. No need to clone the role for one person.

A prohibition takes a permission away and vetoes nothing. Your Gate::before for a super administrator, your policies and other packages keep their word, as in 2.x.

Example 6

Sometimes there is no record yet. A menu item, a "New order" button. There are three ways to ask:

$user->can('orders.view', $order); // this order $user->can('orders.view', Order::class); // orders in general: is there any order I may see? $user->can('orders.view'); // nothing to look at: only permissions without a condition count 

With the permission of Example 1, the second line is true and the third is false.

@can('orders.view', App\Models\Order::class) <a href="/orders">Orders</a> @endcan 

authorizeResource() passes the class for index and create by itself.

The menu of Ann has the item Orders, the menu of a user without the permission has not

The same menu for a user that holds nothing: no Orders

Example 7

Aggregates over related records: count, sum, min, max, exists. Each can take a filter:

'sum(order.items.price) <= user.approval_limit' // Ann: 1, 3, 4, 5 Bob: 3, 5 'exists(order.items, price >= 300)' // 2, 4, 6 

Inside a filter, names without a prefix are columns of the related model.

And time:

"order.created_at >= ago('7 days')" // 1, 3, 4, 6 

Also now(), today(), after('3 days'), monthStart(-1), yearStart(). A permission that expires by itself, without a cron job.

Example 8

Arithmetic, ranges and text:

'order.cost * 1.2 > order.budget' // 2, 6 over budget with 20% on top 'order.cost between 100 and 500' // 1, 4, 5 both ends included "startsWith(order.number, 'EU-')" // 1, 2, 4, 6 "lower(order.client.city) == 'berlin'" // 1, 3, 5 

Also endsWith(), contains(), + - * / and brackets. Text functions are exact, letter case included, on every database. Use lower() on both sides to ignore case.

Example 9

Teams, or tenants. Step 3 set 'tenant_types' => ['Team'], Step 7 created two teams.
A user that inherits from teams gets their ids as the list user.tenant:

Access::for('Role', 'manager')->allow('orders.view', when: 'order.client.team_id in user.tenant'); 

Ann (North) sees 1, 2, 5. Bob (South) sees 3, 4. Move a user to another team and the list follows. There is no role per team and nothing to keep in sync.

Example 10

Trees. "Electronics and everything under it":

$ann->addPermission('orders.view', when: "order.category_id in belowOrSelf('category.name', 'Electronics')"); 

Result: 1, 2, 4, 5. Phones and Laptops came along, Furniture stayed out.

There are four of them: below, belowOrSelf, above, aboveOrSelf. The tree is walked once per request, and the database receives a plain category_id in (1, 2, 3).

Example 11

Many-to-many, with columns of the pivot table:

'not exists(order.products, restricted)' // 1, 3, 5, 6 no dangerous goods inside 'exists(order.products, pivot.quantity >= 5)' // 2, 6 wholesale 'sum(order.products.pivot.quantity) >= 3' // 1, 2, 6 

The relation has to load the column, ->withPivot('quantity'). We did that in Step 5. Forget it, and the condition is refused when you save it, with a hint.

Polymorphic relations (morphMany, morphToMany, morph map) need nothing special. They work the same way, see Example 18.

Example 12

The environment, and guests:

Access::for('Role', 'manager')->allow('orders.approve', when: 'env.weekday in [1, 2, 3, 4, 5] && env.hour between 9 and 18'); 

Also env.ip, env.now, env.today, env.app. Such a part is computed before the query: on a Saturday the database gets WHERE 0 = 1 and does not read a single row.

Visitors without a login are checked as one owner, named in config:

'guest' => ['type' => 'Role', 'id' => 'guest'], 
Access::for('Role', 'guest')->create('Guests'); Access::for('Role', 'guest')->allow('catalog.view'); Gate::forUser(null)->allows('catalog.view'); // true 

Example 13

Two things for people who dislike strings. Names of rules as an enum:

enum Ability: string { case OrdersView = 'orders.view'; } $ann->addPermission(Ability::OrdersView, when: 'order.cost > 100'); $ann->can(Ability::OrdersView, $order); Order::allowedTo(Ability::OrdersView)->get(); 

And conditions built in code. The result is the same as from text:

...
Source:https://dev.to/wnikk/abac-in-laravel-13-permissions-that-look-at-your-data-36gh