Skip to content

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:

class Settings(CraftEasySettings):
    ACCESS_CONTROL_ENABLED: bool = True

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:

  1. Users with scope="system" (the superuser tier).
  2. Users where is_system_user=True in 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.