ABAC and Policy-Based Authorization: Ownership and Tenant Boundaries
Contrast role-only (RBAC) authorization with attribute-based, policy-driven authorization that decides access using the specific resource in question -- resource ownership and organization/tenant boundaries -- and implement real PHP policy classes that enforce both.
RBAC answers 'does this user's role allow the action posts.edit, in general?'. That question is often not fine-grained enough: an 'editor' role should be able to edit posts, but should Alice the editor be able to edit Bob's post? Should a user in Tenant A be able to view a record that belongs to Tenant B, even if their role technically grants the permission? Attribute-Based Access Control (ABAC) -- often implemented as policy-based authorization -- answers these by deciding access based on attributes of the user, the specific resource, and sometimes the environment (time, IP, request context), not just a role name. This lesson builds real PHP policy classes that check resource ownership and tenant/organization boundaries on top of the RBAC foundation from the previous lesson.
RBAC vs. ABAC: Role-Level vs. Instance-Level Decisions
RBAC's role-permission check answers a class-level question: 'can editors, as a group, perform posts.edit?'. It has no concept of WHICH post. ABAC answers an instance-level question: 'can THIS user perform posts.edit on THIS specific post?' -- and the answer can depend on attributes like who created the post, which organization it belongs to, whether it's been published yet, or even the current time. ABAC doesn't replace RBAC; it composes with it. A common, robust pattern is: RBAC narrows down which actions a role can attempt in principle, and ABAC/policy checks then decide, for a specific resource instance, whether this particular attempt is allowed.
Concretely: 'editor' role grants the posts.edit permission (RBAC layer) -- that's necessary but not sufficient. The policy layer (ABAC) then asks: is this editor the author of this post, OR an admin, OR at least in the same organization as the post's author? Only if both layers say yes does the action proceed. This hybrid is exactly what frameworks like Laravel model as Gates (RBAC-ish, coarse) plus Policies (ABAC-ish, per-resource) and what Symfony models as Voters.
The key attributes ABAC decisions commonly hinge on are: subject attributes (the user -- their id, role, organization membership), resource attributes (the specific record -- its owner_id, tenant_id, status), action (what's being attempted), and sometimes environment attributes (time of day, IP range, whether MFA was recently completed). Two attributes matter enough in almost every multi-user app that they deserve their own sections: ownership and tenant boundaries.
• RBAC: class-level -- 'can this role perform this action, in general?' • ABAC/policy: instance-level -- 'can this specific user perform this action on this specific resource?' • In practice, RBAC and ABAC compose: role permission narrows the set of attemptable actions; policy checks the specific instance. • ABAC attributes: subject (user), resource (the record), action, and sometimes environment (time, IP, MFA state).
Resource Ownership: 'Can This User Edit THIS Post?'
The single most common ABAC check in real apps is ownership: does the resource belong to the user attempting the action? A blogging platform's 'editor' role should let a user edit posts they wrote -- it should not, by itself, let them edit every other editor's posts too. That distinction cannot be expressed by role alone; it requires comparing an attribute of the resource (its author_id) against an attribute of the subject (the current user's id).
Skipping this check is one of the most common real-world vulnerability classes: Insecure Direct Object Reference (IDOR). If a route like /posts/{id}/edit only checks 'does this user have the posts.edit permission?' and never checks 'does post #{id} actually belong to this user?', then any authenticated editor can edit ANY post just by changing the {id} in the URL -- the permission check passes every time, because the role genuinely does have posts.edit; what's missing is the instance-level ownership check.
The fix is a policy method that takes both the user and the loaded resource and returns a boolean, checked in addition to (never instead of) the RBAC permission check: the RBAC layer establishes 'editors, in general, may edit posts'; the policy layer establishes 'and specifically, only posts they own (or, if their role allows it, any post)'.
• Ownership check: compare an attribute of the resource (owner_id) against an attribute of the subject (current user id). • IDOR: forgetting the ownership check lets any authorized role holder act on ANY resource by guessing/incrementing an ID. • Policy checks run in addition to RBAC permission checks, not instead of them. • Load the actual resource before checking ownership -- you cannot check an attribute of a record you haven't fetched.
Organization and Tenant Boundaries: Multi-Tenant Isolation
Many B2B apps are multi-tenant: one codebase and one database serve many separate organizations (tenants), and data must never leak across that boundary -- Tenant A's admin, however powerful within their own org, must never see or modify Tenant B's data. This is an ABAC attribute check exactly like ownership, just at a coarser grain: instead of comparing a resource's owner_id to the current user's id, you compare the resource's tenant_id (or organization_id) to the current user's tenant_id.
Tenant-boundary bugs are especially dangerous because they can be invisible in single-tenant testing -- everything works fine until two real organizations exist in the same database and one gets a permission or ID that happens to resolve into the other's data. The safest pattern is to make the tenant check unconditional and automatic rather than something every developer has to remember to add to every query: scope every resource-fetching query by the current tenant at the data-access layer (e.g. always WHERE tenant_id = ?), and treat the explicit policy check as defense-in-depth on top of that, not the only line of defense.
A record that exists but belongs to a different tenant should generally return a 404 Not Found, not a 403 Forbidden -- a 403 confirms to an attacker that the resource exists (just not for them), while a 404 gives no information about whether the ID is even valid. This is the same reasoning applied at tenant scope as at ownership scope.
• Tenant boundary: resource.tenant_id must match current_user.tenant_id -- same attribute-comparison pattern as ownership, coarser grain. • Scope data-access queries by tenant automatically (WHERE tenant_id = ?) rather than relying solely on an after-the-fetch check. • Treat explicit policy checks as defense-in-depth on top of tenant-scoped queries, not the only safeguard. • Prefer 404 over 403 for cross-tenant resource access -- don't confirm the resource's existence to a user outside its tenant.
Writing Policy Classes in PHP
A policy class groups all the authorization rules for one resource type in one place -- a PostPolicy with a method per action (view, update, delete), each taking the current user and the specific resource instance and returning a boolean. This mirrors real frameworks (Laravel's Policy classes, Symfony's Voters) but the underlying idea -- a small pure function of (user, resource) -> bool -- needs no framework at all.
A useful convention: give admins (or any role explicitly meant to bypass instance checks) an explicit early-return escape hatch inside the policy method itself, rather than skipping the policy call entirely for admins at the call site. That keeps ALL the authorization logic for a resource, including the admin override, in one auditable place instead of split between 'sometimes we check the policy, sometimes we don't'.
An AuthorizationService::authorize() helper that resolves the right policy class for a resource type, calls the method, and throws when it returns false gives you one call site ($authService->authorize($user, 'update', $post)) used consistently everywhere a post can be mutated -- controllers, background jobs, console commands -- instead of re-deriving the ownership/tenant logic in each place separately.
• Policy class: one class per resource type, one method per action, signature (User, Resource) -> bool. • Keep admin overrides INSIDE the policy method, not as a bypass at the call site -- one auditable place for all the rules. • A central AuthorizationService::authorize() resolves the right policy and throws on denial -- one call site everywhere. • Policy methods should be pure and side-effect-free so they're trivial to unit test with fake users and fake resources.
Code Examples
Every method checks the tenant boundary first and unconditionally -- even an admin from a different tenant is rejected. Within the same tenant, update() allows either the resource owner or an admin; view() additionally allows anyone to see published posts. This is the ABAC shape: decisions depend on comparing attributes of the user against attributes of the specific Post instance, not just the user's role name. Expected result: bool(true) bool(false) bool(false) if ($user->tenantId !== $post->tenantId) { return false; }
The data-access function scopes by tenant first, so a cross-tenant post ID never even resolves to a row -- that request gets 404. Only within-tenant requests reach the policy check, which returns 403 for an authenticated-but-not-authorized editor. Two layers of defense (tenant-scoped query + policy check) rather than relying on either alone. Expected result: Forbidden return ($post !== null && $post->tenantId === $tenantId) ? $post: null;
Common mistake: Fetching a resource by ID with no tenant filter ("SELECT * FROM posts WHERE id = ?") and relying ONLY on the policy check afterward means a single missed or buggy policy call anywhere in the codebase becomes a full cross-tenant data leak.
<?php
declare(strict_types=1);
final class User
{
public function __construct(
public readonly int $id,
public readonly string $role, // e.g. 'editor', 'admin'
public readonly int $tenantId, // which organization this user belongs to
) {}
}
final class Post
{
public function __construct(
public readonly int $id,
public readonly int $authorId, // resource attribute: who owns it
public readonly int $tenantId, // resource attribute: which org it belongs to
public readonly bool $published,
) {}
}
/**
* All authorization rules for the Post resource, in one place. Each method
* is (User, Post) -> bool -- an instance-level, attribute-based decision,
* in contrast to the role-only RBAC check from the previous lesson.
*/
final class PostPolicy
{
public function view(User $user, Post $post): bool
{
// Tenant boundary first: a post from another organization is not
// even a candidate for access, regardless of role or ownership.
if ($user->tenantId !== $post->tenantId) {
return false;
}
// Published posts are visible to anyone inside t
…<?php
declare(strict_types=1);
final class AuthorizationException extends RuntimeException {}
final class ResourceNotFoundException extends RuntimeException {}
final class AuthorizationService
{
public function __construct(private readonly PostPolicy $postPolicy) {}
/**
* Single call site used by controllers, background jobs, and console
* commands alike -- nobody re-derives ownership/tenant logic by hand.
*/
public function authorize(User $user, string $action, Post $post): void
{
$allowed = match ($action) {
'view' => $this->postPolicy->view($user, $post),
'update' => $this->postPolicy->update($user, $post),
'delete' => $this->postPolicy->delete($user, $post),
default => throw new InvalidArgumentException("Unknown action: {$action}"),
};
if (!$allowed) {
throw new AuthorizationException("Not authorized to {$action} post #{$post->id}");
}
}
}
// --- controller-style usage ---
function loadPostForTenant(int $postId, int $tenantId): ?Post
{
// A real implementation scopes the query itself: "WHERE id = ? AND
// tenant_id = ?" -- so a cross-ten
…RBAC and ABAC composing on one request
RBAC narrows to 'can editors edit posts, generally'; the policy layer then decides for THIS post.
Request: PATCH /posts/50 (user = Bob, tenant 100, role = editor)
|
v
+---------------------------+
| RBAC permission check | editor role grants "posts.edit"? YES
+---------------------------+
| class-level check passed
v
+---------------------------+
| Load post #50, scoped by | tenant_id must match Bob's tenant
| tenant (data-access layer) | -> found (same tenant) or 404
+---------------------------+
| resource loaded
v
+---------------------------+
| PostPolicy::update(Bob,post)| instance-level: owner? admin? same tenant?
| -- ABAC / policy check | -> Bob is NOT the owner -> false
+---------------------------+
|
v
403 ForbiddenImportant Rules
RBAC (role -> permission) and ABAC (policy on the specific resource instance) compose -- use both, not one instead of the other.
Every write/delete policy method should check the tenant/organization boundary first and unconditionally, before any other rule.
Scope resource-fetching queries by the current tenant at the data layer; treat the policy check as defense-in-depth, not the only guard.
Prefer 404 over 403 when a resource belongs to a different tenant, so existence isn't leaked across the boundary.
Keep role-override logic (e.g. 'admins bypass ownership') inside the policy method itself, not as a bypass at the call site.
Where Policy-Based Authorization Shows Up in Production
Google Cloud IAM's Conditions, AWS IAM policies (which can restrict access based on resource tags, request time, or source IP -- not just an identity's role), Laravel's Policy classes, and Symfony's Voters are all ABAC/policy-based authorization systems layered on top of an RBAC-like role/permission base. Any multi-tenant SaaS product (Slack workspaces, GitHub organizations, Salesforce orgs) enforces tenant-boundary checks exactly like the ones in this lesson, almost always duplicated at both the data-access layer (scoped queries) and an explicit policy layer, precisely because a single point of failure in a security boundary this consequential is not an acceptable risk.
if ($authorizer->can($user, 'posts.edit')) { updatePost($request->get('id')); } // no ownership/tenant check at all if ($authorizer->can($user, 'posts.edit')) { $post = loadPostForTenant($id, $user->tenantId); $authService->authorize($user, 'update', $post ?? throw new ResourceNotFoundException()); updatePost($post); }
Checking only the role-level permission and never the specific resource's ownership/tenant attributes is a textbook IDOR: any user with the posts.edit permission can edit ANY post -- including other users' and other tenants' -- simply by supplying a different id. The fix loads the actual resource and runs an instance-level policy check before mutating it.
What question does ABAC/policy-based authorization answer that role-only RBAC cannot? A. Whether the user is authenticated at all B. Whether this specific user can perform this action on this specific resource instance, based on its attributes C. Whether the user's password meets complexity requirements D. Whether the HTTP method used was GET or POST
B. Whether this specific user can perform this action on this specific resource instance, based on its attributes — RBAC answers a class-level question (can editors, in general, edit posts?); ABAC/policy checks answer an instance-level question by comparing attributes of the resource (owner, tenant) against attributes of the user.
An app checks only $authorizer->can($user, 'posts.edit') before letting a request update /posts/{id}, with no check that the post belongs to that user. What vulnerability does this create? A. SQL injection B. Insecure Direct Object Reference (IDOR) -- any user with the role-level permission can edit ANY post by changing the id C. Cross-site scripting (XSS) D. No vulnerability -- the RBAC check is sufficient
B. Insecure Direct Object Reference (IDOR) -- any user with the role-level permission can edit ANY post by changing the id — Without an instance-level ownership check, the role-level permission check passes for every post regardless of who owns it, letting any editor act on any other user's resource just by supplying a different ID.
A user requests a resource that exists but belongs to a different tenant/organization than their own. What's generally the best response? A. 403 Forbidden, so the user knows the resource exists but isn't theirs B. 200 OK with a read-only view C. 404 Not Found, so the resource's existence isn't confirmed across the tenant boundary D. 302 redirect to the resource's actual tenant
C. 404 Not Found, so the resource's existence isn't confirmed across the tenant boundary — Returning 404 rather than 403 avoids leaking information about whether a given ID is valid at all to a user outside that resource's tenant, which matters because 403 implicitly confirms existence.
Add a Comment resource with authorId and tenantId, and a CommentPolicy where delete() allows the comment's author, the POST's author (a post owner can moderate comments on their own post), or an admin -- always still respecting the tenant boundary first.
public function delete(User $user, Comment $comment, Post $parentPost): bool { if ($user->tenantId !== $comment->tenantId) return false; return $user->id === $comment->authorId || $user->id === $parentPost->authorId || $user->role === 'admin'; }
CommentPolicy::delete(User $user, Comment $comment, Post $parentPost) needs the parent post's authorId too, so it can check $user->id === $comment->authorId || $user->id === $parentPost->authorId || $isAdmin, after the tenant check.