Skip to main content

    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

    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.

    Logged in, but still not allowed
    <?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
    }
    ?>
    Output
    Authenticated: yes
    Authorized: NO — 403 Forbidden
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Notice 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 (roles, permissions, pivots)
    -- 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 = denied
    This is the SQL that backs RBAC. Run it in your MySQL/PostgreSQL client to create the tables. The final SELECT 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).

    RBAC engine with role inheritance
    <?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";
    ?>
    Output
    === 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).
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    ACL: who can do what on a specific resource
    <?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");
    ?>
    Output
    Bob   write doc-7 : yes
    Carol write doc-7 : no
    Carol read  doc-7 : yes
    Dave  read  doc-7 : no
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    5️⃣ 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.

    Policy with a role check OR an ownership check
    <?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");
    ?>
    Output
    Editor (10) update post 99 : ALLOW
    Owner  (20) update post 99 : ALLOW
    Other  (30) update post 99 : DENY
    Member (20) delete any post: DENY
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Now 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.

    🎯 Your turn: finish the can() check
    <?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
    ?>
    Output
    create: ALLOW
    delete: DENY
    Fill the three ___ 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.

    🎯 Your turn: add the ownership check
    <?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
    ?>
    Output
    author edits own : ALLOW
    stranger edits   : DENY
    editor edits any : ALLOW
    Replace the ___ 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.

    Authorization middleware gating routes
    <?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";
    }
    ?>
    Output
    GET /admin/users      -> 200
    DELETE /users/5       -> 403
    POST /posts           -> 200
    PUT /settings         -> 401
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Common 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 false and only return true when 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.update to 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 like posts.create and users.delete stay readable and let you group or wildcard them later.
    • 💡 Let frameworks help. Laravel's Gates/Policies and packages like spatie/laravel-permission implement exactly this pattern — now that you've built it by hand, you know what they're doing.

    📋 Quick Reference — RBAC vs ACL

    AspectRBACACL
    Granted toRoles (then roles → users)A specific user, per resource
    ScopeBroad capability ("edit posts")One object ("edit doc-7")
    Scales byNumber of rolesNumber of resources × users
    Best forJob-based access (admin/editor)Sharing (Drive, folders)
    Check callcan($user, 'posts.edit')allows($user, $res, 'edit')
    DefaultDeny (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.

    🎯 Mini-Challenge: write an authorize() gate
    <?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
    ?>
    Return 401/403 when the user is missing or lacks the permission, 200 only when it's explicitly present. Start from "deny" and earn the 200.

    🎉 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service