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:
- Validation.
feature_registry.validate([...])raises on unknown strings, so an access group can never reference a feature that does not exist. - Dependency resolution.
resolve_dependencies([...])expands a set transitively — grantingreports.exportimplicitly requiresreports.view. - Catalog.
all_features()andcategories()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:
- Requires authentication via
require_auth(returns401if missing). - Loads the user's effective features with
AccessAccumulator.get_effective_features(user_id). - Checks that
self.required_featureis in that set. - Populates
request.state.access_rightswith 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.