ConfigurePermissions

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:

StateRuleEffect
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, not budget) — since ObjectStack 14.4 the security-fls-unqualified-key lint 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:

CallerSees
Holds every capability in the field's requiredPermissionsThe stored value
Anyone else, when the field declares requiredPermissionsThe mask
Everyone except system context, when the field declares no requiredPermissionsThe mask
A caller from whom a readable: false grant hides the fieldNothing — 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_ERROR instead of storing 138****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 false rule in one set to override a true elsewhere.
  • 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:

SurfaceHidden fieldMasked fieldRead-only field
Record forms / inline gridsNot renderedValue shown masked; writing the mask back is rejected with 400 VALIDATION_ERRORRendered without an editable control; a direct write attempt is rejected with 403
List views, related lists, exportsColumn value stripped from the responseMasked value shown, in CSV and XLSX exports tooValue shown normally
REST / ObjectQLStripped from resultsMasked value returnedReturned on read; write throws PERMISSION_DENIED with forbiddenFields
MCP / AI agentsStripped — agents run as the calling userSame as RESTSame as REST
Filter, sort, group, aggregateRefused with 403 PERMISSION_DENIEDRefused with 403 PERMISSION_DENIEDAllowed

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 compile diffs the derived (permission set × object) capability matrix against a committed access-matrix.json and fails on drift, so capability changes ride pull requests as reviewable semantic diffs:
os compile --update-access-matrix

Default 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

TaskPage
Author field rules inside permission setsPermission Sets
Control which rows are reachable at allRecord Access
See the whole layered modelPermissions
Verify a specific user's accessManaging Access
Check what AI agents can readConnect AI Tools (MCP)