| title | Security & Access Control |
|---|---|
| description | Comprehensive security model - ACL, field-level security, row-level permissions, and data isolation |
import { Shield, Lock, Users, Eye } from 'lucide-react';
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.
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');
// USING (owner_id = current_user.id) injected by the RLS compilerThe model is composed of three distinct metadata types (see packages/spec/src/security and packages/spec/src/identity):
- Permission Set (
permission/profilemetadata) — the CRUD/FLS grant. A profile is a permission set withisProfile: true. - Role (
rolemetadata) — the reporting hierarchy used for ownership-based visibility roll-up. - Sharing Rule + Organization-Wide Defaults (OWD) — broaden the baseline visibility set for an object.
} title="Object-Level Security (CRUD)" description="Who can Create, Read, Edit, Delete which objects (allowCreate/allowRead/allowEdit/allowDelete)" /> } title="Field-Level Security" description="Per-field readable/editable flags (e.g., hide salary from non-managers)" /> } title="Row-Level Security" description="Filter which records users can see via USING/CHECK policies (e.g., see only own accounts)" /> } title="Masking & Encryption" description="Mask or encrypt sensitive field values at rest and on read" />
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.
# sales_rep.permission.yml
name: sales_rep # lowercase snake_case, required
label: Sales Representative
isProfile: false # set true to make this a base profile
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 |
allowRestore |
Restore from trash (undelete) |
allowPurge |
Permanently delete (hard delete / GDPR) |
viewAllRecords |
Read every record, bypassing sharing & ownership |
modifyAllRecords |
Write every record, bypassing sharing & ownership |
Permission sets are layered: a user has exactly one profile (
isProfile: true) plus zero or more add-on permission sets. Grants are additive (atrueanywhere wins).
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
Row-level filtering is expressed as RLS policies (RowLevelSecurityPolicySchema in packages/spec/src/security/rls.zod.ts). Policies carry a SQL-like using clause (for SELECT/UPDATE/DELETE) and/or a check clause (for INSERT/UPDATE). 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), IN (current_user.roles), 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.
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');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.rolePolicy(...) to generate these.
roles restricts a policy to specific roles; omit it to apply to everyone (except bypassRoles):
rowLevelSecurity:
- name: manager_team_access
object: task
operation: select
using: 'assigned_to_id IN (SELECT id FROM users WHERE manager_id = current_user.id)'
roles: [manager, director]rowLevelSecurity:
- name: regional_sales_access
object: account
operation: select
using: 'region = current_user.region OR region IS NULL'
roles: [sales_rep]rowLevelSecurity:
- name: active_records_only
object: contract
operation: select
using: "status = 'active' AND start_date <= NOW() AND end_date >= NOW()"RLS conditions are compiled to parameterized queries. The default policy when no rule matches is deny (
RLSConfigSchema.defaultPolicy).
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 }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.
Masking is configured on the field via maskingRule (MaskingRuleSchema in packages/spec/src/system/masking.zod.ts). The strategy enum is one of: redact, partial, hash, tokenize, randomize, nullify, substitute.
# user.object.yml
fields:
ssn:
type: text
label: SSN
maskingRule:
field: ssn
strategy: partial
pattern: '\d{5}$' # regex selecting the visible portion
preserveFormat: true
preserveLength: true
exemptRoles: [hr_admin] # roles that see the unmasked valuefields:
credit_card:
type: text
label: Credit Card
maskingRule:
field: credit_card
strategy: redactroles lists the roles that see the masked value; exemptRoles lists those that see the original:
fields:
salary:
type: currency
label: Salary
maskingRule:
field: salary
strategy: partial
exemptRoles: [hr_manager, hr_admin]fields:
email:
type: email
label: Email
maskingRule:
field: email
strategy: hashThe 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 private, read, read_write, full. Sharing rules then grant additional access on top of that baseline.
# account.object.yml
name: account
sharingModel: private # owner-only by defaultShare 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: role # user | group | role | role_and_subordinates | guest
value: sales_repShare records owned by one group/role with another (OwnerSharingRuleSchema):
name: share_west_region
type: owner
object: account
accessLevel: edit
ownedBy:
type: role
value: west_region_reps
sharedWith:
type: role
value: west_region_managers
accessLevelis one ofread,edit, orfull.fulladditionally 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: 30Sensitive fields can be encrypted at rest via encryptionConfig (EncryptionConfigSchema in packages/spec/src/system/encryption.zod.ts). Supported algorithms are aes-256-gcm (default), aes-256-cbc, and chacha20-poly1305. Keys are held by a key-management provider (local, aws-kms, azure-key-vault, gcp-kms, hashicorp-vault).
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.
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 — RLS policy evaluations can be logged via
RLSAuditConfigSchema(enabled,logLevel,destination,retentionDays, …). The recorded event shape isRLSAuditEventSchema(timestamp, userId, operation, object, policyName, granted, evaluationDurationMs).
# account.object.yml
name: account
trackHistory: true
fields:
annual_revenue:
type: currency
auditTrail: true# Restrictive profile: read only what you own
name: account_owner
isProfile: true
objects:
account:
allowRead: true
rowLevelSecurity:
- name: account_owner_access
object: account
operation: select
using: 'owner_id = current_user.id'Combine OWD (sharingModel: private), an RLS using policy, FLS (readable: false) on sensitive fields, and encryptionConfig for data at rest.
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.)