Scope Filters¶
Scope filters restrict query results so that users only see documents within their assigned scope. When a user with scope_type="tenant" lists bookings, they see only bookings belonging to their tenant — automatically.
How it works¶
The scope filter system builds MongoDB query conditions from the user's role assignments. At request time, build_scope_filter() inspects the user's scoped capabilities and generates a $or filter with one clause per scope assignment.
Resolution algorithm¶
- Collect all
ScopedCapabilityentries matching the requested capability - If any has
scope_type="system"→ return{}(no restriction — full access) - If none match → return
{"_id": {"$in": []}}(deny all) - Otherwise, build a
$orfilter with one condition per unique scope
Scope hierarchy¶
Scopes follow an implicit hierarchy where broader scopes include narrower ones:
| Scope type | Includes |
|---|---|
system |
Everything — no filter applied |
partner |
All tenants under the partner, and their parking areas |
tenant |
The tenant and all its parking areas |
parking_area |
Only the specific parking area |
org_node |
The node and all descendants (via materialized path) |
The most-permissive scope wins. If a user has both a tenant-scoped and a parking-area-scoped assignment, the tenant scope takes precedence for that tenant.
Usage in routes¶
The scope_filter() dependency returns a MongoDB query dict that you merge into your query:
from fastapi import APIRouter, Depends
from craft_easy.core.access.dependencies import requires
from craft_easy.core.access.scoping import scope_filter
from myapp.models import Booking
router = APIRouter()
@router.get("/bookings")
async def list_bookings(
access=Depends(requires("bookings:list")),
sf: dict = Depends(scope_filter(Booking, "bookings:list")),
):
# sf contains the MongoDB filter, e.g. {"tenant_id": {"$in": [...]}}
bookings = await Booking.find(sf).to_list()
return bookings
Note
For standard CRUD resources, scope filtering is automatically applied by Resource.build(). You only need to use scope_filter() manually for custom endpoints.
Auto-wiring in Resource¶
When you register a resource with Resource(Booking, path="/bookings"), the framework automatically applies scope filters to all CRUD endpoints. The _apply_scope_filter() method inside Resource merges the scope filter with any other query conditions:
# This happens automatically inside Resource.build():
# GET /bookings → scope_filter(Booking, "bookings:list")
# GET /bookings/{id} → scope_filter(Booking, "bookings:read")
Model configuration¶
For scope filtering to work, your model must declare which fields map to which scope types via ScopeConfig:
from craft_easy.core.crud.base import BaseDocument
class Booking(BaseDocument):
tenant_id: PydanticObjectId
parking_area_id: PydanticObjectId
partner_id: Optional[PydanticObjectId] = None
class ScopeConfig:
tenant = "tenant_id"
parking_area = "parking_area_id"
partner = "partner_id"
The ScopeConfig class tells the filter builder which document field to query for each scope type. If a scope type is not configured, it cannot be used for filtering on that model.
Scope condition builders¶
Each scope type generates a different MongoDB condition:
Tenant scope¶
Filters by tenant_id and includes all parking areas belonging to that tenant:
# For a user with scope_type="tenant", scope_id=tenant_123:
# Generated filter: {"tenant_id": ObjectId("tenant_123")}
Partner scope¶
Filters by all tenants under the partner, resolved via hierarchy lookup:
# For a user with scope_type="partner", scope_id=partner_456:
# Generated filter: {"tenant_id": {"$in": [ObjectId("t1"), ObjectId("t2"), ...]}}
Parking area scope¶
Directly matches the specific parking area:
# For a user with scope_type="parking_area", scope_id=area_789:
# Generated filter: {"parking_area_id": ObjectId("area_789")}
Org node scope¶
Resolves the full subtree using materialized paths and filters by all nodes in the subtree:
# For a user with scope_type="org_node", scope_id=node_abc:
# Generated filter: {"org_node_id": {"$in": [ObjectId("node_abc"), ObjectId("child1"), ...]}}
Hierarchy cache¶
Scope hierarchy lookups (e.g., "which parking areas belong to this tenant?") are cached per request via ScopeHierarchyCache. This avoids repeated database queries when multiple scope filters are resolved in the same request:
from craft_easy.core.access.scope_cache import ScopeHierarchyCache
cache = ScopeHierarchyCache()
areas = await cache.parking_areas_for_tenant("tenant_123")
tenants = await cache.tenant_ids_for_partner("partner_456")
subtree = await cache.org_subtree("node_abc")
Registering resolvers¶
At application startup, register the lookup function that resolves parking areas for a tenant:
from craft_easy.core.access.scope_cache import ScopeHierarchyCache
async def lookup_parking_areas(tenant_id: str) -> list[str]:
areas = await ParkingArea.find(
ParkingArea.tenant_id == PydanticObjectId(tenant_id),
ParkingArea.is_deleted != True,
).to_list()
return [str(a.id) for a in areas if a.id]
ScopeHierarchyCache.set_parking_area_resolver(lookup_parking_areas)
Verifying scope access¶
For create operations, use is_in_user_scope() to validate that the new document's scope fields fall within the user's access:
from craft_easy.core.access.scoping import is_in_user_scope
allowed = await is_in_user_scope(
access=effective_access,
scope_type="tenant",
value=str(new_booking.tenant_id),
capability="bookings:create",
request=request,
)
if not allowed:
raise HTTPException(403, "Cannot create booking in this tenant")