This guide explains how to write and use permission rules in SnackBase’s rule engine for fine-grained access control.
Overview
SnackBase includes a powerful rule engine with a custom DSL (Domain Specific Language) for expressing complex permission logic.
What Are Rules?
Rules are boolean expressions that determine whether a permission grant allows an operation.
Permission = CRUD flags (create, read, update, delete)
+ Rules (additional conditions)
Example: "Users can update posts if they created them"
When to Use Rules
Use rules when you need:
| Scenario | Example |
|---|
| Record ownership | Users can only edit their own posts |
| Status-based access | Published posts can only be edited by admins |
| Time-based access | Records can only be modified within 24 hours |
| Field-value conditions | High-priority tickets require manager approval |
| Complex logic | Combination of role, ownership, and status |
Rule Syntax
Basic Expressions
# Field comparisons
user.id == "user_abc123"
user.email == "[email protected]"
record.status == "published"
# Numeric comparisons
record.views > 1000
record.priority >= 3
record.quantity <= 50
# List membership
status in ["draft", "published", "archived"]
record.category in ["news", "updates"]
# Negation
not record.status == "archived"
record.status != "archived"
Logical Operators
# AND (both conditions must be true)
@has_role("editor") and @owns_record()
status == "draft" and priority < 5
# OR (at least one condition must be true)
@has_role("admin") or @owns_record()
status in ["draft", "pending"] or @has_role("moderator")
# NOT (inverts condition)
not status == "archived"
not @has_role("banned")
# Grouping with parentheses
(@has_role("admin") or @owns_record()) and not status == "locked"
Operator Precedence
Operators are evaluated in this order (highest to lowest):
() - Parentheses
not - Negation
==, !=, >, >=, <, <=, in - Comparisons
and - Logical AND
or - Logical OR
# Example: How this is evaluated
@has_role("admin") or @owns_record() and status == "draft"
# Step 1: AND first (higher precedence)
(@has_role("admin") or (@owns_record() and status == "draft"))
# Use parentheses to change order
(@has_role("admin") or @owns_record()) and status == "draft"
Built-in Functions
Role Functions
| Function | Description | Example |
|---|
@has_role(role) | User has specific role | @has_role("admin") |
@has_any_role([roles]) | User has any of these roles | @has_any_role(["admin", "moderator"]) |
@has_all_roles([roles]) | User has all of these roles | @has_all_roles(["editor", "publisher"]) |
Ownership Functions
| Function | Description | Example |
|---|
@owns_record() | Current user created the record | @owns_record() |
@is_superadmin() | User is a superadmin | @is_superadmin() |
Function Examples
# Single role check
@has_role("admin")
# Multiple roles (OR logic)
@has_any_role(["admin", "moderator", "editor"])
# All roles required (AND logic)
@has_all_roles(["verified", "subscriber"])
# Ownership check
@owns_record()
# Combined with other conditions
@owns_record() and record.status in ["draft", "pending"]
@has_role("admin") or (@owns_record() and not record.locked)
Evaluation Context
Rules have access to these variables:
| Variable | Type | Description | Example Fields |
|---|
user | dict | Current user | id, email, role |
record | dict | Record being accessed | All record fields |
context | dict | Request context | account_id, request_id |
Accessing User Data
# User ID
user.id == "user_abc123"
# User email
user.email == "[email protected]"
user.email.endswith("@domain.com")
# User fields (if you add custom fields)
user.department == "engineering"
user.location == "remote"
Accessing Record Data
# String fields
record.title == "Important Post"
record.category in ["news", "updates"]
# Numeric fields
record.views > 1000
record.priority >= 3
# Boolean fields
record.published == true
record.archived == false
# Date fields (comparing timestamps)
record.created_at > "2025-01-01T00:00:00Z"
# Nested JSON fields
record.metadata.severity == "high"
record.settings.notifications == true
Accessing Context
# Account ID
context.account_id == "AB1001"
# Request ID (for logging/tracking)
context.request_id != null
Rule Examples
Example 1: Edit Own Drafts
Users can edit posts they created, but only if the post is in draft status:
@owns_record() and record.status == "draft"
Example 2: Admin or Owner
Admins can edit any post. Non-admins can only edit their own:
@has_role("admin") or @owns_record()
Example 3: Status-Based Access
Published posts are locked - only admins can edit them:
# For update permission
not record.status == "published" or @has_role("admin")
# Equivalent to:
@has_role("admin") or record.status in ["draft", "pending", "archived"]
Example 4: Time-Based Access
Records can only be edited within 24 hours of creation:
# Note: This requires a custom function or field
record.hours_since_creation < 24 or @has_role("admin")
Example 5: Field-Value Conditions
High-priority tickets require manager approval:
# For update permission
record.priority < 5 or (@has_role("manager") and record.approved)
Example 6: Complex Multi-Condition
Combined conditions for document access:
# User can edit if:
# - They're an admin, OR
# - They own it AND it's not locked, OR
# - They're an editor AND it's in draft status
@has_role("admin")
or (@owns_record() and not record.locked)
or (@has_role("editor") and record.status == "draft")
Advanced Patterns
Pattern 1: Role-Based Workflow
Implement approval workflow based on roles:
# Create permission
@has_role("author") or @has_role("editor")
# Update permission (draft stage)
@has_any_role(["author", "editor"]) and record.status == "draft"
# Update permission (review stage)
@has_role("editor") and record.status == "review"
# Update permission (published stage)
@has_role("admin") and record.status == "published"
# Delete permission
@has_role("admin")
Pattern 2: Escalation Rules
Higher priority items require higher privileges:
# Priority 1-2: Anyone
record.priority <= 2
# Priority 3-4: Editors
record.priority <= 4 and @has_any_role(["editor", "admin"])
# Priority 5+: Admins only
record.priority >= 5 and @has_role("admin")
Pattern 3: Department-Based Access
Users can only access records from their department:
# Assuming user.department field exists
user.department == record.department or @has_role("admin")
Pattern 4: Temporary Access
Grant access based on date/time:
# Assuming record.expires_at field exists
record.expires_at == null or record.expires_at > now()
# Or for events
record.event_start <= now() and record.event_end >= now()
Pattern 5: Field-Specific Rules
Different rules for different fields:
# For "title" field update
@has_role("editor") or @owns_record()
# For "status" field update
@has_role("admin") or (@has_role("editor") and @owns_record())
# For "published_at" field update
@has_role("admin")
Testing Rules
Unit Testing Rules
Test rule logic in isolation:
# tests/unit/test_rules.py
import pytest
from src.snackbase.core.rules.evaluator import RuleEvaluator
@pytest.mark.asyncio
async def test_owns_record_rule():
"""Test @owns_record() function."""
evaluator = RuleEvaluator()
# Should pass: user owns record
result = await evaluator.evaluate(
rule="@owns_record()",
user={"id": "user_123"},
record={"created_by": "user_123"}
)
assert result is True
# Should fail: user doesn't own record
result = await evaluator.evaluate(
rule="@owns_record()",
user={"id": "user_123"},
record={"created_by": "user_456"}
)
assert result is False
Integration Testing
Test rules in context of API requests:
# tests/integration/test_permission_rules.py
import pytest
@pytest.mark.asyncio
async def test_cannot_update_published_post_as_author(client, author_token):
"""Test authors cannot update published posts."""
# Create a published post
create_response = await client.post(
"/api/v1/posts/",
json={"title": "Test", "status": "published"},
headers={"Authorization": f"Bearer {author_token}"}
)
post_id = create_response.json()["id"]
# Try to update as author (should fail)
update_response = await client.put(
f"/api/v1/posts/{post_id}",
json={"title": "Updated"},
headers={"Authorization": f"Bearer {author_token}"}
)
assert update_response.status_code == 403
Manual Testing with Swagger UI
Use Swagger UI at http://localhost:8000/docs to test rules interactively:
- Create test users with different roles
- Create test records with different states
- Try operations with different users
- Verify rules work as expected
Best Practices
1. Keep Rules Simple
Complex rules are hard to understand and maintain:
# BAD: Hard to understand
@has_role("a") or (@has_role("b") and record.x == 1) or (@has_role("c") and not @has_role("d") and record.y in [1,2])
# GOOD: Clear and logical
@has_role("admin")
or (@has_role("editor") and record.status == "draft")
or (@has_role("reviewer") and record.status == "review")
2. Use Parentheses for Clarity
Always use parentheses when combining operators:
# AMBIGUOUS: What does this mean?
@has_role("admin") or @owns_record() and record.status == "draft"
# CLEAR: Intent is obvious
(@has_role("admin") or @owns_record()) and record.status == "draft"
3. Test All Branches
Ensure all logical paths are tested:
# Rule with multiple conditions
@has_role("admin") or (@owns_record() and record.status == "draft")
# Test cases:
# 1. Admin + any status → Should pass
# 2. Owner + draft → Should pass
# 3. Owner + published → Should fail
# 4. Non-owner + any status → Should fail
4. Document Complex Rules
Add comments explaining business logic:
# Business rule: Published content is locked
# - Admins can always edit (emergency fixes)
# - Original authors can edit if not locked
# - Editors can edit draft content only
@has_role("admin")
or (@owns_record() and not record.locked)
or (@has_role("editor") and record.status == "draft")
5. Use Wildcards Carefully
Wildcard permissions with rules can be powerful but dangerous:
# CAUTION: This applies to ALL collections
{
"collection": "*",
"delete": @has_role("admin") # Only admins can delete ANYTHING
}
# SAFER: Be explicit
{
"collection": "posts",
"delete": @has_role("admin")
}
{
"collection": "comments",
"delete": @has_role("admin") or @owns_record()
}
Using wildcard permissions ("collection": "*") with rules applies the rule to ALL collections. Use with caution and test thoroughly.
Rules are evaluated on every request. Keep them efficient:
# SLOW: Multiple nested function calls
@has_any_role(["admin", "editor", "moderator", "publisher", "reviewer"])
and not @has_any_role(["banned", "suspended", "pending"])
and record.status in ["draft", "pending", "review", "published"]
# FASTER: Simplified logic
@has_role("admin") or (@owns_record() and not record.archived)
Test rules before deploying:
# Test rule evaluation
uv run python -m snackbase test-rule \
--rule "@owns_record() or @has_role('admin')" \
--user-id "user_123" \
--record '{"created_by": "user_123", "status": "draft"}'
Summary
| Concept | Key Takeaway |
|---|
| Rule Syntax | Comparisons, logical operators, list membership |
| Built-in Functions | @has_role(), @has_any_role(), @owns_record(), @is_superadmin() |
| Context Variables | user, record, context available in rules |
| Operator Precedence | () > not > comparisons > and > or |
| Common Patterns | Ownership, role-based, status-based, time-based, workflow |
| Best Practices | Keep simple, use parentheses, test thoroughly, document |