14.3. Permission-Based Access Control
Table of Contents
Understanding Permission-Based Access Control
Role based access control (RBAC) gives users roles like admin or user. Permission based access control (PBAC) goes one level deeper and works with fine grained actions such as read:users or delete:orders. In backend development, you often combine both ideas, but here we focus on the permission side itself.
This chapter assumes you already understand users and permissions in general, and basic role based access control. We will focus on what is specific to permission based access control and how to implement it in a backend API.
What Is Permission-Based Access Control?
In permission based access control, the central concept is the permission, not the role.
A permission describes a specific allowed action on a specific type of resource, for example:
users:readusers:writeorders:readorders:cancelproducts:createproducts:delete
You then assign permissions to users, roles, or both. When the user calls your API, your backend checks whether the user has the required permission for that operation.
Key idea: In PBAC, access decisions are based on individual permissions, not only on high level roles.
Typical features of PBAC:
- Very fine grained control over what each user can do
- Easier to add or remove a single capability without changing the whole role
- Good fit for complex business rules
PBAC is often implemented on top of RBAC:
- Roles are collections of permissions
- Users are assigned roles
- Permissions are checked at runtime
You can also assign permissions directly to users for exceptions.
Permission Models and Naming
Designing Permissions
You need a consistent way to name permissions. A simple and popular pattern is:
$resource:$action
Examples:
| Resource | Action | Permission string |
|---|---|---|
users | read | users:read |
users | update | users:update |
orders | create | orders:create |
orders | cancel | orders:cancel |
products | delete | products:delete |
For more complex systems, you might add a third piece:
$resource:$action:$scope
Examples:
| Resource | Action | Scope | Permission |
|---|---|---|---|
orders | read | own | orders:read:own |
orders | read | all | orders:read:all |
users | read | profile | users:read:profile |
Rule: Choose a simple, consistent naming scheme for permissions and use it everywhere in your code and database.
CRUD-based Permissions
For APIs built around standard CRUD operations, you often define permissions like:
resource:createresource:readresource:updateresource:delete
For example:
tasks:create,tasks:read,tasks:update,tasks:deletecomments:create,comments:delete
You can also define special actions that are not CRUD:
orders:refundorders:shipreports:export
Static vs Dynamic Permissions
There are two common approaches:
- Static permission list in code
- You define a central list like:
PERMISSIONS = [
"users:read",
"users:update",
"orders:read",
"orders:create",
"orders:cancel",
]- Good for small to medium systems
- Easier to reason about and test
- Dynamic permissions in database
- Permissions are rows in a
permissionstable - Admins can create new permissions from an admin UI
- More flexible, but also more complex
For beginners, keeping a static list in code and linking it to database rows is often a good compromise.
Storing Permissions in the Database
Although exact schema design belongs in database chapters, it is useful to see how a typical PBAC database model looks at a high level.
Common Schema
A common schema for permissions is:
| Table | Purpose |
|---|---|
users | Application users |
roles | Named roles (admin, editor, viewer) |
permissions | Permission definitions (orders:read) |
user_roles | Many to many between users and roles |
role_permissions | Many to many between roles and permissions |
user_permissions | Optional, direct permissions per user |
You do not need all of these from day one, but the pattern is useful.
A simplified version:
users
id
email
...
roles
id
name -- e.g. "admin"
permissions
id
name -- e.g. "orders:read"
user_roles
user_id
role_id
role_permissions
role_id
permission_id
user_permissions
user_id
permission_idWith this, you can answer:
- Which permissions does user 42 have?
- Which users have permission
orders:cancel? - Which permissions belong to the
managerrole?
Important: Store permissions normalized, do not keep a comma separated string like "orders:read,orders:cancel" in a single column. Use join tables instead.
Checking Permissions in Your Backend
The heart of PBAC is the permission check that happens inside your backend application.
Basic Permission Check Flow
When handling a request, the backend usually:
- Authenticates the user (for example via a session or JWT)
- Loads all permissions of the user (from DB or from a token)
- Checks if the user has the required permission
- If not, returns
403 Forbidden
In plain pseudocode:
def has_permission(user, permission: str) -> bool:
return permission in user.permissionsThen in your API endpoint:
def cancel_order(order_id, user):
if not has_permission(user, "orders:cancel"):
raise ForbiddenError("Missing permission: orders:cancel")
# continue with cancel logicCombining RBAC and PBAC
If you have roles, and permissions attached to roles, you often do something like:
- Load user roles
- Load permissions for all these roles
- Add direct user permissions if you support them
- Combine into a set
Example flow:
def load_user_permissions(user_id: int) -> set[str]:
role_permissions = query_role_permissions_for_user(user_id)
direct_permissions = query_direct_permissions_for_user(user_id)
return set(role_permissions) | set(direct_permissions)Then:
def has_permission(user_id: int, permission: str) -> bool:
permissions = load_user_permissions(user_id)
return permission in permissionsYou usually add caching to avoid hitting the database for every request, but the concept is the same.
Permission Check Location
You have several places where you can enforce permissions:
| Location | Example | Pros | Cons |
|---|---|---|---|
| In route / handler | Check in each endpoint | Very explicit | Repeated code |
| As decorator | @require_permission("x:y") | Reusable, clean | Needs some framework knowledge |
| As middleware | Check certain paths | Central place, less duplication | Harder to handle very specific conditions |
| In service layer | Inside business logic functions | Keeps HTTP layer clean | Must not forget to call it everywhere |
Most backends use decorators or framework specific mechanisms to keep APIs clean.
Permissions vs Roles in Practice
Permission based access control is often introduced because RBAC alone becomes too coarse.
Where RBAC Alone Fails
Imagine you only have these roles:
adminmanageruser
Some real world needs:
- A support person can read all orders, but cannot refund them
- A finance person can refund orders, but cannot delete users
- A marketing person can export reports, but not see raw user data
If you try to model this with only roles, you might end up creating too many roles:
support_read_orders_no_refundsupport_read_orders_and_refundsupport_only_read_own_orders
This becomes unmanageable.
With PBAC, you do:
orders:read:allorders:refundusers:deletereports:export
Then give each user exactly the set of permissions they need, directly or through roles.
Combining RBAC and PBAC
A typical pattern is:
- Use roles for grouping permissions into job profiles, for example
support_agent,finance_analyst - Use permissions as the unit of access control, like
orders:refund
For example:
support_agentrole:orders:read:allusers:read:profilefinance_analystrole:orders:read:allorders:refund
Users get one or more roles, and possibly extra direct permissions such as reports:export.
Designing Permission Boundaries
Permission based access control is only as good as the way you define boundaries around actions.
Coarse vs Fine Grained Permissions
If you define:
orders:full_access
Then every user with this permission can do everything with orders, which is not very safe.
If you define:
orders:readorders:updateorders:cancelorders:refundorders:delete
You have more flexibility, but also more entities to manage.
A good practice is to:
- Start with CRUD level permissions
- Split permissions only where real business rules need it
For example:
orders:read(view)orders:update(edit data)orders:cancel(customer initiated cancel)orders:refund(money movement, more sensitive)
Rule: Split permissions when actions have different risk levels or different business owners.
Ownership vs Global Permissions
PBAC focuses on what the user can do, but often you also need to know to which resources they can do it.
For example, an user might be allowed to read only their own orders, not all orders. You can model this in two ways:
- Permissions encode scope:
orders:read:ownorders:read:all
The code then checks both permission and ownership.
- Permissions encode only the action, and separate logic enforces ownership.
- Permission:
orders:read - Business rule: if the user is not an admin, only show orders where
order.user_id == current_user.id
The second method is more common, but for very complex systems the first method can be helpful.
Implementing Permission Checks in a Web API
Here we focus on how permission based access looks from an API and backend perspective.
Example Endpoint Requirements
Imagine a Task Management API with permissions:
tasks:createtasks:readtasks:updatetasks:delete
Possible policy:
| Endpoint | Required permission |
|---|---|
POST /tasks | tasks:create |
GET /tasks | tasks:read |
GET /tasks/{task_id} | tasks:read |
PUT /tasks/{task_id} | tasks:update |
DELETE /tasks/{task_id} | tasks:delete |
At the code level, you might define a simple decorator:
def require_permission(permission: str):
def decorator(handler):
def wrapper(request, *args, **kwargs):
user = request.user
if permission not in user.permissions:
raise ForbiddenError(f"Missing permission: {permission}")
return handler(request, *args, **kwargs)
return wrapper
return decoratorThen use it:
@require_permission("tasks:create")
def create_task(request):
...
@require_permission("tasks:delete")
def delete_task(request, task_id):
...Permissions in JWT Tokens
In token based authentication, you can include permissions in the token payload so you do not need a database query on every request.
Example JWT payload:
{
"sub": "123",
"email": "alice@example.com",
"permissions": [
"tasks:create",
"tasks:read",
"tasks:update"
],
"exp": 1735690000
}At request time:
- Verify token
- Extract
permissionsclaim - Check if required permission is in this list
Tradeoff:
- Faster checks, no DB hit
- Permissions are frozen until the token expires
- If you revoke a permission, existing tokens still allow it until expiration
Often you combine:
- Short lived access tokens with embedded permissions
- When permissions change, you invalidate refresh tokens or maintain a token blacklist
Common Patterns and Examples
Permission Hierarchies
Sometimes you want one permission to imply others. For example:
orders:adminmeans:orders:readorders:createorders:updateorders:delete
You can implement permission hierarchies in code:
PERMISSION_TREE = {
"orders:admin": [
"orders:read",
"orders:create",
"orders:update",
"orders:delete",
]
}Then when you load user permissions, expand them:
def expand_permissions(perms: set[str]) -> set[str]:
expanded = set(perms)
for perm in list(perms):
children = PERMISSION_TREE.get(perm, [])
expanded.update(children)
return expanded
So if a user has orders:admin, checks for orders:read or orders:update will pass.
Feature Flags vs Permissions
Sometimes you see feature flags used in similar ways to permissions. Key differences:
| Aspect | Permissions | Feature Flags |
|---|---|---|
| Target | Security, access control | Gradual rollout, experiments |
| Who controls | Security / product owners | Product managers, developers |
| Lifetime | Long term, part of policy | Often temporary |
You might have both:
- Permission:
reports:export - Feature flag:
enable_new_report_export_ui
The permission is checked on the backend. The feature flag might be used in frontend to show or hide new UI elements.
Example: Combining User Attributes and Permissions
PBAC does not replace business rules. For example, a business rule could be:
- Only allow refunds for orders that are less than 30 days old
- Even if the user has
orders:refund
Your code could look like:
def refund_order(order_id: int, user):
if "orders:refund" not in user.permissions:
raise ForbiddenError("Missing permission orders:refund")
order = get_order(order_id)
if order.age_in_days > 30:
raise BusinessRuleError("Order too old to refund")
# process refundPermissions are about who can try a certain action. Business rules are about when the action is allowed.
Managing Permissions Over Time
In real systems, permissions evolve. You need ways to maintain and migrate them.
Changing or Removing Permissions
If you rename or remove permissions, you must:
- Update references in code
- Migrate database records
- Regenerate or expire tokens if they include old permission names
Example migration:
- Old permission:
orders:refund - New permissions:
orders:refund:partialorders:refund:full
Migration path:
- Add new permissions to
permissionstable - For each user that had
orders:refund, decide which of the new ones they need - Update role and user permission mappings
- Remove old permission from code and DB
Auditing and Logging
Permission systems are often used in sensitive contexts. You should log:
- When permissions are granted or revoked
- Who changed them
- When someone attempts an action without the required permission
Example log entries:
2026-01-01T10:00Z admin@company.com granted 'orders:refund' to user 'bob@example.com'2026-01-02T08:12Z user 'bob@example.com' denied 'users:delete' on /users/42
These logs can be crucial for debugging and for security investigations.
Summary
Permission based access control focuses on specific, named permissions like orders:read or tasks:delete. It gives you fine grained control over what users can do and is often built on top of roles that group those permissions.
Key points to remember:
- Use a consistent naming scheme for permissions, such as
resource:actionorresource:action:scope. - Store permissions in a normalized way, linking users, roles, and permissions via join tables.
- Always check permissions in your backend, usually via decorators, middleware, or service layer checks.
- Permissions handle who is allowed to attempt an action. Business rules still validate when the action is valid.
- Plan for evolution of permissions, including migrations and auditing changes.
With these patterns, you can design backends that are both flexible and secure, and that adapt as your application's requirements grow more complex.
Views: 8
KAHIBARO