Access Control Overview¶
Once authentication has answered "who are you?", Craft Easy's three-layer access control stack answers "what can you do?". Each layer sits at a different granularity and is evaluated in order — a request that fails any layer is rejected before the handler runs.
┌─────────────────────────────────────────────────────────┐
│ Layer 1 — Scope │
│ Coarse boundary: system > partner > tenant │
│ Enforced by ScopeGuard. Example: "only system users │
│ can touch /admin/partners". │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Layer 2 — Feature │
│ Per-endpoint capability check. │
│ Enforced by FeatureGuard / RequireAllFeatures / │
│ RequireAnyFeature. Features are strings like │
│ "reports.export", defined once in FeatureRegistry. │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Layer 3 — Attribute │
│ Per-field read/write control. │
│ Applied inside CRUD routes — blocks writes to fields │
│ the user cannot modify, strips fields the user cannot │
│ read from responses. │
└─────────────────────────────────────────────────────────┘
All three layers read from the same data source: the user's access groups. An access group is a bag of features, per-resource methods, attribute_access maps, filters, and tag scopes. A user may belong to several groups; rights are merged by AccessAccumulator.
The moving parts¶
| Concept | Module | Documented in |
|---|---|---|
| Scope hierarchy | core.access.guards.ScopeGuard |
Scope Guards |
| Feature registry | core.access.features.FeatureRegistry |
Feature Guards |
| Feature/any/all guards | core.access.guards |
Feature Guards |
| Attribute access | core.access.attributes |
Attribute-Level Access |
| Access accumulator | core.access.accumulator.AccessAccumulator |
Access Groups |
| Access group model | models.access_group.AccessGroup |
Access Groups |
| Group templates | core.access.templates |
Access Groups |
| Subcontractor grants | core.access.subcontractor |
Subcontractor Access |
The master switch¶
Access control is centrally toggled by one setting:
When ACCESS_CONTROL_ENABLED=False, every guard returns early and every authenticated caller has full access. This is intended for bootstrap scenarios — the very first time the database is seeded — and tests. Do not ship it to production.
Two callers always bypass access control, even when it is on:
- Users with
scope="system"(the superuser tier). - Users where
is_system_user=Truein the token payload (service accounts, batch workers).
Everyone else goes through all three layers.
A minimal example¶
from fastapi import APIRouter, Depends
from craft_easy.core.access import FeatureGuard, ScopeGuard
router = APIRouter()
# Any authenticated tenant user, as long as they have the feature
@router.get("/reports", dependencies=[Depends(FeatureGuard("reports.view"))])
async def list_reports(): ...
# Must be a partner or system user AND have the export feature
@router.post(
"/reports/{id}/export",
dependencies=[
Depends(ScopeGuard("partner")),
Depends(FeatureGuard("reports.export")),
],
)
async def export_report(id: str): ...
The guards compose. FastAPI runs them in declaration order and stops at the first rejection.
How rights are computed¶
User → data_access entries (active, not expired)
→ AccessGroups referenced by those entries
→ For each group:
- features (set)
- access_rights[resource]: methods, attribute_access, filters, features
- tag_scopes (for data scoping)
→ AccessAccumulator merges all groups:
- methods: union
- attribute_access: write > read > none
- filters: union (OR)
- features: union
- tag_scopes: union
The result is cached per request — the first guard or attribute lookup loads the user's rights, every subsequent check during the same request reuses them.
Read Access Groups for the merge rules in full, and Attribute-Level Access for how field-level read/write is enforced.