Field-Level Security
Hide, mask or lock down individual fields — grant semantics, server-side enforcement, and how FLS behaves in forms, views, and the API.
Field-level security (FLS) controls the visibility and editability of
individual fields, after object permissions and
record access have allowed
the user to reach the record at all. It is the layer for "support can
see the account, but not its annual_revenue" and "reps can read the
external id but never change it".
FLS rules live in permission sets — this page goes deeper on the grant semantics and enforcement than the field security appendix there.
Field permission grants
Field permissions are keyed <object>.<field> and use readable /
editable:
// Keys of a permission set; its other keys are omitted.
fields: {
// Read-only: visible but not editable
'account.annual_revenue': { readable: true, editable: false },
'account.description': { readable: true, editable: true },
// Hidden: not visible at all
'account.ssn': { readable: false, editable: false },
'opportunity.amount': { readable: true, editable: true },
'opportunity.probability': { readable: true, editable: false },
}The two flags produce three states:
| State | Rule | Effect |
|---|---|---|
| Hidden | { readable: false, editable: false } | Field is not visible at all — stripped from every response |
| Read-only | { readable: true, editable: false } | Field is returned, but writes to it are rejected |
| Editable | { readable: true, editable: true } | Field is visible and writable |
Always write field-permission keys object-qualified (
crm_lead.budget, notbudget) — since ObjectStack 14.4 thesecurity-fls-unqualified-keylint rejects bare keys at compile time, because they silently matched nothing.
Masked fields
Since ObjectStack 17.1 a field can also be masked: served with most of its value replaced, so it stays recognizable without being disclosed. Masking is declared on the field itself, not in a permission set:
// Fields of an object; the object around them is omitted.
mobile: Field.phone({
label: 'Mobile',
maskingRule: 'phone', // served as 138****5678
requiredPermissions: ['view_pii'], // holders of view_pii see the stored value
}),
tax_id: Field.text({
label: 'Tax ID',
maskingRule: { keepHead: 2, keepTail: 2 }, // keep two characters at each end
}),maskingRule takes one of five presets — phone, id_card,
bank_account, email, name — or { keepHead, keepTail }, the
number of characters to keep at each end. The mask is deterministic and
keeps the value's length.
Who sees the stored value:
| Caller | Sees |
|---|---|
Holds every capability in the field's requiredPermissions | The stored value |
Anyone else, when the field declares requiredPermissions | The mask |
Everyone except system context, when the field declares no requiredPermissions | The mask |
A caller from whom a readable: false grant hides the field | Nothing — the field stays hidden |
The capability in requiredPermissions is granted like any other, by a
permission set. Masking never widens a hidden field: a readable: false
grant still removes it entirely.
Masking is one value on every surface — API responses, list views and forms, CSV and XLSX exports, and the context an AI agent reads all serve the same mask. Two more rules keep it from leaking:
- A masked field cannot be queried. Filtering, sorting, grouping or aggregating on it is refused, as for a hidden field — see Enforcement in the API.
- The mask never overwrites the value. A write that sends the masked
placeholder back — a client re-submitting a whole record it read — is
refused with
400 VALIDATION_ERRORinstead of storing138****5678.
How grants combine
FLS uses default-visible (block-list) semantics: fields without an explicit rule pass through untouched — readable and writable. Permission sets only constrain fields they explicitly enumerate.
Field grants union most-permissively across a user's sets: one
set's readable: true out-votes another set's false. Until a
subtractive muting layer lands (reserved as ADR-0066 ⑧), a
{ readable: false } rule hides the field only as long as no other
set the user holds declares it readable: true. The practical
consequences:
- Protect sensitive fields by granting them only in the sets that
need them — never rely on a
falserule in one set to override atrueelsewhere. - Treat a sensitive field on a broadly-granted object as a review smell.
Declared rules themselves are enforced fail-closed: a hidden field is stripped on read and a write to a non-editable field throws.
Enforcement in the API
The SecurityPlugin middleware enforces field rules on the server, regardless of how the request arrived — REST, ObjectQL, or any other path. There is no back door through a lower-level API.
On read — find / findOne results have non-readable fields
stripped from every record before the response leaves the engine, and
masked fields replaced by their mask.
On query — a field the caller cannot read, or reads only masked,
cannot be used to select rows either, because which rows match would
disclose its value one probe at a time. Filtering, sorting, grouping or
aggregating on it is refused with 403 PERMISSION_DENIED. Since
ObjectStack 17.6 that holds at every query door, not only the data
API's filter and sort:
- a field named as the right-hand side of a comparison, in a filter or an aggregation;
- analytics queries;
- filters and sorts over the copied values in the activity stream, the audit log and approval snapshots — refused unless the filter names one object by equality and the caller can read every field of it.
On write — insert / update requests are checked before the
operation reaches the driver. If the payload contains any field the
caller is not permitted to edit, the engine throws
PermissionDeniedError (HTTP 403) with the offending field names:
{
"error": {
"code": "PERMISSION_DENIED",
"message": "[Security] Field write denied: not permitted to edit [salary, ssn] on 'employee'",
"details": {
"operation": "insert",
"object": "employee",
"forbiddenFields": ["salary", "ssn"]
}
}
}Why throw instead of silently stripping? Silent strip hides the security boundary from honest clients (their update "doesn't save" and they cannot tell why) and gives a probing client no signal either way. Throwing makes the boundary observable in both directions — legitimate UIs get an actionable error to fix; probing clients learn nothing they could not already infer.
Two more enforcement details:
- Bulk inserts are checked row-by-row; a single offending field in any row rejects the whole batch atomically.
- System operations (
ExecutionContext { isSystem: true }) bypass the check entirely — used for migrations, seed loading, and audit-log writes.
FLS in forms and views
The generated forms and inline grids hide non-editable fields from the UI — but that is a UX layer only. The server-side check above is the source of truth, so behavior stays consistent everywhere:
| Surface | Hidden field | Masked field | Read-only field |
|---|---|---|---|
| Record forms / inline grids | Not rendered | Value shown masked; writing the mask back is rejected with 400 VALIDATION_ERROR | Rendered without an editable control; a direct write attempt is rejected with 403 |
| List views, related lists, exports | Column value stripped from the response | Masked value shown, in CSV and XLSX exports too | Value shown normally |
| REST / ObjectQL | Stripped from results | Masked value returned | Returned on read; write throws PERMISSION_DENIED with forbiddenFields |
| MCP / AI agents | Stripped — agents run as the calling user | Same as REST | Same as REST |
| Filter, sort, group, aggregate | Refused with 403 PERMISSION_DENIED | Refused with 403 PERMISSION_DENIED | Allowed |
Because reads are stripped rather than errored, a hidden field simply
looks absent to that user — which is the point. Querying on it is the
exception: that answers 403, as above.
Verifying and reviewing FLS
- Per decision, at runtime — the explain engine reports the FLS layer's verdict alongside every other layer, with the contributing permission set named, and reports a masked field as masked rather than hidden. Use it when a user reports a "missing" field.
- Per change, at build time — if your app has opted into the
access-matrix snapshot gate,
os compilediffs the derived (permission set × object) capability matrix against a committedaccess-matrix.jsonand fails on drift, so capability changes ride pull requests as reviewable semantic diffs:
os compile --update-access-matrixDefault to hide rather than read-only when the field carries sensitive data — read-only still leaks the value into responses and logs. Bundle field rules into permission sets that match a real job function, and pair them with audit-log retention for compliance cases. More authoring patterns: Permission Sets.
Where to go next
| Task | Page |
|---|---|
| Author field rules inside permission sets | Permission Sets |
| Control which rows are reachable at all | Record Access |
| See the whole layered model | Permissions |
| Verify a specific user's access | Managing Access |
| Check what AI agents can read | Connect AI Tools (MCP) |