Skip to content

Access Control

Craft Easy provides capability-based access control that is enforced automatically on every route. Define a model, register it as a resource, and the framework generates capabilities, wires enforcement dependencies, and validates at startup that no endpoint is left unprotected.

Core concepts

Access control is built from four building blocks:

Concept What it does Documented in
Capability A named permission like "bookings:create". Auto-generated from resources, or declared with @capability. Capabilities
Role A named collection of capabilities with inheritance. E.g., "operator" inherits from "viewer". Roles
Role Assignment Connects a user to a role within a typed scope (tenant, partner, parking area) with optional expiry. Role Assignments
Sensitive Field Per-field read/write annotation on models. Strips fields from responses or blocks writes automatically. Sensitive Fields

Two supporting systems complete the picture:

System What it does Documented in
Scope Filter Generates MongoDB query filters so users only see documents within their assigned scope. Scope Filters
Access Resolver Resolves a user's effective capabilities at request time by expanding roles, filtering by time, and merging scopes. Access Resolver

How it fits together

User authenticates
Role Assignments (who has which role, where, until when)
Roles (named capability sets with inheritance)
Access Resolver → EffectiveAccess (flat capabilities + scoped capabilities)
  ├─► requires() — blocks request if capability missing (403)
  ├─► Scope Filter — restricts queries to user's scope
  └─► Sensitive Fields — strips/blocks fields per capability
Response

Quick start

Here is the minimum setup to protect a resource with access control:

1. Define a model

from craft_easy.core.crud.base import TenantScopedDocument
from beanie import Indexed

class Product(TenantScopedDocument):
    name: Indexed(str)
    price: float

    class Settings:
        name = "products"

2. Register as a resource

from craft_easy.core.crud.resource import Resource

app.register_resource(Resource(Product, path="/products"))

This automatically:

  • Registers 5 capabilities: products:list, products:read, products:create, products:update, products:delete
  • Wires requires() dependencies on each CRUD endpoint
  • Applies scope filtering based on the model's ScopeConfig
  • Applies sensitive field filtering for any SensitiveField annotations

3. Create a role

from craft_easy.models.role import Role

role = Role(
    name="product-manager",
    display_name="Product Manager",
    capabilities=["products:list", "products:read", "products:create", "products:update"],
)
await role.insert()

4. Assign the role to a user

from craft_easy.models.role_assignment import RoleAssignment

assignment = RoleAssignment(
    user_id=user.id,
    role_name="product-manager",
    scope_type="tenant",
    scope_id=tenant.id,
)
await assignment.insert()

Now this user can list, read, create, and update products within their tenant — but cannot delete them, because products:delete is not in the role.

5. Add a custom endpoint

from craft_easy.core.access.capabilities import capability

@router.post("/products/{id}/archive")
@capability("products.archive", "Archive a product", module="products")
async def archive_product(id: str):
    ...

Key principles

  1. Deny-by-default — every route must have a capability or be explicitly marked @public. Unprotected routes are flagged at startup.

  2. Code is source of truth — capabilities are generated from resource registrations and @capability decorators, not stored in a database.

  3. Deterministic naming — CRUD capabilities follow <resource>:<action> (e.g., users:list). Custom capabilities follow <module>.<verb> (e.g., bookings.cancel).

  4. Scope and time are independent — role assignments filter by both scope (where) and temporal validity (when). A user can hold different roles in different scopes with different expiry dates.

  5. Most-permissive scope wins — if a user has system scope, they bypass all scope filters. Tenant scope includes all parking areas within that tenant.

  6. Adding roles only expands access — a user's effective capabilities are the union of all their active role assignments. There is no "deny" mechanism.

Bypass rules

Two principal types always bypass access control:

  • Dev mode (AUTH_ENABLED=False) — all capabilities are granted. For development only.
  • System scope — users with a system-scoped admin role have unrestricted access.

Settings

Setting Default Description
AUTH_ENABLED True Master switch. When False, all routes are accessible without authentication.
PUBLIC_USER_ENABLED False Enable anonymous access via public user with restricted role.
PUBLIC_USER_ROLE_NAME "public" Name of the role assigned to the public user.
PUBLIC_USER_TENANT_HEADER "X-Tenant-Slug" Header used to determine tenant context for public requests.