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 — operation pending (M2); RBAC gate pre-mapped (#1883) |
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.
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 | full
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 ofread,edit, orfull.fulladditionally grants transfer/share/delete.
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: 306. 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.
- Field history — set
trackHistory: trueon the object (ObjectCapabilitiesinobject.zod.ts) to record per-field changes. - Per-field audit trail — set
auditTrail: trueon an individual field to track every change with user and timestamp. - 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
trackHistory: true
fields:
annual_revenue:
type: currency
auditTrail: 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. (owner/audit injection keys on systemFields are reserved for future expansion; only tenant is active today — see packages/spec/src/data/object.zod.ts.)