Skip to content

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:

cap = registry.for_route("GET", "/users")
# Returns Capability(name="users:list", ...)

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:

  1. Registers the capability in CapabilityRegistry at import time
  2. Sets func.__capability__ on the endpoint function
  3. At startup, wire_capability_endpoints() auto-injects a requires() 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:

curl http://localhost:5001/access/capabilities | python -m json.tool

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): raises AccessConfigurationError and 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.