Security & Access Control
Comprehensive security model - ACL, field-level security, row-level permissions, and data isolation
Security Protocol
ObjectStack implements a multi-layered security model that enforces access control at the data layer, before queries execute. Security is declarative—defined as metadata (permission sets, row-level policies, sharing rules), not scattered in application code.
Security Philosophy
Traditional approach:
// Security in application code (easily bypassed)
app.get('/api/accounts', (req, res) => {
// Developer must remember to check permissions
if (!req.user.hasPermission('read_account')) {
return res.status(403).json({ error: 'Forbidden' });
}
// Easy to forget row-level filtering
const accounts = await db.query('SELECT * FROM account');
res.json(accounts);
});ObjectStack approach:
// Security as metadata (enforced automatically)
// permission set: sales_rep grants read on account
// row-level policy filters by ownership
const accounts = await dataEngine.find('account');
// owner_id == current_user.id (CEL) → compiled to a row filter by the RLS compilerThe model is composed of three distinct metadata types (see packages/spec/src/security and packages/spec/src/identity), per the Permission Model v2 vocabulary (ADR-0090):
- Permission Set (
permissionmetadata) — the only capability container: CRUD, FLS, RLS, scope depth, View/Modify-All, system permissions. There is no profile concept — a user's capability is the union of every set they hold. - Position (
positionmetadata) — the flat distribution layer: who holds which permission sets. Hierarchy lives in the business-unit tree (sys_business_unit), never on positions. - Sharing Rule + Organization-Wide Defaults (OWD) — broaden the baseline visibility set for an object.
Security Layers
Object-Level Security (CRUD)
Who can Create, Read, Edit, Delete which objects (allowCreate/allowRead/allowEdit/allowDelete)
Field-Level Security
Per-field readable/editable flags (e.g., hide salary from non-managers)
Row-Level Security
Filter which records users can see via USING/CHECK policies (e.g., see only own accounts)
Masking & Encryption
Mask or encrypt sensitive field values at rest and on read
1. Object-Level Security (CRUD Permissions)
Object permissions live inside a permission set. Each entry in the objects map keys an object name to a boolean grant. The full ObjectPermissionSchema is defined in packages/spec/src/security/permission.zod.ts.
Basic Permission Set
# sales_rep.permission.yml
name: sales_rep # lowercase snake_case, required
label: Sales Representative
objects:
account:
allowCreate: true
allowRead: true
allowEdit: false
allowDelete: false
opportunity:
allowCreate: true
allowRead: true
allowEdit: true
allowDelete: falseBeyond the four CRUD flags, the schema also exposes lifecycle and super-user grants:
| Flag | Meaning |
|---|---|
allowCreate / allowRead / allowEdit / allowDelete | Standard CRUD |
allowTransfer | Change record ownership (assign/reassign/disown owner_id) — enforced now via the insert/update owner_id guard (#3004); the dedicated transfer op is still M2 |
allowRestore | Restore from trash (undelete) — operation pending (M2); RBAC gate pre-mapped (#1883) |
allowPurge | Permanently delete (hard delete / GDPR) — operation pending (M2); RBAC gate pre-mapped (#1883) |
viewAllRecords | Read every record, bypassing sharing & ownership |
modifyAllRecords | Write every record, bypassing sharing & ownership |
Permission sets are additive-only: a user's effective capability is the union of every set they hold — directly, via positions, or via the built-in
everyonebaseline (ADR-0090 D5). Atrueanywhere wins; there are no subtraction sets — to withhold, don't grant.
Permission Check Flow
User requests: GET /api/v1/data/account/123
↓
1. Object-Level Permission
✓ Does the user's effective permission set grant allowRead on 'account'?
↓
2. Row-Level Security (RLS)
✓ Does a USING policy admit record 123? (unless viewAllRecords)
↓
3. Field-Level Security (FLS)
✓ Strip fields whose FieldPermission.readable is false
↓
Return filtered result2. Row-Level Security
Row-level filtering is expressed as RLS policies (RowLevelSecurityPolicySchema in packages/spec/src/security/rls.zod.ts). Policies carry a CEL using clause (for SELECT/UPDATE/DELETE) and/or a check clause (for INSERT/UPDATE) — canonical CEL since ADR-0058; a legacy SQL-style =/IN (...) predicate still compiles via a deprecated bridge (warns). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the unique identifiers and membership sets the runtime pre-resolves: equality predicates may use current_user.id, current_user.email (the unique, seedable owner anchor), or current_user.organization_id; set-membership predicates may use id in current_user.org_user_ids, 'manager' in current_user.positions, or any §7.3.1 set staged in ExecutionContext.rlsMembership. Display name and arbitrary user fields are intentionally not resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.
Policies can be attached to a permission set via its rowLevelSecurity array, or registered as standalone metadata.
Owner-Based Access
rowLevelSecurity:
- name: opportunity_owner_access
object: opportunity
operation: select
using: 'owner_id == current_user.id'// At runtime the RLS compiler injects:
// WHERE owner_id = '<current_user_id>'
const opportunities = await dataEngine.find('opportunity');Tenant Isolation
organization_id maps to ExecutionContext.tenantId. To enforce multi-tenant isolation, define a policy with both using and check:
rowLevelSecurity:
- name: account_tenant_isolation
object: account
operation: all
using: 'organization_id == current_user.organization_id'
check: 'organization_id == current_user.organization_id'The RLS helper factory exports RLS.tenantPolicy(object) / RLS.ownerPolicy(object) / RLS.positionPolicy(...) to generate these.
Position-Scoped Access
positions restricts a policy to users holding one of the named positions (ADR-0090 D3; formerly roles); omit it to apply to everyone:
rowLevelSecurity:
- name: manager_team_access
object: task
operation: select
using: 'assigned_to_id in current_user.team_member_ids' # pre-resolved §7.3.1 set — NOT a subquery (ADR-0055)
positions: [manager, director]Regional / Territory Access
rowLevelSecurity:
- name: regional_sales_access
object: account
operation: select
using: 'territory_id in current_user.territory_ids' # pre-resolved set (current_user.region is not exposed)
positions: [sales_rep]Time-Based Access
rowLevelSecurity:
- name: active_records_only
object: contract
operation: select
using: "status == 'active'" # date-window/function predicates (NOW(), arithmetic) are NOT pushdown-able (ADR-0055) — enforce time windows in the app layer or a pre-resolved setRLS conditions are compiled to parameterized queries, and the enforcement path fails closed: an applicable policy that cannot be compiled denies (returns zero rows) rather than being dropped. (The old global
RLSConfigSchemawas removed in 2026-07 — it was never read by the enforced path; per-policyRowLevelSecurityPolicySchemais the live surface.)
3. Field-Level Security
Field permissions are a map on the permission set keyed by <object>.<field>, each with two boolean flags (FieldPermissionSchema):
# sales_rep.permission.yml (continued)
fields:
user.salary:
readable: false
editable: false
user.ssn:
readable: false
editable: false# hr_manager.permission.yml
fields:
user.salary:
readable: true
editable: trueBehavior:
// Sales rep reads a user — salary/ssn stripped (readable: false)
const u = await dataEngine.findOne('user', { where: { id: '123' } });
// { id: '123', name: 'John Doe', email: 'john@example.com' }
// HR manager (different permission set) reads the same user — salary visible
const u2 = await dataEngine.findOne('user', { where: { id: '123' } });
// { id: '123', name: 'John Doe', email: '…', salary: 120000 }Field Permission Reference
fields:
<object>.<field>:
readable: true | false # default true — can see the field
editable: true | false # default false — can modify the fieldThere is no separate
createflag for fields.editablegoverns both create-time and update-time writes; read stripping is governed byreadable.
Static readonly fields on the write path
readonly: true on a field definition is a schema-level lock, independent of permission
sets: no permission set can grant a write to it. It is enforced by stripping, not by
rejecting — the offending key is removed from the payload and the rest of the write is
committed. The write therefore succeeds (REST answers 200), and the read-only column
simply keeps its stored value.
Four rules decide whether a given value survives:
| # | Rule | Effect |
|---|---|---|
| 1 | Trusted context is exempt | A write carrying context.isSystem === true skips the strip entirely and may set read-only columns. |
| 2 | Only caller-supplied keys are candidates | The engine snapshots the payload's keys at entry (suppliedKeys), before middleware and beforeUpdate hooks run. Only keys in that snapshot can be stripped. |
| 3 | Hook / middleware backfill survives | A key a beforeUpdate hook adds to data is absent from the entry snapshot, so it is not a candidate — this is why the built-in updated_by / updated_at stamps land even though those columns are readonly. |
| 4 | context.preserveAudit admits a whitelist — on UPDATE only | An opt-in historical import reinstates the audit/timestamp family and author-declared business readonly fields; platform-managed system columns (tenancy, generated) stay stripped. This exemption exists on the UPDATE path and nowhere else — see below. |
Rule 2 is scoped to keys, not values: a key the caller sent stays a strip candidate even if
a hook later overwrites its value. So a beforeUpdate hook can backfill a read-only
field, but cannot rescue one the caller supplied.
preserveAudit is an UPDATE-path exemption. It does not apply on INSERT (#6640).
The two write paths run two different strips: UPDATE is stripped inside the engine
(stripReadonlyFields), which consults preserveAudit; CREATE is stripped earlier, at the
DataProtocol ingress (stripReadonlyForInsert, #3043), whose only exemption is
context.isSystem. So one historical import that upserts keeps an author-declared
readonly column on the rows it updates and strips it from the rows it creates.
That asymmetry is deliberate, not an oversight: treatAsHistorical arrives on an ordinary
(non-system) REST import request, so honouring it on create would let any caller seed the
approval/status columns the create-side strip exists to protect — in a single POST. To
replay archival read-only facts on create, write from a system context (isSystem), the
same trust marker rule 1 already names.
A non-system create that requests preserveAudit and loses fields to the strip is not
silently ignored: the server logs a WARN naming the object, the stripped fields, and this
UPDATE-only rule. The strip still applies — the warning replaces the silence, not the
enforcement.
Server-side plugins are not trusted by default. ctx.getService('data') hands back the
same engine the REST layer uses, with an empty execution context — so a cron job or
background task writing a system-computed read-only column travels the same strip as
untrusted client input, and its value is dropped while the call reports success. Trusted
server code must say so:
const data = ctx.getService('data');
await data.update('attendance', { id, status: 'closed', work_duration: 480 },
{ context: { isSystem: true } }); // ← required to write a `readonly` columnisSystem is the documented convention for this, and it is deliberately explicit: it
bypasses permission checks too, so it declares "this write is platform code acting as the
platform", not "this write came from a plugin".
The read-only strip is one of 80 behaviours the platform keys off that flag, across 18
packages — ownership stamping, referential-integrity checks and sharing-grant
materialisation all change with it, and none of them is visible from the write payload.
Before setting it, read the full census:
System Context (isSystem).
Detecting a drop programmatically. Every strip pass reports itself to the caller
through options.onFieldsDropped, so a caller that reports per-field success (a flow's
update_record step, an import runner) can surface a warning instead of a clean success:
await data.update('attendance', { id, work_duration: 480 }, {
onFieldsDropped: ({ object, fields, reason }) => {
// { object: 'attendance', fields: ['work_duration'], reason: 'readonly' }
logger.warn(`dropped ${fields.join(', ')} on ${object} (${reason})`);
},
});reason is 'readonly' for this static lock and 'readonly_when' for a conditional
readonlyWhen predicate. A third value,
'primary_key', reports the one legal strip that is not a read-only lock: an
update payload whose id the engine has already ruled is not an identifier is
dropped rather than written over the targeted row's primary key. The vocabulary is
open — it grows as the write path gains legal strips — so branch on reason
exhaustively rather than treating "not readonly_when" as "read-only". The listener is an in-process
callback: it is delivered by the local engine, and does not cross the RPC / Virtual
Data Engine boundary, so a remote caller never receives these events. Without a listener,
the only trace is a server-side WARN naming the object, the field, and both remedies.
4. Data Masking
Status: REMOVED (2026-07, ADR-0056 D8 "design + enforce, or remove").
MaskingRuleSchema/MaskingConfigSchemawere deleted from the spec: no redaction layer ever applied them, and the per-fieldmaskingRuleproperty had already been pruned in 2026-06. To keep a value out of reader hands, use FLS (readable: falsefield permissions — enforced by plugin-security's field masker),hidden, or atype: 'secret'field. A subtractive masking/deny layer, if needed, arrives with the ADR-0066 ⑦/⑧ muting work. Disposition tracked in the ADR-0056 D10 conformance matrix (data-masking).
5. Sharing Rules & Organization-Wide Defaults
The baseline visibility of an object is set by its Organization-Wide Default (OWD) — the sharingModel field on the object (packages/spec/src/data/object.zod.ts), one of the four canonical values private, public_read, public_read_write, controlled_by_parent (ADR-0090 D4 — the legacy aliases read/read_write/full were removed from the enum; authoring rejects them). A custom object with no declared sharingModel resolves to private (ADR-0090 D1 — the unset state no longer means public). An optional externalSharingModel (same enum, default private, never wider than the internal value) is the stricter dial for external portal/partner principals (ADR-0090 D11). Sharing rules then grant additional access on top of that baseline — sharing only ever widens, RLS only ever narrows.
# account.object.yml
name: account
sharingModel: private # owner-only baseline (also the D1 default)Criteria-Based Sharing
Share records whose fields match a CEL predicate (CriteriaSharingRuleSchema in packages/spec/src/security/sharing.zod.ts):
# share_enterprise_accounts.sharing.yml
name: share_enterprise_accounts
type: criteria
object: account
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | group | position | unit_and_subordinates | guest
value: sales_repunit_and_subordinates expands a business-unit subtree: the unit named by value plus every descendant unit's members (ADR-0057 D5 / ADR-0090 D3 — the former position-tree walk was re-homed onto the sys_business_unit tree).
Owner-Based Sharing
Share records owned by one group with another (OwnerSharingRuleSchema):
name: share_west_region
type: owner
object: account
accessLevel: edit
ownedBy:
type: position
value: west_region_reps
sharedWith:
type: position
value: west_region_managersEnforcement status. Criteria rules with
user/position/unit_and_subordinatesrecipients compile and enforce (the CEL condition lowers to a runtime filter that materializessys_record_sharegrants, ADR-0058 D3). Owner-type rules andgroup/guestrecipients are[experimental — not enforced]: the seed bootstrap skips them (logged) rather than seeding a permissive match-all (ADR-0049).
accessLevelis one ofreadoredit. Sharing widens which rows a principal reaches, never which verbs they may use — aneditshare opens update, not delete: delete comes from ownership, the ADR-0057 DEPTH scopes, or themodifyAllRecordsbypass, enforced by the sharing layer's owncanDeletegate (distinct from thecanEditupdate gate) on top of the object-level CRUD gate (ADR-0111 D3). A third levelfull("Full Access — transfer/share/delete") was authorable through protocol 16 but never granted any of those verbs: both enforcement sites matchededit/fullalike, so it was equivalent toeditwhile telling admins otherwise, and it was removed (#3865, ADR-0078). Stacks still authoring it are rewritten toeditat load by thesharing-rule-access-level-full-to-editconversion.
Public Share Links
For "anyone with the link" style external sharing, an object opts in via publicSharing (see packages/plugins/plugin-sharing/src/share-link-service.ts):
name: proposal
publicSharing:
enabled: true
allowedAudiences: [link_only]
allowedPermissions: [view]
redactFields: [internal_notes]
maxExpiryDays: 30Who may mint and revoke (ADR-0111 D8). Minting a link requires the object's
publicSharing opt-in and that the caller can see the record — an opted-in
object deliberately delegates re-share power to anyone who can view the row.
Revoking a link is allowed for the link's creator, a record share-manager
(the record's owner or a modifyAllRecords admin — the same authority as
canManageShares), or system context: a link someone else minted on your record
is your record's exposure to kill, not only its creator's.
6. Field-Level Encryption
Status: schema exists, field wiring removed.
EncryptionConfigSchema(packages/spec/src/system/encryption.zod.ts, algorithmsaes-256-gcmdefault /aes-256-cbc/chacha20-poly1305; key providerslocal,aws-kms,azure-key-vault,gcp-kms,hashicorp-vault) remains in the System namespace, but the per-fieldencryptionConfigproperty was pruned from the Field schema in 2026-06 — at-rest field encryption was never implemented by the runtime. The supported channel for secret values is atype: 'secret'field (one-way handling via the settings crypto provider). The example below shows the schema shape for implementers.
fields:
ssn:
type: text
label: SSN
encryptionConfig:
enabled: true
algorithm: aes-256-gcm
scope: field # field | record | table | database
keyManagement:
provider: aws-kms
keyId: ssn-master-key
rotationPolicy:
enabled: true
frequencyDays: 90Set deterministicEncryption: true to allow equality queries on the ciphertext, or searchableEncryption: true for search support.
Secret fields. For reversible secrets (DB passwords, API keys, tokens) the field
type: secretround-trips through the registered secret provider — encrypted on write, decrypted on read. Seepackages/spec/src/data/field.zod.ts.
7. Auditing & Field History
Object- and field-level history is opt-in metadata, not a free-form enable.audit block.
- Object history tab — set
enable.trackHistory: trueon the object (theObjectCapabilitiesblock inobject.zod.ts) to surface the record History tab. - Per-field history — set
trackHistory: trueon an individual field to render that field's value changes on the record activity timeline. (The former per-fieldauditTrailflag was pruned in 2026-06 — it had no runtime consumer.) - RLS audit — removed (2026-07, ADR-0056 D8): the
RLSAuditConfigSchema/RLSAuditEventSchemashapes were never emitted or read by the runtime RLS path and were deleted from the spec. Enforcement decisions surface today through the security plugin's fail-closed warnings and the standard audit log (plugin-audit), not a dedicated RLS event stream.
# account.object.yml
name: account
enable:
trackHistory: true
fields:
annual_revenue:
type: currency
trackHistory: true8. Best Practices
Principle of Least Privilege
# Restrictive permission set: read only what you own
name: account_owner
objects:
account:
allowRead: true
rowLevelSecurity:
- name: account_owner_access
object: account
operation: select
using: 'owner_id == current_user.id'Defense in Depth
Combine OWD (sharingModel: private), an RLS using policy, FLS (readable: false) on sensitive fields, and type: 'secret' for values that must never round-trip to clients.
Security by Default
Leave registry-level systemFields injection enabled (the object default) so multi-tenant objects auto-stamp organization_id on create, and use field defaultValue to seed ownership fields — so records are never left unscoped. (systemFields accepts two opt-out keys — tenant and audit — both active; owner_id provisioning is governed separately by the object-level ownership property. See packages/spec/src/data/object.zod.ts.)