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
SensitiveFieldannotations
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¶
-
Deny-by-default — every route must have a capability or be explicitly marked
@public. Unprotected routes are flagged at startup. -
Code is source of truth — capabilities are generated from resource registrations and
@capabilitydecorators, not stored in a database. -
Deterministic naming — CRUD capabilities follow
<resource>:<action>(e.g.,users:list). Custom capabilities follow<module>.<verb>(e.g.,bookings.cancel). -
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.
-
Most-permissive scope wins — if a user has system scope, they bypass all scope filters. Tenant scope includes all parking areas within that tenant.
-
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. |