Scope Guards¶
A scope is the coarsest access-control boundary in Craft Easy. Every token carries a scope claim, every user belongs to exactly one scope tier, and the scope guard enforces that a request is coming from the right tier before any feature or attribute check runs.
Scopes are Layer 1 of the access control stack. They exist to model the three-tier ownership structure that every Craft Easy deployment shares:
system ───── the platform operator
│
└── partner ───── a reseller, integrator, or parent organization
│
└── tenant ───── the end customer
The hierarchy¶
| Scope | Who | What they see |
|---|---|---|
system |
The team running the platform. | Everything — no tenant filter. |
partner |
A reseller or umbrella organization. | All tenants under their partner_id. |
tenant |
A single end customer. | Only their own tenant's data. |
Scopes are strictly ordered: system > partner > tenant. A user with a higher scope passes any guard requiring a lower scope — a system user can call a tenant-only endpoint, but never the other way around.
ScopeGuard¶
craft_easy.core.access.guards.ScopeGuard is a FastAPI dependency that enforces the minimum required scope for an endpoint:
from fastapi import APIRouter, Depends
from craft_easy.core.access import ScopeGuard
router = APIRouter()
# Only system users
@router.get("/admin/partners", dependencies=[Depends(ScopeGuard("system"))])
async def list_partners(): ...
# Partners and system users
@router.get("/partner/overview", dependencies=[Depends(ScopeGuard("partner"))])
async def partner_overview(): ...
# Any authenticated user
@router.get("/me", dependencies=[Depends(ScopeGuard("tenant"))])
async def me(): ...
Under the hood, ScopeGuard looks up token.scope on the authenticated user, compares it against its internal ranking — {"tenant": 0, "partner": 1, "system": 2} — and raises AuthorizationError when the user's level is lower than required:
{
"detail": {
"error": "authorization_error",
"message": "Insufficient scope. Required: 'partner', current: 'tenant'"
}
}
The dependency returns the TokenPayload on success, so you can combine it with your own type-safe access to user info:
@router.get("/partner/overview")
async def partner_overview(user: TokenPayload = Depends(ScopeGuard("partner"))):
return {"partner_id": user.partner_id}
Passing an invalid scope string to the constructor raises ValueError immediately at import time — typos are caught at startup rather than at request time.
Where scope is set¶
A user's scope is decided when their token is issued. The authentication routes read it from the user document and the partner/tenant relationships:
# From routes/auth.py:_resolve_user_scope
scope_info = {
"scope": "tenant",
"partner_id": str(user.partner_id) if user.partner_id else None,
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
}
if user.system_user:
scope_info["scope"] = "system"
elif user.tenant_id is None and user.partner_id:
scope_info["scope"] = "partner"
These fields are baked into the JWT and read back by every guard — there is no database roundtrip during a request to determine scope.
Tenant isolation¶
The tenant scope is the most common. Every tenant-scoped resource filters tenant_id automatically — a tenant user literally cannot see other tenants, regardless of what they put in where. This is enforced in core.crud.filtering and documented in Tenant Isolation.
A partner user sees every tenant under their partner_id. A system user sees everything.
Combining scope with feature guards¶
Scope is coarse; features are fine. Real endpoints usually need both:
@router.post(
"/system/tenants",
dependencies=[
Depends(ScopeGuard("system")), # must be a platform operator
Depends(FeatureGuard("tenants.create")), # must have the capability
],
)
async def create_tenant(body: CreateTenantBody): ...
The scope guard prevents a tenant user from even approaching the endpoint; the feature guard lets you carve up responsibilities within the system tier (e.g. billing admins vs. ops engineers).
System users bypass everything below¶
If your scope is system, or your token has is_system_user=True, the feature and attribute layers are skipped entirely. This is exactly what you want for:
- Bootstrap scripts seeding the initial dataset.
- Cross-tenant batch jobs running on a service account.
- Incident-response tooling that must be able to touch any tenant immediately.
Grant system scope sparingly, audit every login, and prefer purpose-built service accounts over personal system accounts.
A working example¶
from craft_easy.core.access import FeatureGuard, ScopeGuard
from craft_easy.core.auth.tokens import TokenPayload
from fastapi import APIRouter, Depends
router = APIRouter()
@router.get(
"/partner/tenants",
dependencies=[
Depends(ScopeGuard("partner")),
Depends(FeatureGuard("tenants.list")),
],
)
async def list_tenants(user: TokenPayload = Depends(require_auth)):
# ScopeGuard already ensured user.scope ∈ {"partner", "system"}
# The CRUD layer will scope the query to user.partner_id automatically
return await Tenant.find({"partner_id": user.partner_id}).to_list()
A tenant user hitting this endpoint sees:
HTTP/1.1 403 Forbidden
{
"detail": {
"error": "authorization_error",
"message": "Insufficient scope. Required: 'partner', current: 'tenant'"
}
}
A partner user with the tenants.list feature sees the list of their tenants. A system user sees the same list regardless of partner_id.