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.
Declaring a restricted field
Section titled “Declaring a restricted field”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.
What it enforces
Section titled “What it enforces”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:
| Layer | Behavior 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-by | A 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.
Read filtering is at the output boundary
Section titled “Read filtering is at the output boundary”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.
Aggregates and grouping
Section titled “Aggregates and grouping”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.
Write checks are at the API boundary
Section titled “Write checks are at the API boundary”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.
Write-only fields (secrets)
Section titled “Write-only fields (secrets)”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_only — groups= alone would still
hand them to any admin, and the point is that no one receives them.
groups=[...] | write_only=True | |
|---|---|---|
| Hides the value from | users outside the listed groups | everyone |
Admin (core_admin) still sees it | yes (bypass) | no |
| Also blocks writing the field | yes (AccessError) | no — still writable |
| In-process attribute reads affected | no | no |
| Use for | role-private but legitimately-viewable data | secrets that must never be output |
Relation to Model Access and Record Rules
Section titled “Relation to Model Access and Record Rules”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.
groupsis compared against the user’s effective groups, which include groups reached transitively viaimplied_groups(see Groups and Users).- Restricting a field is a pure metadata change — no migration is needed. Adding or changing
groupson 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.