Skip to content

Sensitive Fields

Sensitive fields provide field-level access control via model annotations. Mark individual fields as sensitive, and the framework automatically strips them from responses or blocks writes based on the caller's capabilities.

SensitiveField annotation

Use SensitiveField() instead of Field() to annotate fields that require capability checks:

from craft_easy.core.access.sensitive import SensitiveField
from craft_easy.core.crud.base import BaseDocument
from decimal import Decimal
from typing import Optional

class Payment(BaseDocument):
    reference: str
    amount: Decimal = SensitiveField(..., read="payments:read_amount")
    bank_account: Optional[str] = SensitiveField(
        None,
        read="payments:read_bank",
        write="payments:write_bank",
    )
    status: str

Parameters

Parameter Type Description
default any Default value (same as Pydantic Field())
read str (optional) Capability required to see this field in responses
write str (optional) Capability required to include this field in create/update
**kwargs any All other Pydantic Field() parameters

If only read is set, anyone can write the field but only users with the capability can read it. If only write is set, anyone can read but only users with the capability can write. Set both for full control.

Read filtering

When a response is returned, filter_sensitive() strips fields the caller cannot read:

from craft_easy.core.access.sensitive_filter import filter_sensitive

# User WITHOUT "payments:read_amount" capability
filtered = filter_sensitive(Payment, payment_data, access)
# Result: {"reference": "PAY-001", "status": "completed"}
# "amount" and "bank_account" are stripped

# User WITH "payments:read_amount" but not "payments:read_bank"
filtered = filter_sensitive(Payment, payment_data, access)
# Result: {"reference": "PAY-001", "amount": "150.00", "status": "completed"}
# "bank_account" is stripped

For list endpoints, filter_sensitive_list() applies the same filtering to every item:

from craft_easy.core.access.sensitive_filter import filter_sensitive_list

filtered_items = filter_sensitive_list(Payment, payments, access)

Note

For standard CRUD resources, sensitive field filtering is automatically applied by Resource.build(). You only need to call these functions manually for custom endpoints.

Write checking

When a create or update request includes a sensitive field, check_sensitive_write() verifies the caller has the required write capability:

from craft_easy.core.access.sensitive_filter import check_sensitive_write

# Raises SensitiveFieldWriteDenied if caller lacks "payments:write_bank"
check_sensitive_write(Payment, request_data, access)

If the caller tries to write a field they lack the capability for, the API returns a 403 error:

{
  "detail": {
    "error": "sensitive_field_write_denied",
    "field": "bank_account",
    "required": "payments:write_bank"
  }
}

Wildcard support

Both annotations and user capabilities support wildcards with *:

# Annotation: any "payments:*" capability satisfies this
amount: Decimal = SensitiveField(..., read="payments:*")

# Or: user holds "payments:*" which matches any "payments:..." requirement

The has_capability() function handles wildcard matching in both directions:

  • Annotation wildcard: read="payments:*" matches if the user holds any capability starting with payments:
  • User wildcard: User holds payments:* and field requires payments:read_amount → match

Auto-registration

When a resource is registered with Resource.build(), all capabilities referenced by SensitiveField annotations are automatically registered in the CapabilityRegistry:

from craft_easy.core.access.sensitive import register_sensitive_capabilities

# Called automatically by Resource.build()
register_sensitive_capabilities(Payment)
# Registers: "payments:read_amount", "payments:read_bank", "payments:write_bank"

This is idempotent — registering the same capabilities multiple times is safe.

Introspection

You can inspect which fields on a model are sensitive:

from craft_easy.core.access.sensitive import get_sensitive_fields

fields = get_sensitive_fields(Payment)
# Returns:
# {
#     "amount": {"read": "payments:read_amount"},
#     "bank_account": {"read": "payments:read_bank", "write": "payments:write_bank"},
# }

Integration with CRUD

The standard CRUD operations in Resource integrate sensitive fields automatically:

Operation Sensitive field behavior
List (GET /) filter_sensitive_list() strips unreadable fields from every item
Read (GET /{id}) filter_sensitive() strips unreadable fields
Create (POST /) check_sensitive_write() blocks writes to protected fields
Update (PATCH /{id}) check_sensitive_write() blocks writes to protected fields

Testing sensitive fields

Verify that fields are correctly filtered by making requests with different capability sets:

# User with payments:read_amount — sees amount
curl http://localhost:5001/payments/664f1a2b3c4d5e6f7a8b9c0d \
  -H "Authorization: Bearer $OPERATOR_TOKEN"
# Response includes "amount": "150.00"

# User without payments:read_amount — amount stripped
curl http://localhost:5001/payments/664f1a2b3c4d5e6f7a8b9c0d \
  -H "Authorization: Bearer $VIEWER_TOKEN"
# Response does NOT include "amount"

# User without payments:write_bank — write blocked
curl -X PATCH http://localhost:5001/payments/664f1a2b3c4d5e6f7a8b9c0d \
  -H "Authorization: Bearer $VIEWER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"bank_account": "SE1234567890"}'
# 403: {"detail": {"error": "sensitive_field_write_denied", "field": "bank_account", ...}}