Skip to content

Feature Guards

A feature is a named capability the API can grant or deny. Features are strings like "reports.export", "users.invite", "payments.refund". They are defined once in a central registry, attached to access groups, and enforced at the FastAPI dependency layer with FeatureGuard, RequireAllFeatures, and RequireAnyFeature.

Features are Layer 2 of the access control stack — they sit below scope guards and above attribute-level access.

The feature registry

craft_easy.core.access.features.FeatureRegistry holds every feature the system knows about. There is a global singleton at feature_registry:

from craft_easy.core.access.features import feature_registry

feature_registry.register(
    name="reports.view",
    description="View reports dashboard",
    category="reports",
)

feature_registry.register(
    name="reports.export",
    description="Export reports to CSV/PDF",
    category="reports",
    depends_on=["reports.view"],
)

Why bother registering? Three reasons:

  1. Validation. feature_registry.validate([...]) raises on unknown strings, so an access group can never reference a feature that does not exist.
  2. Dependency resolution. resolve_dependencies([...]) expands a set transitively — granting reports.export implicitly requires reports.view.
  3. Catalog. all_features() and categories() feed admin UIs and documentation.

Register all features at app startup, before the first request is served:

# app.py
from craft_easy.core.access.features import feature_registry

def register_features():
    # Reports
    feature_registry.register("reports.view", "View reports dashboard")
    feature_registry.register("reports.export", "Export reports", depends_on=["reports.view"])
    # Users
    feature_registry.register("users.list", "List users")
    feature_registry.register("users.invite", "Invite new users")
    feature_registry.register("users.delete", "Delete users", depends_on=["users.list"])
    # Payments
    feature_registry.register("payments.view", "View payments")
    feature_registry.register("payments.refund", "Issue refunds", depends_on=["payments.view"])

register_features()

Convention: name features <resource>.<verb> — it keeps the catalog tidy and makes UI grouping trivial via category.

FeatureGuard

FeatureGuard is a FastAPI dependency that blocks the request unless the current user has one specific feature:

from fastapi import APIRouter, Depends
from craft_easy.core.access import FeatureGuard

router = APIRouter(prefix="/reports")

@router.get("/", dependencies=[Depends(FeatureGuard("reports.view"))])
async def list_reports():
    ...

@router.post("/{id}/export", dependencies=[Depends(FeatureGuard("reports.export"))])
async def export_report(id: str):
    ...

If ACCESS_CONTROL_ENABLED is off, or the caller is a system user (scope="system" or is_system_user=True), the guard is a no-op. Otherwise it:

  1. Requires authentication via require_auth (returns 401 if missing).
  2. Loads the user's effective features with AccessAccumulator.get_effective_features(user_id).
  3. Checks that self.required_feature is in that set.
  4. Populates request.state.access_rights with the resource-specific rights — attribute-level access uses this downstream.

Failure raises AuthorizationError("Missing required feature: ..."), which the error handler turns into 403 Forbidden with a structured body:

{
  "detail": {
    "error": "authorization_error",
    "message": "Missing required feature: reports.export",
    "feature": "reports.export"
  }
}

RequireAllFeatures

Some endpoints require all of several features — typical for complex operations that touch multiple resources:

from craft_easy.core.access import RequireAllFeatures

@router.post(
    "/orders/{id}/refund",
    dependencies=[Depends(RequireAllFeatures("orders.update", "payments.refund", "audit.write"))],
)
async def refund_order(id: str): ...

Missing any of the listed features is a 403 with "Missing features: [...]".

RequireAnyFeature

For endpoints where multiple roles are valid, require any of several features:

from craft_easy.core.access import RequireAnyFeature

@router.get(
    "/admin/dashboard",
    dependencies=[Depends(RequireAnyFeature("dashboard.admin", "dashboard.partner", "dashboard.support"))],
)
async def admin_dashboard(): ...

Typical use case: a dashboard accessible to several stakeholder roles with their own slices of data.

Combining guards

Scope guards and feature guards compose naturally. List them all in dependencies=[...]; FastAPI runs them top-to-bottom and the first rejection wins:

from craft_easy.core.access import ScopeGuard, FeatureGuard

@router.post(
    "/system/feature-flags",
    dependencies=[
        Depends(ScopeGuard("system")),
        Depends(FeatureGuard("system.feature_flags.write")),
    ],
)
async def toggle_feature_flag(name: str, enabled: bool): ...

The order matters only for the error message — any failing check stops execution, so put the cheapest (and most informative) check first.

Programmatic checks inside a handler

Sometimes the decision depends on the request body — "can this user assign this role?". Use the accumulator directly:

from craft_easy.core.access.accumulator import AccessAccumulator
from craft_easy.core.errors.exceptions import AuthorizationError

@router.post("/teams/{id}/members")
async def add_member(id: str, body: AddMemberBody, user = Depends(require_auth)):
    features = await AccessAccumulator.get_effective_features(user.user_id)
    required = "teams.assign.admin" if body.role == "admin" else "teams.assign.member"
    if required not in features:
        raise AuthorizationError(f"Missing required feature: {required}", feature=required)
    ...

Where features are granted

A user gets features by being assigned to an access group whose features list includes them. Multiple groups merge: the effective feature set is the union of every active group's features.

Features can also be granted per resource inside an access_rights entry — useful for scoping a feature to one collection:

AccessGroup(
    name="report-viewers",
    features=["reports.view"],
    access_rights={
        "reports": ResourceAccess(
            methods=["GET"],
            features=["reports.export"],  # scoped to the "reports" resource
        ),
    },
)

Both places are merged together by AccessAccumulator.get_effective_features.

Testing

When writing tests for a guarded endpoint:

async def test_export_requires_feature(client, create_user):
    user = await create_user(features=["reports.view"])  # no export!
    resp = await client.post(f"/reports/123/export", headers=auth(user))
    assert resp.status_code == 403
    assert resp.json()["detail"]["feature"] == "reports.export"

    user.features.append("reports.export")
    await user.save()
    resp = await client.post(f"/reports/123/export", headers=auth(user))
    assert resp.status_code == 200

See Testing for the full setup pattern.