Skip to content

Attribute-Level Access

Attribute access is the finest layer of Craft Easy's access control. Where feature guards say "this user cannot call this endpoint", attribute access says "this user can see the row but only certain fields" — or "this user can PATCH the row but cannot touch price or status".

The implementation lives in craft_easy.core.access.attributes and is enforced automatically inside the CRUD routes. No handler code is required.

The level hierarchy

Every field can be in one of three states per user:

Level Read Write
write yes yes
read yes no
none no no

The levels are strictly ordered: write > read > none. When a user belongs to multiple access groups, the highest level from any group wins (AccessAccumulator uses write > read > none).

Fields not mentioned in the access map default to write — an unconfigured field is fully accessible. This makes it safe to add attribute restrictions incrementally: you only spell out the fields you want to restrict.

Configuration in an access group

from craft_easy.models.access_group import AccessGroup, ResourceAccess

group = AccessGroup(
    name="support-tier-1",
    features=["tickets.list", "tickets.update"],
    access_rights={
        "tickets": ResourceAccess(
            methods=["GET", "PATCH"],
            attribute_access={
                "status":          "write",  # can update
                "assignee_id":     "write",
                "internal_notes":  "read",   # can see, cannot edit
                "sla_credit":      "none",   # cannot see at all
            },
        ),
        "customers": ResourceAccess(
            methods=["GET"],
            attribute_access={
                "ssn":             "none",
                "annual_revenue":  "none",
            },
        ),
    },
)

A user in this group can list and update tickets, change their status and assignee, read internal notes but not edit them, and never see the SLA credit field. They can list customers but never see the sensitive financial fields.

ResourceAccess.full_attribute_access=True turns off all per-field rules for that resource — the user has full read/write on every field.

How it is enforced

Read path

The CRUD routes call get_effective_attribute_access(request, resource_name) once per request (cached in request.state) and pass the result to filter_response_fields(data, attr_access):

# simplified — this happens automatically in the CRUD response pipeline
attr_access = await get_effective_attribute_access(request, "tickets")
filtered    = filter_response_fields(raw_data, attr_access)
return filtered

filter_response_fields strips every field whose level is none. It works on single dicts, lists of dicts, and Pydantic models (via model_dump()).

Write path

On every POST/PATCH/PUT, the CRUD routes call check_write_access(data, attr_access, is_patch=...) before the DB is touched:

def check_write_access(data, attr_access, is_patch=False):
    blocked = [
        {"field": name, "access": attr_access.get(name, "write")}
        for name in data
        if attr_access.get(name, "write") != "write"
    ]
    if blocked:
        raise HTTPException(403, detail={
            "message": "You do not have write access to some fields",
            "blocked_fields": blocked,
        })

Blocked fields come back in the response body so a client can show them inline:

{
  "detail": {
    "message": "You do not have write access to some fields",
    "blocked_fields": [
      {"field": "sla_credit", "access": "none"},
      {"field": "internal_notes", "access": "read"}
    ]
  }
}

For PATCH, fields that the caller did not include are ignored entirely — the caller is only responsible for the diff they submitted.

Merging across groups

If a user is in two groups with different levels for the same field, AccessAccumulator picks the highest:

# Group A: {"status": "read"}
# Group B: {"status": "write"}
# Effective: {"status": "write"}

The merge rule is write beats read beats none. This makes "additive" group design work the way people expect: adding a user to a more-privileged group can only loosen restrictions, never tighten them.

Bypass rules

Attribute access is not evaluated for:

  • Calls where ACCESS_CONTROL_ENABLED=False.
  • Calls where AUTH_ENABLED=False.
  • Calls where token.scope == "system".
  • Calls where the user has no resource-specific access_rights entry (treated as full access — the feature guard is what gates them).
  • Resources whose merged rights have full_attribute_access=True.

This means: a user can have access to a resource (via features) without having attribute restrictions on it. Start permissive and tighten where needed.

Per-request caching

get_effective_attribute_access stores the resolved map on request.state.{resource_name} so subsequent reads during the same request are free. This matters because a typical list-then-read flow can otherwise hit the DB twice for the same user:

cache_key = f"_attr_access_{resource_name}"
cached = getattr(request.state, cache_key, None)
if cached is not None:
    return cached

Across requests, cache invalidation is not needed — the data lives in MongoDB and is fetched fresh per request. If you need to invalidate aggressively (for example after a group change), terminating the user's session is enough.

Computing effective access by hand

For admin UIs that need to preview what a user can do without issuing a request:

from craft_easy.core.access.accumulator import AccessAccumulator

rights = await AccessAccumulator.get_access_rights(user_id, "tickets")
# {
#   "methods": ["GET", "PATCH"],
#   "attribute_access": {"status": "write", "internal_notes": "read", "sla_credit": "none"},
#   "full_attribute_access": False,
#   "filters": {...},
#   "full_filter_access": False,
#   "features": ["tickets.update"],
# }

Use this to build the "access matrix" page described in the admin spec.

A common pattern: hide PII

ResourceAccess(
    methods=["GET", "PATCH"],
    attribute_access={
        "ssn":              "none",
        "date_of_birth":    "none",
        "home_address":     "read",   # visible but immutable
    },
)

Hiding PII via attribute access is usually safer than filtering it in handlers — the rule applies to every CRUD endpoint, the admin schema, export jobs, and anything else that goes through the standard response pipeline.