Custom Authorization

While AllSafe Fast's built-in role and permission checks cover most common authorization scenarios, real-world applications often need more nuanced access control. This page covers the concepts and patterns for implementing custom authorization rules — such as ownership checks, conditional logic, and policy-based access control — using the UserPrincipal in your route handlers.

Core Concepts

Custom authorization in AllSafe Fast builds on several foundational concepts. Understanding these helps you design effective access control for your application:

User
The authenticated identity, represented by UserPrincipal. Contains the user's ID, email, roles, and permissions.
Role
A coarse-grained access label (e.g., admin, user). Stored on the User model and embedded in JWT claims.
Permission
A fine-grained capability (e.g., users:read, billing:write). Also stored on the User model and embedded in JWT claims.
Policy
A custom rule that combines roles, permissions, and business logic to make an authorization decision. You implement policies in your application code.
Resource
The object or data being protected (e.g., an invoice, a document, a user profile). Resources often have an owner or access control list.
Action
The operation being performed on the resource (e.g., view, edit, delete, export).

Custom Business Rules

The built-in auth.user() dependency handles static checks (roles and permissions) that can be evaluated from the JWT alone. For dynamic checks that depend on the request data — such as whether a user owns a specific resource — you need custom logic in your route handler.

ℹ Static vs. Dynamic Authorization

auth.user(role=..., permission=...) performs static checks — it can verify roles and permissions from the JWT without database access. Custom authorization performs dynamic checks — it may need to query the database to determine ownership, check relationships, or evaluate business rules.

Example: Invoice Update Policy

A common pattern is the ownership check: a user can update a resource if they own it, have a specific permission, or have an administrative role. Here is a complete example:

from allsafe_fast import Auth, AuthConfig, UserPrincipal, AuthorizationError
from fastapi import FastAPI, Depends, HTTPException

app = FastAPI()
auth = Auth(AuthConfig(secret_key="your-secret-key"))
app.include_router(auth.router)

# Custom policy: user can update invoice IF:
#   1. They own the invoice, OR
#   2. They have the "invoice:update" permission, OR
#   3. They have the "admin" role
async def can_update_invoice(user: UserPrincipal, invoice_id: str) -> bool:
    # Check 1: Ownership — does this user own the invoice?
    invoice = await get_invoice_from_db(invoice_id)
    if invoice is None:
        raise HTTPException(status_code=404, detail="Invoice not found")

    if str(invoice.owner_id) == str(user.id):
        return True

    # Check 2: Has the invoice:update permission
    if user.has_permission("invoice:update"):
        return True

    # Check 3: Is an admin
    if user.has_role("admin"):
        return True

    return False

@app.put("/api/invoices/{invoice_id}")
async def update_invoice(
    invoice_id: str,
    data: InvoiceUpdate,
    user: UserPrincipal = Depends(auth.user()),
):
    # Custom authorization check
    if not await can_update_invoice(user, invoice_id):
        raise AuthorizationError(
            "You do not have permission to update this invoice"
        )

    # Authorization passed — proceed with update
    return await update_invoice_in_db(invoice_id, data)

Using UserPrincipal in Route Handlers

The UserPrincipal provides everything you need for custom authorization checks. Use its properties and methods to build complex authorization logic:

from allsafe_fast import UserPrincipal, AuthorizationError

# Available on UserPrincipal:
user.id               # UUID — user's unique identifier
user.email            # str — email address
user.email_verified   # bool — email verification status
user.roles            # tuple[str, ...] — user's roles
user.permissions      # tuple[str, ...] — user's permissions
user.is_authenticated # bool — always True for valid principal

# Methods for checking roles and permissions:
user.has_role("admin")              # -> bool
user.has_permission("users:read")    # -> bool

# Example: Multi-condition authorization
async def authorize_document_access(user: UserPrincipal, doc_id: str):
    doc = await get_document(doc_id)

    # Admin can access everything
    if user.has_role("admin"):
        return doc

    # Document owner can access their own documents
    if str(doc.owner_id) == str(user.id):
        return doc

    # Users with documents:read permission can view
    if user.has_permission("documents:read"):
        return doc

    # Shared with this user
    if await is_document_shared_with(doc_id, user.id):
        return doc

    raise AuthorizationError("Access denied to this document")

Policy Pattern

For complex authorization logic, encapsulate your rules in policy classes. This keeps authorization logic organized, testable, and reusable:

from allsafe_fast import UserPrincipal, AuthorizationError

# Conceptual policy pattern — this is application code you write,
# not a built-in AllSafe Fast API
class InvoicePolicy:
    @staticmethod
    async def can_view(user: UserPrincipal, invoice_id: str) -> bool:
        invoice = await get_invoice(invoice_id)
        return (
            user.has_role("admin") or
            user.has_permission("invoices:view") or
            str(invoice.owner_id) == str(user.id)
        )

    @staticmethod
    async def can_edit(user: UserPrincipal, invoice_id: str) -> bool:
        invoice = await get_invoice(invoice_id)
        return (
            user.has_role("admin") or
            user.has_permission("invoices:edit") or
            (str(invoice.owner_id) == str(user.id) and invoice.status == "draft")
        )

    @staticmethod
    async def can_delete(user: UserPrincipal, invoice_id: str) -> bool:
        return user.has_role("admin")

# Usage in route handler
@app.get("/api/invoices/{invoice_id}")
async def view_invoice(
    invoice_id: str,
    user: UserPrincipal = Depends(auth.user()),
):
    if not await InvoicePolicy.can_view(user, invoice_id):
        raise AuthorizationError("Access denied")
    return await get_invoice(invoice_id)
❗ Conceptual vs. Actual AllSafe APIs

The InvoicePolicy class above is a conceptual example — it is application code you write, not a built-in AllSafe Fast API. AllSafe Fast provides UserPrincipal, AuthorizationService, and auth.user() as the building blocks. The policy pattern itself is a design approach you implement in your application.

Conditional Authorization

Sometimes authorization depends on the state of the resource or the user's relationship to it. Here are common patterns:

Time-Based Access

from datetime import datetime, timedelta

async def can_access_report(user: UserPrincipal, report_id: str) -> bool:
    if user.has_role("admin"):
        return True

    report = await get_report(report_id)

    # Reports are accessible for 30 days after creation
    if datetime.now() - report.created_at > timedelta(days=30):
        return user.has_permission("reports:archive:read")

    return user.has_permission("reports:read")

Resource State-Based Access

async def can_edit_article(user: UserPrincipal, article_id: str) -> bool:
    article = await get_article(article_id)

    # Admin can always edit
    if user.has_role("admin"):
        return True

    # Owner can edit only if article is in draft state
    if str(article.author_id) == str(user.id):
        return article.status == "draft"

    # Editor can edit published articles
    if user.has_permission("content:edit"):
        return article.status in ("published", "review")

    return False

Combining Built-In and Custom Authorization

The most effective approach is to combine AllSafe Fast's built-in authorization with custom checks. Use auth.user() for the initial gate, then perform custom checks inside the handler:

# Step 1: auth.user() ensures the user is authenticated and has a base permission
# Step 2: Custom check for resource-specific authorization
@app.put("/api/invoices/{invoice_id}")
async def update_invoice(
    invoice_id: str,
    data: InvoiceUpdate,
    # Built-in: must be authenticated and have invoices:edit permission
    user: UserPrincipal = Depends(auth.user(permission="invoices:edit")),
):
    # Custom: must also own the invoice or be admin
    invoice = await get_invoice(invoice_id)
    if str(invoice.owner_id) != str(user.id) and not user.has_role("admin"):
        raise AuthorizationError("You can only edit your own invoices")

    return await update_invoice_in_db(invoice_id, data)
💡 Best Practices for Custom Authorization

1. Use auth.user() for the initial gate — it's fast and handles JWT validation.

2. Keep custom checks in dedicated functions or policy classes for testability.

3. Always raise AuthorizationError (not generic HTTP exceptions) for consistent 403 responses.

4. Return 404 for missing resources before checking authorization — this prevents information leakage.