Authorization frameworks and patterns
As authorization rules grow complex, consider dedicated frameworks: Cerbos (open-source policy engine), Auth0 (managed service), AWS IAM (if on AWS). They centralize rules, make them testable, and scale to thousands of permissions. Without them, authorization logic scattered across your code becomes unmaintainable.
5 min read
The problem: authorization sprawl
As apps grow, authorization logic spreads everywhere:
# In view 1: can delete a post?
if user.role == "admin" or (user.role == "editor" and user.id == post.owner_id):
delete_post(post_id)
# In view 2: can comment on a post?
if user.role != "banned" and post.allow_comments:
add_comment(post_id, user.id, text)
# In view 3: can see post?
if user.role in ["admin", "editor", "viewer"] or post.is_public:
return post
# In view 4: can see analytics?
if user.role == "admin" or (user.organization_id == post.organization_id and user.role in ["admin", "editor"]):
return analyticsProblems:
- Rules are duplicated across views.
- Hard to test: authorization logic is mixed with business logic.
- Hard to change: update a rule and find 5 places to change it.
- Easy to miss edge cases: "oh, I forgot to check the organization_id in this query."
Authorization frameworks solve this
An authorization framework centralizes rules in one place:
# authorization.yaml
rules:
delete_post:
- user.role == "admin"
- user.role == "editor" AND user.id == resource.owner_id
comment_on_post:
- user.role != "banned" AND resource.allow_comments
view_post:
- user.role IN ["admin", "editor", "viewer"]
- resource.is_public
view_analytics:
- user.role == "admin"
- user.organization_id == resource.organization_id AND user.role == "editor"Then your code calls the framework:
@app.delete('/posts/<post_id>')
def delete_post(post_id):
post = db.get_post(post_id)
# Centralized check
if not authz.allow(request.user, 'delete_post', post):
abort(403)
db.delete_post(post_id)
return 200Benefits:
- Rules are in one place.
- Easy to test: separate from business logic.
- Easy to change: update once, affects all views.
- Auditable: view exactly what permissions are allowed.
Cerbos: open-source policy engine
Cerbos is a dedicated authorization service. You define policies in Cerbos, and your app asks Cerbos for decisions.
Install and run:
docker run -d -p 3592:3592 ghcr.io/cerbos/cerbos:latestDefine a policy:
# posts.yaml
apiVersion: api.cerbos.dev/v1
metadata:
resource: "post"
rules:
- actions: ['delete']
effect: EFFECT_ALLOW
principals:
- admin
condition:
match:
expr: "true" # admins can delete any post
- actions: ['delete']
effect: EFFECT_ALLOW
principals:
- editor
condition:
match:
expr: "principal.id == resource.owner_id" # editors can delete own postsQuery from your app:
import grpc
from cerbos import cerbos_service_pb2
# Create a request
request = cerbos_service_pb2.CheckResourcesRequest()
# Add principal (user)
principal = request.principal
principal.id = "alice"
principal.roles.append("editor")
# Add resource (post)
resource = request.resources.add()
resource.id = "post-123"
resource.kind = "post"
resource.attributes["owner_id"].string_value = "alice"
resource.attributes["title"].string_value = "My Blog"
# Add action (delete)
request.actions.append("delete")
# Query Cerbos
channel = grpc.aio.aio.secure_channel('localhost:3592', grpc.ssl_channel_credentials())
client = cerbos_service_pb2_grpc.CerbosServiceStub(channel)
response = client.CheckResources(request)
# Check result
if response.results[0].actions["delete"] == cerbos_service_pb2.EFFECT_ALLOW:
delete_post(post_id)
else:
abort(403)AWS IAM: if you're on AWS
If your app runs on AWS, AWS IAM (Identity and Access Management) can handle authorization.
Define an IAM policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "posts:Delete",
"Resource": "arn:aws:posts:us-east-1::posts/*",
"Condition": {
"StringEquals": {
"aws:userid": "${post:owner_id}"
}
}
}
]
}Attach to a role/user. When your Lambda or EC2 instance runs code, the AWS SDK checks the policy.
Benefits:
- Centralized: one place for all AWS permissions.
- Hierarchical: organizations, teams, roles.
- Auditable: CloudTrail logs all permission checks.
- Integrates with AWS services (S3, DynamoDB, etc.).
Downside: only for AWS services, not for application-specific rules.
Auth0: managed authorization
Auth0 (now Okta) is a managed identity and access management (IAM) platform. It handles authentication and authorization.
Define roles and permissions in Auth0's dashboard:
Role: Editor
Permissions:
- read:posts
- create:posts
- edit:own_posts
- delete:own_posts
Role: Viewer
Permissions:
- read:posts
Your app queries Auth0:
from auth0 import Auth0
auth0 = Auth0('your-domain.auth0.com', 'your-api-token')
# Get user's roles
user_roles = auth0.get_user_roles('alice') # ['editor']
# Get role permissions
permissions = auth0.get_role_permissions('editor') # ['read:posts', 'create:posts', ...]
# Check permission
if 'delete:posts' in permissions:
delete_post(post_id)Benefits:
- Managed: Auth0 handles scaling, backups, updates.
- Full-featured: roles, permissions, user groups, organization hierarchies.
- Standards-based: OpenID Connect, OAuth2.
Downside: vendor lock-in, cost per user.
Rule patterns: building intuition
Attribute-based rules
Check user and resource attributes:
allow_edit_document:
- user.role == "admin"
- user.department == resource.department AND user.role == "editor"
- user.id == resource.owner_id AND resource.allow_owner_editTemporal rules
Rules that depend on time:
allow_access_during_office_hours:
- user.role == "admin" # admins always
- user.role == "employee" AND now().hour >= 9 AND now().hour <= 17Relationship-based rules
Rules that depend on relationships:
can_view_team_resources:
- user.team_id == resource.team_id AND user.role in ["admin", "member"]Hierarchy-based rules
Rules that respect organizational hierarchy:
can_manage_employees:
- user.role == "admin"
- user.department_head == true AND resource.department_id == user.department_idTesting authorization rules
Authorization rules must be tested:
def test_editor_can_delete_own_post():
user = User(id="alice", role="editor")
post = Post(id="123", owner_id="alice")
assert authz.allow(user, 'delete_post', post)
def test_editor_cannot_delete_others_post():
user = User(id="alice", role="editor")
post = Post(id="123", owner_id="bob")
assert not authz.allow(user, 'delete_post', post)
def test_admin_can_delete_any_post():
user = User(id="admin", role="admin")
post = Post(id="123", owner_id="bob")
assert authz.allow(user, 'delete_post', post)With a centralized framework, tests are simple and cover all cases.
Common mistakes
Trusting the frontend to enforce authorization
// WRONG — frontend hides buttons for non-admins
if (user.role === "admin") {
showDeleteButton();
}Always enforce on the backend.
Mixing authorization with business logic
# WRONG — authorization is buried in the business logic
def delete_post(post_id):
post = db.get_post(post_id)
if request.user.role != "admin" and request.user.id != post.owner_id:
abort(403)
# ... business logicCORRECT — separate concerns
def delete_post(post_id):
if not authz.allow(request.user, 'delete_post', post):
abort(403)
# ... business logicNot testing authorization rules
Authorization rules are code and must be tested.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What is the main advantage of centralizing authorization rules in a framework?
2. What does Cerbos do?
3. When should you use AWS IAM for authorization?