ObjectStackObjectStack

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 compiler

The 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 (permission metadata) — 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 (position metadata) — 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: false

Beyond the four CRUD flags, the schema also exposes lifecycle and super-user grants:

FlagMeaning
allowCreate / allowRead / allowEdit / allowDeleteStandard CRUD
allowTransferChange record ownership (assign/reassign/disown owner_id) — enforced now via the insert/update owner_id guard (#3004); the dedicated transfer op is still M2
viewAllRecordsRead every record, bypassing sharing & ownership
modifyAllRecordsWrite every record, bypassing sharing & ownership

The former allowRestore / allowPurge flags were retired (#12497, ADR-0049): the restore / purge operations they claimed to gate have never existed, so authoring them granted nothing — the schema now refuses them with a migration prescription. A dispatched restore / purge is denied unconditionally (fail-closed destructive-operation backstop). The flags return with the M2 lifecycle initiative (feature + RBAC in one batch, #1883).

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 everyone baseline (ADR-0090 D5). A true anywhere 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 result

2. 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 set

RLS 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 RLSConfigSchema was removed in 2026-07 — it was never read by the enforced path; per-policy RowLevelSecurityPolicySchema is 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: true

Behavior:

// 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 field

There is no separate create flag for fields. editable governs both create-time and update-time writes; read stripping is governed by readable.

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:

#RuleEffect
1Trusted context is exemptA write carrying context.isSystem === true skips the strip entirely and may set read-only columns.
2Only caller-supplied keys are candidatesThe engine snapshots the payload's keys at entry (suppliedKeys), before middleware and beforeUpdate hooks run. Only keys in that snapshot can be stripped.
3Hook / middleware backfill survivesA 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.
4context.preserveAudit admits a whitelist — on UPDATE onlyAn 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` column

isSystem 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 / MaskingConfigSchema were deleted from the spec: no redaction layer ever applied them, and the per-field maskingRule property had already been pruned in 2026-06. To keep a value out of reader hands, use FLS (readable: false field permissions — enforced by plugin-security's field masker), hidden, or a type: '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 | team | position | unit_and_subordinates | business_unit
  value: sales_rep

unit_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 — removed in v17

Owner-based rules (type: 'owner', ownedBy) were removed from the authoring surface in v17 (#1878), together with the group / guest recipients. They depended on live membership the static seeder cannot track, so they validated but never materialized a share. Use a criteria rule or a scope-depth grant instead; none of these shapes parses anymore, so a stale definition fails loudly at authoring time instead of silently doing nothing (ADR-0078).

"Skipped" and "rejected" are different instructions to an author. Before v17 these rules were declared but skipped at seed time: you could write one, it parsed, and the seeder ignored it. They are not skipped now — they do not parse. SharingRuleSchema rejects the block above with three issues: type: 'owner' fails the criteria literal, the required condition is reported missing, and ownedBy comes back as an unrecognized key carrying its own guidance message. There is also no OwnerSharingRuleSchema to point at — packages/spec/src/security/sharing.zod.ts exports CriteriaSharingRuleSchema, and SharingRuleSchema is that schema.

The retired share_west_region rule — "West-region accounts reach the regional managers" — is expressed by predicating the field instead of the owner:

# share_west_region.sharing.yml
name: share_west_region
type: criteria
object: account             # sharingModel: private, declared above
accessLevel: edit
condition: 'record.region == "west"'
sharedWith:
  type: position
  value: west_region_managers

Enforcement status. Every authorable rule and recipient type is enforced. Criteria rules with user / team / position / unit_and_subordinates / business_unit recipients compile and enforce (the CEL condition lowers to a runtime filter that materializes sys_record_share grants, ADR-0058 D3). Owner-type rules and the group / guest recipients are not [experimental — not enforced] and are no longer skipped at seed time — v17 removed them from the schema, so they do not parse at all (see above). What is still skipped-and-logged is a condition the compiler cannot lower (functions, cross-object traversal): it is never seeded as a permissive match-all (ADR-0049).

accessLevel is one of read or edit. Sharing widens which rows a principal reaches, never which verbs they may use — an edit share opens update, not delete: delete comes from ownership, the ADR-0057 DEPTH scopes, or the modifyAllRecords bypass, enforced by the sharing layer's own canDelete gate (distinct from the canEdit update gate) on top of the object-level CRUD gate (ADR-0111 D3). A third level full ("Full Access — transfer/share/delete") was authorable through protocol 16 but never granted any of those verbs: both enforcement sites matched edit/full alike, so it was equivalent to edit while telling admins otherwise, and it was removed (#3865, ADR-0078). Stacks still authoring it are rewritten to edit at load by the sharing-rule-access-level-full-to-edit conversion.

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: 30

Who 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, algorithms aes-256-gcm default / aes-256-cbc / chacha20-poly1305; key providers local, aws-kms, azure-key-vault, gcp-kms, hashicorp-vault) remains in the System namespace, but the per-field encryptionConfig property 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 a type: '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: 90

Set 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: secret round-trips through the registered secret provider — encrypted on write, decrypted on read. See packages/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: true on the object (the ObjectCapabilities block in object.zod.ts) to surface the record History tab.
  • Per-field history — set trackHistory: true on an individual field to render that field's value changes on the record activity timeline. (The former per-field auditTrail flag was pruned in 2026-06 — it had no runtime consumer.)
  • RLS audit — removed (2026-07, ADR-0056 D8): the RLSAuditConfigSchema/RLSAuditEventSchema shapes 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: true

8. 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.)


Next Steps

On this page