Skip to content

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:

from craft_easy.core.access.resolver import get_access_resolver

resolver = get_access_resolver()

resolve()

The main method computes effective access for a user:

access = await resolver.resolve(user_id="664f1a2b3c4d5e6f7a8b9c0d")

The resolution process:

  1. Fetch assignments — query all RoleAssignment entries for the user
  2. Filter by time — keep only assignments where valid_from <= now < valid_until
  3. Expand roles — for each assignment, fetch the Role and recursively expand inherits_from
  4. Collect capabilities — build both the flat capabilities set and the scoped list
  5. ReturnEffectiveAccess with 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

  1. Startup validation — when the dependency is created, it checks that all named capabilities exist in the CapabilityRegistry. Unknown names raise UnknownCapability immediately, preventing the application from starting with invalid configuration.

  2. Request-time check — for each incoming request:

    • Calls _get_current_access() to resolve the user's EffectiveAccess
    • Checks if the user holds the required capabilities
    • Raises HTTPException(403) if any are missing
    • Returns EffectiveAccess on success

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():

resolver.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:

from craft_easy.core.access.startup import wire_capability_endpoints

wire_capability_endpoints(app)

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