Capabilities¶
A capability is a named permission that the API can grant or deny. Capabilities are strings like "users:read", "bookings:create", "payments.refund". They are registered once in a central registry, attached to roles, and enforced at the FastAPI dependency layer with requires().
Unlike the old feature-based system, capabilities are auto-generated from resources. When you register a resource, five CRUD capabilities are created automatically. Custom capabilities can be added with the @capability decorator.
CapabilityRegistry¶
craft_easy.core.access.capabilities.CapabilityRegistry holds every capability the system knows about. A global singleton is accessible via get_capability_registry():
from craft_easy.core.access.capabilities import get_capability_registry
registry = get_capability_registry()
# List all registered capabilities
all_caps = registry.all()
# Look up a specific capability
cap = registry.get("users:read")
print(cap.name, cap.description, cap.action)
# Check if a capability exists
if registry.has("bookings:create"):
...
# Get all capabilities for a resource
user_caps = registry.for_resource("users")
# Get all capabilities for a module
booking_caps = registry.for_module("bookings")
Auto-generation from resources¶
When you register a resource with Resource(User, path="/users"), the framework calls register_crud() which creates five capabilities automatically:
| Capability | Action | Description |
|---|---|---|
users:list |
list | List users |
users:read |
read | Read users |
users:create |
create | Create users |
users:update |
update | Update users |
users:delete |
delete | Delete users |
The naming convention is <resource>:<action>. This happens at startup inside Resource.build():
from craft_easy.core.access.capabilities import get_capability_registry
registry = get_capability_registry()
crud_caps = registry.register_crud("users", description_prefix="")
# Returns 5 Capability instances: users:list, users:read, users:create, users:update, users:delete
Registration is idempotent — calling register_crud("users") twice for the same resource name is safe.
Route binding¶
After capabilities are registered, the framework binds each HTTP route to its capability:
registry.bind_route("GET", "/users", "users:list")
registry.bind_route("GET", "/users/{item_id}", "users:read")
registry.bind_route("POST", "/users", "users:create")
registry.bind_route("PATCH", "/users/{item_id}", "users:update")
registry.bind_route("DELETE", "/users/{item_id}", "users:delete")
You can look up which capability protects a given route:
The Capability dataclass¶
from dataclasses import dataclass, field
from typing import Optional, Literal
CapabilityAction = Literal["list", "read", "create", "update", "delete", "custom"]
@dataclass(frozen=True)
class Capability:
name: str # Unique identifier, e.g. "users:read"
description: str # Human-readable description
resource: Optional[str] = None # Resource name (for CRUD capabilities)
action: CapabilityAction = "custom" # One of the six action types
module: Optional[str] = None # Module name (for custom capabilities)
routes: tuple[str, ...] = field(default=()) # Bound HTTP routes
@capability decorator¶
For custom endpoints that don't map to standard CRUD, use the @capability decorator to register a capability and attach it to the endpoint:
from fastapi import APIRouter
from craft_easy.core.access.capabilities import capability
router = APIRouter()
@router.post("/bookings/{id}/cancel")
@capability("bookings.cancel", "Cancel a booking", module="bookings")
async def cancel_booking(id: str):
...
The decorator:
- Registers the capability in
CapabilityRegistryat import time - Sets
func.__capability__on the endpoint function - At startup,
wire_capability_endpoints()auto-injects arequires()dependency for any@capability-decorated endpoint that lacks one
The naming convention for custom capabilities is <module>.<verb> (e.g., bookings.cancel, payments.refund). The module is inferred from the name if not supplied explicitly.
Registration is idempotent on re-import — if the same name with identical parameters is registered twice, it reuses the existing entry. If parameters diverge, it raises CapabilityAlreadyRegistered.
@public decorator¶
Some endpoints should be accessible without any capability check. Mark them with @public:
from craft_easy.core.access.capabilities import public
@router.get("/health")
@public
async def health():
return {"status": "ok"}
The @public decorator sets func.__public__ = True. The startup validation (validate_access_configuration) treats public endpoints as intentionally unprotected.
Use @public only for infrastructure endpoints (/health, /ready, /docs, /auth/token, etc.). For anonymous access with capability checks (e.g., public-facing read endpoints), use the public user mechanism instead.
Debug endpoint¶
In development, you can inspect all registered capabilities:
This returns a list of all capabilities with their names, descriptions, actions, modules, and bound routes.
Deny-by-default¶
At startup, validate_access_configuration() traverses every route in the application. Any route that lacks both a capability dependency and a @public marker is flagged:
- In strict mode (
strict=True): raisesAccessConfigurationErrorand prevents the application from starting - In non-strict mode (default): logs a warning for each unprotected route
Infrastructure endpoints (/health, /ready, /docs, /redoc, /openapi.json, /metrics) are automatically skipped.
from craft_easy.core.access.startup import validate_access_configuration
# Called during application startup
validate_access_configuration(app, strict=True)
This ensures that no endpoint is accidentally left unprotected — every route must explicitly declare its capability requirement or be marked as public.