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 — operation pending (M2); RBAC gate pre-mapped (#1883)
allowRestoreRestore from trash (undelete) — operation pending (M2); RBAC gate pre-mapped (#1883)
allowPurgePermanently delete (hard delete / GDPR) — operation pending (M2); RBAC gate pre-mapped (#1883)
viewAllRecordsRead every record, bypassing sharing & ownership
modifyAllRecordsWrite 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 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.


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 | full
condition: 'record.account_type == "Enterprise"'
sharedWith:
  type: position            # user | group | position | unit_and_subordinates | guest
  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

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_managers

Enforcement status. Criteria rules with user / position / unit_and_subordinates 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 group/guest recipients are [experimental — not enforced]: the seed bootstrap skips them (logged) rather than seeding a permissive match-all (ADR-0049).

accessLevel is one of read, edit, or full. full additionally grants transfer/share/delete.

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

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.

  • Field history — set trackHistory: true on the object (ObjectCapabilities in object.zod.ts) to record per-field changes.
  • Per-field audit trail — set auditTrail: true on an individual field to track every change with user and timestamp.
  • 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
trackHistory: true

fields:
  annual_revenue:
    type: currency
    auditTrail: 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. (owner/audit injection keys on systemFields are reserved for future expansion; only tenant is active today — see packages/spec/src/data/object.zod.ts.)


Next Steps

On this page