Lesson 42 • Advanced
RBAC & ACL Permissions 🔒
By the end of this lesson you'll be able to tell authentication from authorization and build a real PHP authorization layer — roles, permissions, per-resource ACLs, policies with ownership checks, and middleware that enforces it all server-side, deny-by-default.
What You'll Learn in This Lesson
- Separate authentication (who you are) from authorization (what you can do)
- Design the RBAC database schema: users, roles, permissions, and pivot tables
- Build a can() check with role inheritance and check permissions, not role names
- Add per-resource ACLs for "only this user, on this object" access
- Write a policy/gate with an ownership check so authors edit their own posts
- Enforce permissions server-side with middleware, deny-by-default
php file.php. The Output panel under each example shows exactly what to expect.1️⃣ Authentication vs Authorization
These two words look alike and get mixed up constantly, but they answer different questions. Authentication is "who are you?" — the login that proves identity. Authorization is "what are you allowed to do?" — the permission check that runs after login, on every protected action. Being logged in does not mean you're allowed to do everything. Authentication always comes first; this lesson is about everything that happens next.
<?php
// Two questions, two different jobs. Don't confuse them.
// AUTHENTICATION = "Who are you?" — proving identity (login).
$loggedInUserId = 7; // the server already verified the password
$isAuthenticated = $loggedInUserId !== null;
// AUTHORIZATION = "What are you allowed to do?" — checking permission.
// You can be authenticated (logged in) and still NOT be authorized.
$canDeleteUsers = false; // this user is a plain member, not an admin
echo $isAuthenticated ? "Authenticated: yes\n" : "Authenticated: no\n";
// Deny-by-default: only allow when permission is explicitly true.
if ($canDeleteUsers) {
echo "Authorized: deleting user...\n";
} else {
echo "Authorized: NO — 403 Forbidden\n"; // blocked even though logged in
}
?>Authenticated: yes
Authorized: NO — 403 ForbiddenNotice the gate starts from "no" and only flips to "yes" when a permission is explicitly true. That is deny-by-default, and it's the single most important habit in this lesson — anything you forget to allow stays safely blocked.
2️⃣ The RBAC Database Schema
RBAC (Role-Based Access Control) connects three things: users get roles, and roles grant permissions. A user never owns a permission directly — they own roles, and roles carry permissions. Because a user can have many roles and a role can have many permissions, you join them with pivot tables (also called join tables): user_roles and role_permissions. Here's the schema and the single query that answers "can this user do X?".
-- RBAC schema: users get roles, roles grant permissions.
-- Two pivot (join) tables connect the many-to-many relationships.
CREATE TABLE roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL -- 'viewer', 'editor', 'admin'
);
CREATE TABLE permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL -- 'posts.create', 'users.delete'
);
-- Pivot: which permissions a role grants (many-to-many)
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id),
permission_id INT REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id) -- no duplicate pairs
);
-- Pivot: which roles a user has (many-to-many)
CREATE TABLE user_roles (
user_id INT REFERENCES users(id),
role_id INT REFERENCES roles(id),
PRIMARY KEY (user_id, role_id)
);
-- A permission a user has = any permission granted by any of their roles.
-- One query answers "can this user do X?":
SELECT 1
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = 7 AND p.name = 'posts.create'
LIMIT 1; -- one row back = allowed, no rows = deniedSELECT is the permission check — one row back means allowed.3️⃣ Checking Permissions with RBAC
Now the PHP side. The whole point of RBAC is the single method can($userId, $permission) that the rest of your app asks. Role inheritance keeps it tidy: an admin inherits everything an editor can do, which inherits everything a viewer can do — so you define each permission once. Read how getAllPermissions() walks the hierarchy and how can() ends with return false (deny-by-default).
<?php
// Role-Based Access Control (RBAC):
// Users -> Roles -> Permissions, with role inheritance.
// You never grant a permission to a person — you grant it to a ROLE,
// then give people roles. Change the role once, everyone updates.
class RBAC
{
/** @var array<string, array<string>> role => its own permissions */
private array $roles = [];
/** @var array<string, array<string>> role => roles it inherits from */
private array $hierarchy = [];
/** @var array<string, array<string>> userId => roles */
private array $userRoles = [];
public function defineRole(string $name, array $permissions): void
{
$this->roles[$name] = $permissions;
}
public function setHierarchy(string $role, array $inheritsFrom): void
{
$this->hierarchy[$role] = $inheritsFrom; // admin inherits editor, etc.
}
public function assignRole(string $userId, string $role): void
{
$this->userRoles[$userId][] = $role;
}
/** A role's own permissions PLUS everything it inherits (recursively). */
public function getAllPermissions(string $role): array
{
$perms = $this->roles[$role] ?? [];
foreach ($this->hierarchy[$role] ?? [] as $parent) {
$perms = array_merge($perms, $this->getAllPermissions($parent));
}
return array_values(array_unique($perms));
}
/** The one method the rest of your app calls. Deny-by-default. */
public function can(string $userId, string $permission): bool
{
foreach ($this->userRoles[$userId] ?? [] as $role) {
// strict in_array (3rd arg true) avoids loose "0 == 'x'" bugs
if (in_array($permission, $this->getAllPermissions($role), true)) {
return true; // a role granted it -> ALLOW
}
}
return false; // nothing granted it -> DENY
}
public function show(string $userId, string $name, string $permission): void
{
$mark = $this->can($userId, $permission) ? "ALLOWED" : "DENIED";
echo str_pad($name, 18) . str_pad($permission, 16) . "-> {$mark}\n";
}
}
$rbac = new RBAC();
$rbac->defineRole("viewer", ["posts.view", "comments.view"]);
$rbac->defineRole("editor", ["posts.create", "posts.update", "posts.publish"]);
$rbac->defineRole("admin", ["users.create", "users.delete", "settings.update"]);
// Hierarchy: admin inherits editor, which inherits viewer.
$rbac->setHierarchy("admin", ["editor"]);
$rbac->setHierarchy("editor", ["viewer"]);
$rbac->assignRole("u1", "admin"); // Alice
$rbac->assignRole("u2", "editor"); // Bob
$rbac->assignRole("u3", "viewer"); // Charlie
echo "=== Access Checks ===\n";
$rbac->show("u1", "Alice (admin)", "users.delete");
$rbac->show("u1", "Alice (admin)", "posts.view"); // inherited twice over
$rbac->show("u2", "Bob (editor)", "posts.create");
$rbac->show("u2", "Bob (editor)", "users.delete"); // not granted -> deny
$rbac->show("u3", "Charlie (viewer)","posts.view");
$rbac->show("u3", "Charlie (viewer)","posts.create"); // not granted -> deny
$adminPerms = $rbac->getAllPermissions("admin");
echo "\nAdmin has " . count($adminPerms) . " permissions (own + inherited).\n";
?>=== Access Checks ===
Alice (admin) users.delete -> ALLOWED
Alice (admin) posts.view -> ALLOWED
Bob (editor) posts.create -> ALLOWED
Bob (editor) users.delete -> DENIED
Charlie (viewer) posts.view -> ALLOWED
Charlie (viewer) posts.create -> DENIED
Admin has 8 permissions (own + inherited).Charlie the viewer is denied posts.create not because of an explicit rule, but because nothing granted it — the loop finishes and falls through to return false. That is the safe default working for you.
4️⃣ ACL — Per-Resource Permissions
RBAC is broad: "editors can edit posts." But sometimes access is about one specific object — this document, shared with this one person. That's an ACL (Access Control List): a list of "user → resource → allowed actions" entries. Think Google Drive, where you share one file with one colleague as read-only. RBAC and ACL are not rivals; real apps use both.
<?php
// Access Control List (ACL): permissions on a SPECIFIC resource, per user.
// RBAC says "editors can edit posts". ACL says "Bob can edit THIS post".
// Use ACL when access is per-object: shared docs, private folders, etc.
class ACL
{
/** @var array<string, array<string>> "userId:resourceId" => actions */
private array $entries = [];
public function grant(string $userId, string $resource, array $actions): void
{
$this->entries["{$userId}:{$resource}"] = $actions;
}
public function allows(string $userId, string $resource, string $action): bool
{
$actions = $this->entries["{$userId}:{$resource}"] ?? [];
return in_array($action, $actions, true); // deny-by-default
}
}
$acl = new ACL();
// Bob owns doc-7 (read + write); Carol was shared it read-only.
$acl->grant("bob", "doc-7", ["read", "write", "delete"]);
$acl->grant("carol", "doc-7", ["read"]);
// Ask the ACL per (user, resource, action). Anyone unlisted is denied.
printf("Bob write doc-7 : %s\n", $acl->allows("bob", "doc-7", "write") ? "yes" : "no");
printf("Carol write doc-7 : %s\n", $acl->allows("carol", "doc-7", "write") ? "yes" : "no");
printf("Carol read doc-7 : %s\n", $acl->allows("carol", "doc-7", "read") ? "yes" : "no");
printf("Dave read doc-7 : %s\n", $acl->allows("dave", "doc-7", "read") ? "yes" : "no");
?>Bob write doc-7 : yes
Carol write doc-7 : no
Carol read doc-7 : yes
Dave read doc-7 : no5️⃣ Policies, Gates & Ownership Checks
A policy is a class that holds all the rules for one resource type (a PostPolicy for posts). A gate is the single yes/no question you ask it: "can this user update this post?" The killer feature is the ownership check: most apps want "editors can edit any post, and authors can edit their own." That's a role-permission check OR an owner comparison — exactly the Laravel policy pattern, shown here from scratch.
<?php
// A POLICY centralises the rules for one resource type (here: Post).
// A GATE is a single yes/no question you ask the policy.
// This is the Laravel pattern, written from scratch so you see the wiring.
class Post
{
public function __construct(public int $id, public int $authorId) {}
}
class PostPolicy
{
public function __construct(private RBAC $rbac) {}
/** Can $userId update THIS post? Role permission OR ownership. */
public function update(string $userId, Post $post): bool
{
// 1) Admins/editors with the role permission can edit any post...
if ($this->rbac->can($userId, "posts.update")) {
return true;
}
// 2) ...otherwise you may only edit a post you own (OWNERSHIP CHECK).
return (int) $userId === $post->authorId;
}
/** Only admins may delete — and never your own account loophole here. */
public function delete(string $userId): bool
{
return $this->rbac->can($userId, "posts.delete");
}
}
// Minimal RBAC so this snippet runs on its own.
class RBAC
{
private array $userPerms = [];
public function assign(string $u, array $perms): void { $this->userPerms[$u] = $perms; }
public function can(string $u, string $perm): bool
{
return in_array($perm, $this->userPerms[$u] ?? [], true);
}
}
$rbac = new RBAC();
$rbac->assign("10", ["posts.update", "posts.delete"]); // user 10 = editor
$rbac->assign("20", []); // user 20 = plain member
$policy = new PostPolicy($rbac);
$post = new Post(id: 99, authorId: 20); // owned by user 20
// The gate: ask the policy, then act. Deny-by-default if it returns false.
printf("Editor (10) update post 99 : %s\n", $policy->update("10", $post) ? "ALLOW" : "DENY");
printf("Owner (20) update post 99 : %s\n", $policy->update("20", $post) ? "ALLOW" : "DENY");
printf("Other (30) update post 99 : %s\n", $policy->update("30", $post) ? "ALLOW" : "DENY");
printf("Member (20) delete any post: %s\n", $policy->delete("20") ? "ALLOW" : "DENY");
?>Editor (10) update post 99 : ALLOW
Owner (20) update post 99 : ALLOW
Other (30) update post 99 : DENY
Member (20) delete any post: DENYNow you try. The can() below is almost finished — fill in each ___ using the 👉 hint so it allows only what a role grants, then run it and check the Output panel.
<?php
// 🎯 YOUR TURN — finish the can() check, then run it.
// can() must return true ONLY when one of the user's roles grants the permission.
class Auth
{
private array $rolePerms = ["editor" => ["posts.create", "posts.update"]];
private array $userRoles = ["bob" => ["editor"]];
public function can(string $userId, string $permission): bool
{
foreach ($this->userRoles[$userId] ?? [] as $role) {
// 1) Is $permission inside this role's permission list?
if (in_array($permission, $this->rolePerms[$role] ?? [], ___)) { // 👉 strict compare: put true
return ___; // 👉 a role granted it — return true
}
}
return ___; // 👉 deny-by-default — return false
}
}
$auth = new Auth();
echo $auth->can("bob", "posts.create") ? "create: ALLOW\n" : "create: DENY\n";
echo $auth->can("bob", "users.delete") ? "delete: ALLOW\n" : "delete: DENY\n";
// ✅ Expected output:
// create: ALLOW
// delete: DENY
?>create: ALLOW
delete: DENY___ blanks: a strict true compare, return true on a match, and false at the end. Output should be two lines.One more — this time the missing piece is the ownership check itself. Add the comparison so an author can edit a post they wrote.
<?php
// 🎯 YOUR TURN — add the OWNERSHIP check so authors can edit their own post.
// Rule: allow if the user has 'posts.update', OR if they wrote the post.
class Post { public function __construct(public int $authorId) {} }
function canUpdate(bool $hasUpdatePerm, int $userId, Post $post): bool
{
if ($hasUpdatePerm) {
return true; // editors can edit anything
}
// 👉 Otherwise allow ONLY when this user owns the post.
return $userId === ___; // 👉 compare $userId with $post->authorId
}
$post = new Post(authorId: 42);
echo canUpdate(false, 42, $post) ? "author edits own : ALLOW\n" : "author edits own : DENY\n";
echo canUpdate(false, 99, $post) ? "stranger edits : ALLOW\n" : "stranger edits : DENY\n";
echo canUpdate(true, 99, $post) ? "editor edits any : ALLOW\n" : "editor edits any : DENY\n";
// ✅ Expected output:
// author edits own : ALLOW
// stranger edits : DENY
// editor edits any : ALLOW
?>author edits own : ALLOW
stranger edits : DENY
editor edits any : ALLOW___ with $post->authorId so the owner is allowed but a stranger is not.6️⃣ Enforcing It with Middleware
All those checks are worthless unless something actually runs them on every request. Middleware is code that sits in front of your controller: each route declares the permission it needs, and the middleware blocks anyone who lacks it before the controller's logic ever executes. Return 401 when nobody is logged in (not authenticated) and 403 when they're logged in but not allowed (not authorized). This is your one enforcement point — the server, not the browser.
<?php
// Enforce authorization in ONE place: middleware that runs before the
// controller. Routes declare the permission they need; the gate blocks
// anyone who lacks it with 403 — the server is the source of truth.
class RBAC
{
private array $userPerms = [];
public function assign(string $u, array $perms): void { $this->userPerms[$u] = $perms; }
public function can(string $u, string $perm): bool
{
return in_array($perm, $this->userPerms[$u] ?? [], true);
}
}
class AuthorizeMiddleware
{
public function __construct(private RBAC $rbac) {}
/** Returns the HTTP status this request would receive. */
public function handle(array $request, string $permission): int
{
if ($request['userId'] === null) {
return 401; // not logged in -> Unauthorized
}
if (!$this->rbac->can($request['userId'], $permission)) {
return 403; // logged in but not allowed -> Forbidden
}
return 200; // OK — let the controller run
}
}
$rbac = new RBAC();
$rbac->assign("u1", ["users.manage", "posts.manage", "settings.update"]); // admin
$rbac->assign("u2", ["posts.manage"]); // editor
$mw = new AuthorizeMiddleware($rbac);
$routes = [
["path" => "GET /admin/users", "perm" => "users.manage", "userId" => "u1"],
["path" => "DELETE /users/5", "perm" => "users.manage", "userId" => "u2"],
["path" => "POST /posts", "perm" => "posts.manage", "userId" => "u2"],
["path" => "PUT /settings", "perm" => "settings.update", "userId" => null],
];
foreach ($routes as $r) {
$status = $mw->handle($r, $r["perm"]);
echo str_pad($r["path"], 22) . "-> {$status}\n";
}
?>GET /admin/users -> 200
DELETE /users/5 -> 403
POST /posts -> 200
PUT /settings -> 401Common Errors (and the fix)
- You only check permissions in the UI — hiding a delete button does nothing; anyone can still fire the request with curl or Postman. The button is UX, not security. Always enforce the same check on the server inside your controller or middleware.
- Allow-by-default — your gate blocks a few known-bad cases and lets everything else through. Any action you forget to list stays wide open. Flip it: start from
return falseand only returntruewhen a permission is explicitly granted, so forgotten cases fail closed. - Role explosion — checking
if ($user->role === 'admin')in dozens of places means every new role is a code hunt. Check the permission ($user->can('users.delete')) and let roles decide who holds it. Adding a role then touches zero controllers. - No ownership check — granting
posts.updateto a role lets a member edit everyone's posts, not just their own. For per-object access add an owner comparison ($userId === $post->authorId) in the policy alongside the role check.
Pro Tips
- 💡 Cache permissions per session. Load a user's full permission set once at login and stash it; don't hit the database on every
can()call. - 💡 Name permissions
resource.action. Patterns likeposts.createandusers.deletestay readable and let you group or wildcard them later. - 💡 Let frameworks help. Laravel's Gates/Policies and packages like
spatie/laravel-permissionimplement exactly this pattern — now that you've built it by hand, you know what they're doing.
📋 Quick Reference — RBAC vs ACL
| Aspect | RBAC | ACL |
|---|---|---|
| Granted to | Roles (then roles → users) | A specific user, per resource |
| Scope | Broad capability ("edit posts") | One object ("edit doc-7") |
| Scales by | Number of roles | Number of resources × users |
| Best for | Job-based access (admin/editor) | Sharing (Drive, folders) |
| Check call | can($user, 'posts.edit') | allows($user, $res, 'edit') |
| Default | Deny (return false) | Deny (return false) |
Frequently Asked Questions
Q: What is the difference between authentication and authorization?
Authentication answers "Who are you?" — it is the login step that proves identity with a password, token, or OAuth. Authorization answers "What are you allowed to do?" — it is the permission check that runs after login. They are separate: a fully logged-in (authenticated) user can still be denied (not authorized) from deleting another account. You always do authentication first, then authorization on every protected action.
Q: When should I use RBAC versus ACL?
Use RBAC (role-based) when permissions group naturally by job: viewers, editors, admins. You grant permissions to a role once and assign roles to people, so it scales to thousands of users without per-user fiddling. Use ACL (access control list) when access is per-object — this specific document, folder, or record shared with this specific user, like Google Drive sharing. Most real apps combine them: RBAC for broad capabilities ("editors can edit posts") plus an ownership/ACL check for the specific resource ("…but only their own post").
Q: Why should I check permissions, not roles, in my code?
If you write if (user.role === 'admin') everywhere, you have hard-coded a role name into business logic. The day you add a "super-editor" role or split admin into two, you must hunt down and change every check. Instead check the capability — if (user.can('users.delete')) — and let roles decide who has that permission. Adding or reshuffling roles then never touches your controller code.
Q: Is hiding a button in the UI enough to protect an action?
No. Hiding or disabling a button is UX polish only — anyone can still send the underlying HTTP request directly with curl, the browser dev tools, or Postman. Authorization must be enforced on the server, inside the controller or middleware, on every request. The frontend check is a convenience; the server check is the actual security boundary.
Q: What does 'deny-by-default' mean and why does it matter?
Deny-by-default means your gate starts from "no" and only returns "yes" when a permission is explicitly granted. The opposite — allow-by-default, where you only block a few known-bad cases — is dangerous because any action you forget to block stays open. With deny-by-default a forgotten permission simply fails closed (a 403), which is safe. Every can()/allows() method in this lesson returns false at the end for exactly this reason.
Mini-Challenge: A Deny-by-Default Gate
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is the same write-run-check loop you'll use on every real authorization check.
<?php
// 🎯 MINI-CHALLENGE: a deny-by-default permission gate.
// No code is filled in — work from the steps, then run it.
//
// 1. Make an array $userPerms = ["alice" => ["reports.view", "reports.export"]].
// 2. Write authorize($user, $perm) that returns 403 if the user is missing,
// 403 if the permission is NOT in their list, and 200 otherwise.
// (Start from "deny" and only return 200 when the permission is present.)
// 3. Print the status for these three checks:
// authorize("alice", "reports.view") // she has it
// authorize("alice", "reports.delete") // she does NOT have it
// authorize("mallory", "reports.view") // unknown user
//
// Tip: use in_array($perm, $userPerms[$user] ?? [], true) for the check.
//
// ✅ Expected output:
// alice reports.view -> 200
// alice reports.delete -> 403
// mallory reports.view -> 403
// your code here
?>🎉 Lesson Complete!
- ✅ Authentication proves who you are; authorization decides what you can do — separate steps, login first
- ✅ RBAC wires users → roles → permissions through pivot tables, with role inheritance so you define each permission once
- ✅ Check the permission (
can('posts.edit')), never the role name - ✅ ACLs add per-resource access for "this user, on this object" (sharing)
- ✅ A policy/gate combines a role check with an ownership check so authors edit their own posts
- ✅ Enforce everything in middleware on the server, deny-by-default — the UI is never the security boundary
- ✅ Next lesson: File Storage — abstract file handling across local disk, S3, and other cloud providers
Sign up for free to track which lessons you've completed and get learning reminders.