Skip to content

Latest commit

 

History

History
469 lines (368 loc) · 14.1 KB

File metadata and controls

469 lines (368 loc) · 14.1 KB
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';

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');
// USING (owner_id = current_user.id) injected by the RLS compiler

The model is composed of three distinct metadata types (see packages/spec/src/security and packages/spec/src/identity):

  • Permission Set (permission / profile metadata) — the CRUD/FLS grant. A profile is a permission set with isProfile: true.
  • Role (role metadata) — the reporting hierarchy used for ownership-based visibility roll-up.
  • Sharing Rule + Organization-Wide Defaults (OWD) — broaden the baseline visibility set for an object.

Security Layers

} 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" />

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

Beyond 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 (a true anywhere wins).

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

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.rolePolicy(...) to generate these.

Role-Scoped Access

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]

Regional / Territory Access

rowLevelSecurity:
  - name: regional_sales_access
    object: account
    operation: select
    using: 'region = current_user.region OR region IS NULL'
    roles: [sales_rep]

Time-Based Access

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


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

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.

Partial Masking

# 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 value

Full Redaction

fields:
  credit_card:
    type: text
    label: Credit Card
    maskingRule:
      field: credit_card
      strategy: redact

Role-Scoped Masking

roles 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]

Hash Masking

fields:
  email:
    type: email
    label: Email
    maskingRule:
      field: email
      strategy: hash

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 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 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: role                # user | group | role | role_and_subordinates | guest
  value: sales_rep

Owner-Based Sharing

Share 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

accessLevel is one of read, edit, or full. full additionally 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: 30

6. Field-Level Encryption

Sensitive 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: 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 — RLS policy evaluations can be logged via RLSAuditConfigSchema (enabled, logLevel, destination, retentionDays, …). The recorded event shape is RLSAuditEventSchema (timestamp, userId, operation, object, policyName, granted, evaluationDurationMs).
# account.object.yml
name: account
trackHistory: true

fields:
  annual_revenue:
    type: currency
    auditTrail: true

8. Best Practices

Principle of Least Privilege

# 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'

Defense in Depth

Combine OWD (sharingModel: private), an RLS using policy, FLS (readable: false) on sensitive fields, and encryptionConfig for data at rest.

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