AshGrant supports column-level read authorization through field groups. Field groups control which fields are visible based on the actor's permissions, using Ash's native field_policies system.
Field Group DSL
Define field groups with optional inheritance:
ash_grant do
resolver MyApp.PermissionResolver
scope :always, true
# Root group — no inheritance (whitelist)
field_group :public, [:name, :department, :position]
# Inherits all fields from :public, adds phone and address
field_group :sensitive, [:phone, :address], inherits: [:public]
# Inherits all fields from :sensitive (which includes :public)
field_group :confidential, [:salary, :email], inherits: [:sensitive]
endBlacklist Mode (except)
When a resource has many attributes, use :always with except to exclude specific fields instead of listing all visible ones:
ash_grant do
resolver MyApp.PermissionResolver
scope :always, true
# All attributes except salary and ssn
field_group :public, :all, except: [:salary, :ssn]
# Child group adds back the excluded fields
field_group :full, [:salary, :ssn], inherits: [:public]
end:always expands to all resource attributes at compile time. except removes fields from that list. :always without except is also valid (expands to all attributes).
Permission Strings with Field Groups
The 5th part of the permission string specifies the field group:
"employee:*:read:always:public" # See name, department, position only
"employee:*:read:always:sensitive" # See public + phone, address
"employee:*:read:always:confidential" # See all fields
"employee:*:read:always" # No field_group → all fields visibleFields not in the actor's field group are replaced with %Ash.ForbiddenField{}.
Field groups are grant-only
A field_group can only appear on an allow permission. A deny rule that
carries a field_group (e.g. "!employee:*:read:always:sensitive") is invalid —
field-group access is positive-only, so column restrictions belong in the
field_group definition (inherits/except/mask), not in a deny rule. In a
hierarchy, "all fields except X" is already expressible as a positive grant of
the lower group (here, :public). The field_group_permissions option
(:off / :warn (default) / :strict) controls whether an invalid
field-group deny is silent, logged, or raises — it never changes the
(fail-closed) outcome.
Per-Record Field Visibility
Field-group access can vary per record. When an actor's row access is
broader than their field-group access for some group, the group's fields are
visible only on the records the grant applies to, and %Ash.ForbiddenField{}
elsewhere — in the same result set:
# public fields on every readable row, sensitive fields only on owned rows
["employee:*:read:always:public", "employee:*:read:own:sensitive"]
# sensitive fields only on a specific instance
["employee:*:read:always:public", "employee:emp_123:read::sensitive"]For field group G, visibility on a row is the OR of the row predicate of every
allow grant that reaches G (directly or via inheritance): the scope expression
for RBAC grants (employee:*:read:own:G), id in [...] for instance grants
(employee:emp_123:read::G). A trivial scope (always) yields a constant
true — visible on every row, identical to action-wide behavior. A group-less
(4-part) grant with a scope grants all fields on the rows matching that scope.
Caveat: ordering by a per-record-hidden field
Ash applies sorting and filtering at the data layer, before field visibility
is resolved. Sorting a query by a field that is %Ash.ForbiddenField{} on some
rows still orders those rows by their true (hidden) value — an ordering oracle.
The value itself is never returned, but relative order can be inferred. Avoid
exposing sorts/filters on fields an actor may not see on every row.
Mode A: Manual Field Policies
Write Ash field_policies using AshGrant.field_check/1:
field_policies do
field_policy [:salary, :email] do
authorize_if AshGrant.field_check(:confidential)
end
field_policy [:phone, :address] do
authorize_if AshGrant.field_check(:sensitive)
end
field_policy :* do
authorize_if always()
end
endMode B: Auto-Generated Field Policies
Set default_field_policies: true to auto-generate field policies from field group definitions:
ash_grant do
resolver MyApp.PermissionResolver
default_policies true
default_field_policies true # Auto-generates field_policies from field_groups
scope :always, true
field_group :public, [:name, :department, :position]
field_group :sensitive, [:phone, :address], inherits: [:public]
field_group :confidential, [:salary, :email], inherits: [:sensitive]
endThis also works with blacklist mode:
field_group :public, :all, except: [:salary, :ssn]
field_group :full, [:salary, :ssn], inherits: [:public]Auto-generates equivalent field policies with a catch-all field_policy :* that allows non-grouped fields.
Field Group Inheritance
Inheritance follows a DAG (directed acyclic graph) — a child group includes all parent fields:
:public → [:name, :department, :position]
:sensitive → [:name, :department, :position, :phone, :address]
:confidential → [:name, :department, :position, :phone, :address, :salary, :email]An actor with confidential permission can see everything that sensitive and public can see, plus their own fields.
Field Masking
Instead of hiding fields entirely, you can show masked values. The
mask: option lists fields to mask at that group's level; mask_with:
is the function that produces the displayed value.
field_group :sensitive, [:phone, :address],
inherits: [:public],
mask: [:phone, :address],
mask_with: fn value, _field ->
if is_binary(value), do: String.replace(value, ~r/./, "*"), else: "***"
endmask_with/2 signature
mask_with is a 2-arity function called once per masked field, per
record:
@spec mask_with(value :: term(), field :: atom()) :: term()value— the attribute's current value on the record. May benil.field— the attribute name as an atom. Useful for one function that masks multiple fields differently.
The return value replaces the attribute in the emitted record. It does
not have to be the same type as the input — returning a string
"***" for a numeric field is fine. The consumer should treat a
masked response as display-only.
# Per-field logic via the second argument
mask_with: fn
value, :phone when is_binary(value) ->
"***-****-" <> String.slice(value, -4..-1)
value, :email when is_binary(value) ->
[_, domain] = String.split(value, "@", parts: 2)
"***@" <> domain
_value, _field ->
"***"
endMasking rules
- Not inherited. Masking attaches to the group that declared it. A child group inheriting from a masked parent does not inherit the masking. An actor at the higher-level group sees raw values.
- Allow-wins across groups. If an actor holds two field groups and any one of them grants unmasked access to a field, that field is not masked. You don't have to reason about permission order — explicit unmasked access always wins.
- 4-part permissions skip masking entirely. An actor with
"employee:*:read:always"(no field_group) sees raw values, the same way they see all fields.
Interaction with %Ash.ForbiddenField{}
Masking runs in a read-time after_action hook, before Ash's native
restrict_field_access step. If a field is outside the actor's field
groups entirely, it becomes %Ash.ForbiddenField{} during restriction
— masking does not touch those. mask_with never receives a
%Ash.ForbiddenField{} and never runs on fields the actor cannot see.
This means three levels exist:
| Actor access to field | Result |
|---|---|
| Not in any granted field group | %Ash.ForbiddenField{} |
| In a masked group only | mask_with.(value, field) — a displayable stand-in |
| In any unmasked group | raw value |
Error handling
mask_with is called inside the query's after_action pipeline. If the
function raises, the read fails — the same way any other after_action
raise would. Keep the function total:
- Handle
nilvalues explicitly if the column is nullable. - Handle unexpected types defensively — return a generic mask rather than letting a pattern-match failure crash the read.
- Avoid DB calls inside
mask_with— it runs per record and bypasses any batching.
# DO: total and fast
mask_with: fn
nil, _field -> nil
value, _field when is_binary(value) -> String.replace(value, ~r/./, "*")
_value, _field -> "***"
end
# DON'T: partial — raises on nil or non-binary values
mask_with: fn value, _ -> String.replace(value, ~r/./, "*") endExample behavior
| Actor Permission | phone | salary |
|---|---|---|
...:public | %Ash.ForbiddenField{} | %Ash.ForbiddenField{} |
...:sensitive (with masking) | "*************" | %Ash.ForbiddenField{} |
...:confidential | "010-1234-5678" | 80000 |
... (4-part, no field_group) | "010-1234-5678" | 80000 |
Masking functions and JSON
When an AshGrant.Explanation that references a masked field group is
serialized via Jason.encode!/1, the mask_with function value is
stripped — functions aren't JSON-representable. The rest of the
field group (name, fields, masked field names) round-trips cleanly.