Skip to content

Field-Level Security

Field-level security restricts who can read and write an individual field on a model, independently of the model-wide CRUD permissions in Model Access. Where ModelAccess answers “can this group read/write records of this model at all?”, field-level security answers “within a record they can access, can they see and edit this one field?”

Use it for sensitive columns that only a subset of a model’s users should see — a salary on an employee, a cost price on a product, an internal risk score on a lead — without splitting the record into a separate model.

Add groups=[...] to any field. The value is a list of group identifiers (the bare identifier, not the dotted module.name form):

class Employee(Model):
_verbose_name = "Employee"
name = Char(required=True)
department = ManyToOne("Department", on_delete="SET NULL", related_name="employees")
# Only HR managers can see or edit the salary.
salary = Monetary(groups=["hr_manager"])

A field with no groups (the default) is unrestricted — the check is skipped entirely, so there is no cost for the common case.

Listing more than one group is OR logic — the user needs to be in any one of them:

cost_price = Monetary(groups=["purchase_manager", "invoicing_admin"])

Members of the built-in admin group (core_admin) bypass all field-level checks.

The same groups restriction gates both reading and writing — there is no separate read-only-vs-writable distinction. A user outside the group cannot see the value and cannot change it. Enforcement happens at three layers:

LayerBehavior for a user outside the group
Read (serialization)The field’s value is omitted from every response — record fetch, list query, search, and CSV/XLSX export. The column and stored data are untouched; only the output is filtered.
Aggregate / group-byA measure or grouping dimension over the field is rejected with AccessError; a footer/per-group total or stat card over it is dropped from the response. See Aggregates and grouping.
Write (create / update)Any attempt to set the field is rejected with AccessError (HTTP 403) before the write reaches the database. This covers create, update, bulk create/update, import, and nested child-record writes through a parent’s relation field.
View (UI composition)The field’s widget is removed from the composed view arch, so the input never renders for that user. A field’s groups also merges into any widget-level groups as an intersection (the user must satisfy both).

Because the value is stripped at output and rejected at input, the restriction is fully reversible: grant the group later and the existing data is immediately visible again — nothing was ever deleted.

Every client-facing read path funnels through one serializer, so a single check covers them all — you do not annotate each endpoint. A restricted field simply never appears in the JSON (or export row) for a user without access. Computed/onchange responses are serialized the same way, so a restricted calculated field does not leak either.

Summarising a field is reading it. A SUM over a restricted Monetary hands back the amount, and grouping by a restricted field returns its distinct values as the group labels — the same disclosure the output filter exists to prevent, in summary form. So the restriction applies to the aggregate paths that back pivots, charts, list totals and stat cards, in two different ways:

  • A measure or a grouping dimension is refused with AccessError. These are the request — a pivot asked to sum a restricted column, or to group by one, cannot be answered as a smaller question without misleading the caller.
  • A footer total, a per-group total, or a stat card is dropped from the response. These decorate a column that is already hidden for this user, so the natural degradation is a missing cell — not a failed screen for everyone who opens the list.

Both halves are automatic for the standard query endpoint. A pivot or chart declared in a view should still carry a matching groups: on the widget, so a user’s client never asks for a measure the server will refuse. (For a field widget this is automatic — the field’s groups merge into the widget during composition — but a chart names its measure in a property, so there is nothing for composition to merge.)

If you write your own client-reachable endpoint that aggregates or groups by a caller-supplied field name, apply the same check:

from fullfinity.engine.utils import assert_can_read_fields
# Raises AccessError if the caller may not read one of these fields.
assert_can_read_fields(MyModel, [measure_field, group_field])
total = await MyModel.filter(...).sum(measure_field)

Restricting a field does not restrict filtering on it. A caller who can guess the field name can still narrow a query with it and read the resulting row count, which for a high-cardinality value (an exact salary, a date of birth) is a slow disclosure channel. Where that matters, keep the sensitive column on a model whose rows the audience cannot read at all (a record rule or a separate model), rather than relying on field gating alone.

Field-level write access is enforced at the API boundary (the create/update endpoints), not inside the ORM’s create()/update(). This is deliberate: trusted server-side code — a calculated field’s setter, an automation, a migration — must be able to write a restricted field regardless of the acting user. Only writes that originate from a client request are checked.

The practical consequence: if you write your own client-reachable entry point that accepts raw field values (a custom route or RPC method that forwards a payload into create/update), call the check yourself:

from fullfinity.engine.utils import assert_can_write_fields
# Raises AccessError if `vals` touches a field the current user may not write.
assert_can_write_fields(MyModel, vals)
await MyModel.create(**vals)

Server code that legitimately needs to bypass field access (and record/model access) should use elevate() explicitly rather than relying on the boundary gap.

groups=[...] is the right tool for data that a subset of users may see (a salary, a cost price). It is the wrong tool for a genuine secret — a password hash, a reset/invite token, a stored credential — for two reasons: members of core_admin bypass it, and its purpose is “who may see this”, not “this must never be returned”.

For secrets, mark the field write_only=True:

class User(Model):
email = Char(required=True)
hashed_password = Char(write_only=True) # never serialized, to anyone
reset_token = Char(max_length=128, write_only=True)

A write_only field is omitted from every serialized response for every caller — with no admin bypass. Unlike groups=, it does not gate writes and does not check the user’s groups; it is purely an output exclusion. The value is still stored, still writable, and still readable by in-process server code through normal attribute access — so authentication keeps working (bcrypt.verify(user.hashed_password, …), User.filter(reset_token=token)) while the value can never leave the server in a query, search, form-load, or export.

Reach for it whenever a field lives on a model that a broad audience can read. User, for example, is readable by every internal user (it powers assignment dropdowns and name-search), so its password hash and reset token must be write_onlygroups= alone would still hand them to any admin, and the point is that no one receives them.

groups=[...]write_only=True
Hides the value fromusers outside the listed groupseveryone
Admin (core_admin) still sees ityes (bypass)no
Also blocks writing the fieldyes (AccessError)no — still writable
In-process attribute reads affectednono
Use forrole-private but legitimately-viewable datasecrets that must never be output

The three mechanisms are independent filters that all apply — a user must pass every one:

  • Model Access — coarse CRUD gate per model per group.
  • Record Rules — row-level: which records of a model a user may touch.
  • Field-Level Security — column-level: which fields of an accessible record a user may read/write.

A user might have full read/write ModelAccess on Employee, be allowed by record rules to see every row, and still have the salary field stripped from the response because they are not in hr_manager.

  • groups is compared against the user’s effective groups, which include groups reached transitively via implied_groups (see Groups and Users).
  • Restricting a field is a pure metadata change — no migration is needed. Adding or changing groups on an existing field does not touch the schema.
  • Prefer field-level security over creating a parallel “sensitive” model when the only difference is visibility of a few columns; it keeps one record, one form, one source of truth.