Skip to main content
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:
ScenarioExample
Record ownershipUsers can only edit their own posts
Status-based accessPublished posts can only be edited by admins
Time-based accessRecords can only be modified within 24 hours
Field-value conditionsHigh-priority tickets require manager approval
Complex logicCombination 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):
  1. () - Parentheses
  2. not - Negation
  3. ==, !=, >, >=, <, <=, in - Comparisons
  4. and - Logical AND
  5. 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

FunctionDescriptionExample
@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

FunctionDescriptionExample
@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:
VariableTypeDescriptionExample Fields
userdictCurrent userid, email, role
recorddictRecord being accessedAll record fields
contextdictRequest contextaccount_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:
  1. Create test users with different roles
  2. Create test records with different states
  3. Try operations with different users
  4. 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.

6. Consider Performance

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)

7. Use Rule Testing Tools

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

ConceptKey Takeaway
Rule SyntaxComparisons, logical operators, list membership
Built-in Functions@has_role(), @has_any_role(), @owns_record(), @is_superadmin()
Context Variablesuser, record, context available in rules
Operator Precedence() > not > comparisons > and > or
Common PatternsOwnership, role-based, status-based, time-based, workflow
Best PracticesKeep simple, use parentheses, test thoroughly, document