Access Resolver¶
The Access Resolver is the central component that answers the question: "What can this user do, and where?" It resolves a user's effective capabilities by expanding all active role assignments, following inheritance chains, and accounting for scope and temporal validity.
EffectiveAccess¶
The result of access resolution is an EffectiveAccess object:
from craft_easy.core.access.dependencies import EffectiveAccess
@dataclass
class EffectiveAccess:
user_id: str
capabilities: frozenset[str] # All flat capabilities
scoped: tuple[ScopedCapability, ...] # Capabilities with scope context
roles: frozenset[str] # Role names the user holds
The capabilities field is a flat set of all capability names the user holds across all assignments. The scoped field preserves the scope context for each capability, which is used by Scope Filters to restrict query results.
ScopedCapability¶
from craft_easy.core.access.resolver import ScopedCapability
@dataclass(frozen=True)
class ScopedCapability:
capability: str # e.g. "bookings:list"
scope_type: str # e.g. "tenant"
scope_id: Optional[str] # e.g. "664f1a2b3c4d5e6f7a8b9c0d"
AccessResolver¶
The resolver is accessed via a singleton:
resolve()¶
The main method computes effective access for a user:
The resolution process:
- Fetch assignments — query all
RoleAssignmententries for the user - Filter by time — keep only assignments where
valid_from <= now < valid_until - Expand roles — for each assignment, fetch the
Roleand recursively expandinherits_from - Collect capabilities — build both the flat
capabilitiesset and thescopedlist - Return —
EffectiveAccesswith all resolved data
You can also resolve at a specific point in time:
from datetime import datetime, timezone
access = await resolver.resolve(
user_id="664f1a2b3c4d5e6f7a8b9c0d",
at=datetime(2026, 6, 1, tzinfo=timezone.utc),
)
expand_role()¶
Recursively expands a role to its full capability set, following inheritance:
caps = await resolver.expand_role("admin")
# Returns set of all capability names, including inherited
- Maximum depth: 5 levels
- Circular inheritance detected and stopped via visited set
- Result is the union of the role's own capabilities plus all inherited capabilities
requires() dependency¶
The requires() function is a FastAPI dependency that enforces capability checks on routes:
from fastapi import APIRouter, Depends
from craft_easy.core.access.dependencies import requires, EffectiveAccess
router = APIRouter()
@router.post("/bookings/{id}/cancel")
async def cancel_booking(
id: str,
access: EffectiveAccess = Depends(requires("bookings.cancel")),
):
# access.capabilities, access.scoped, access.roles available
...
How requires() works¶
-
Startup validation — when the dependency is created, it checks that all named capabilities exist in the
CapabilityRegistry. Unknown names raiseUnknownCapabilityimmediately, preventing the application from starting with invalid configuration. -
Request-time check — for each incoming request:
- Calls
_get_current_access()to resolve the user'sEffectiveAccess - Checks if the user holds the required capabilities
- Raises
HTTPException(403)if any are missing - Returns
EffectiveAccesson success
- Calls
Multiple capabilities¶
# Require ALL listed capabilities
@router.post("/settle", dependencies=[Depends(requires("payments:read", "settlements:create"))])
async def settle(): ...
# Require ANY of the listed capabilities
@router.get("/dashboard", dependencies=[Depends(requires("dashboard.admin", "dashboard.partner", mode="any"))])
async def dashboard(): ...
Error response¶
When a required capability is missing, the API returns:
HTTP/1.1 403 Forbidden
{
"detail": {
"error": "missing_capabilities",
"missing": ["bookings.cancel"]
}
}
Principal resolution¶
The _get_current_access() dependency resolves effective access differently depending on the principal type:
| Principal | Resolution |
|---|---|
User (AUTH_ENABLED=True) |
AccessResolver.resolve(user_id) — expands roles from RoleAssignment entries |
| M2M client | Capabilities extracted directly from access_claims.scope in the access token |
| Public user | Resolved from the public role (cached in memory, invalidated on role update) |
Dev mode (AUTH_ENABLED=False) |
Returns superuser with all registered capabilities |
The result is cached on request.state.access so it is computed only once per request.
Public user support¶
When PUBLIC_USER_ENABLED=True, unauthenticated requests are resolved against a special public user with a public role:
from craft_easy.core.access.public_user import seed_public_user
# Called at startup — creates public user, role, and assignment
await seed_public_user(settings)
The public role is cached in memory by the resolver. When the public role's capabilities are updated, call invalidate_public_cache():
Public tenant context is determined by the X-Tenant-Slug header (configurable via PUBLIC_USER_TENANT_HEADER).
Startup validation¶
Two functions validate the access configuration at startup:
wire_capability_endpoints()¶
Traverses all routes and auto-injects requires() dependencies for endpoints decorated with @capability that lack an explicit enforcement dependency:
validate_access_configuration()¶
Verifies that every route is protected. Must be called after wire_capability_endpoints() and all routers are included:
from craft_easy.core.access.startup import validate_access_configuration
validate_access_configuration(app, strict=True)
In strict mode, an unprotected route (no capability dependency and no @public marker) raises AccessConfigurationError. In non-strict mode, it logs warnings.
Request flow¶
HTTP Request
│
▼
Auth Middleware → AuthContext (request.state.auth)
│
▼
requires("capability") dependency
│
▼
_get_current_access()
│
├─ User → AccessResolver.resolve() → Role + RoleAssignment + expand_role()
├─ M2M → capabilities from access_claims.scope
└─ Public → cached public role capabilities
│
▼
EffectiveAccess (capabilities + scoped + roles)
│
▼
Enforce: has required capabilities? → 403 if missing
│
▼
Resource.build() applies automatically:
├─ Scope filter (build_scope_filter)
└─ Sensitive field masking (filter_sensitive)
│
▼
Response