ObjectStackObjectStack

Rls schema — Security Protocol reference

Implements fine-grained record-level access control inspired by PostgreSQL RLS and Salesforce Criteria-Based Sharing Rules.

Row-Level Security (RLS) Protocol

Implements fine-grained record-level access control inspired by PostgreSQL RLS and Salesforce Criteria-Based Sharing Rules.

Overview

Row-Level Security (RLS) allows you to control which rows users can access in database tables based on their identity and positions. Unlike object-level permissions (CRUD), RLS provides record-level filtering.

Use Cases

  1. Multi-Tenant Data Isolation

    • Users only see records from their organization
    • using: "organization_id == current_user.organization_id"
  2. Ownership-Based Access

    • Users only see records they own
    • using: "owner_id == current_user.id"
  3. Organization Member Visibility

    • Users see fellow members of their active organization
    • using: "id in current_user.org_user_ids" (org_user_ids is pre-resolved by the runtime)
  4. Territory / Regional Access (§7.3.1 dynamic membership)

    • Sales reps only see accounts in their assigned territories
    • using: "account_id in current_user.territory_account_ids" (the runtime stages territory_account_ids in ExecutionContext.rlsMembership)
  5. Manager / Hierarchy Access (§7.3.1 dynamic membership)

    • Managers see records assigned to anyone they manage
    • using: "assigned_to_id in current_user.team_member_ids" (the runtime pre-resolves team_member_ids, no subquery needed)

PostgreSQL RLS Comparison

PostgreSQL RLS Example:

CREATE POLICY tenant_isolation ON accounts
  FOR SELECT
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

CREATE POLICY account_insert ON accounts
  FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);

ObjectStack RLS Equivalent:

{
  name: 'tenant_isolation',
  object: 'account',
  operation: 'select',
  using: 'organization_id == current_user.organization_id'
}

Salesforce Sharing Rules Comparison

Salesforce uses "Sharing Rules" and a visibility hierarchy for record-level access (our equivalent hierarchy is the business-unit tree, ADR-0090 D3). ObjectStack RLS provides similar functionality with more flexibility.

Salesforce:

  • Criteria-Based Sharing: Share records matching criteria with users/groups
  • Owner-Based Sharing: Share records based on who owns them
  • Manual Sharing: Individual record sharing

ObjectStack RLS:

  • A constrained CEL predicate grammar: comparisons and set-membership against literals or current_user.* values, composable with && / ||; anything that does not lower to a filter fails closed
  • Subquery-shaped needs are pre-resolved by the runtime (§7.3.1)
  • Multiple policies OR-combine on reads (any match allows); for writes see check

Best Practices

  1. Always Define SELECT Policy: Control what users can view
  2. Define INSERT/UPDATE CHECK Policies: Prevent data leakage
  3. Use Position-Scoped Policies: Apply different rules to different positions
  4. Test Thoroughly: RLS can have complex interactions
  5. Monitor Performance: Complex RLS policies can impact query performance

Security Considerations

  1. Defense in Depth: RLS is one layer; use with object permissions
  2. Default Deny, among the policies that apply: once a policy applies to the caller and the operation, a row that none of them admits is denied, and a policy that cannot be evaluated fails closed. When no policy applies, the policies restrict nothing (the tenant wall, a separate layer, still applies): object permissions, not RLS policies, stop an un-permissioned user
  3. Context Variables: Ensure current_user context is always set

See also: https://www.postgresql.org/docs/current/ddl-rowsecurity.html

See also: https://help.salesforce.com/s/articleView?id=sf.security_sharing_rules.htm

Source: packages/spec/src/security/rls.zod.ts

TypeScript Usage

import { RLSEvaluationResultSchema, RLSOperation, RLSUserContextSchema, RowLevelSecurityPolicySchema } from '@objectstack/spec/security';
import type { RLSEvaluationResult, RLSOperation, RLSUserContext, RowLevelSecurityPolicy } from '@objectstack/spec/security';

// Validate data
const result = RLSEvaluationResultSchema.parse(data);

RLSEvaluationResult

Properties

PropertyTypeRequiredDescription
policyNamestring✅Policy name
grantedboolean✅Whether access was granted
durationMsnumberoptionalEvaluation duration in milliseconds
errorstringoptionalError message if evaluation failed
usingResultbooleanoptionalUSING clause evaluation result
checkResultbooleanoptionalCHECK clause evaluation result

RLSOperation

Allowed Values

  • select
  • insert
  • update
  • delete
  • all

RLSUserContext

Properties

PropertyTypeRequiredDescription
idstring✅User ID
emailstringoptionalUser email
tenantIdstringoptionalTenant/Organization ID
positionsstring[]optionalPositions held by the user
departmentstringoptionalUser department
attributesRecord<string, any>optionalAdditional custom user attributes

RowLevelSecurityPolicy

Properties

PropertyTypeRequiredDescription
namestring✅Policy unique identifier (snake_case)
labelstringoptionalHuman-readable policy label
descriptionstringoptionalPolicy description and business justification
objectstring✅Target object name
operationEnum<'select' | 'insert' | 'update' | 'delete' | 'all'>✅Database operation this policy applies to
usingstringoptionalRow predicate, authored in canonical CEL (ADR-0058 D1): the rows a select policy lets a caller read, and the existing rows an update or delete policy lets a caller change or remove. On an insert or an update, when no applicable policy for that operation declares check, each applicable policy's using also stands in as its check on every row written (see check); on an insert policy that is its only effect. It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a current_user.* context value using ==, !=, <, <=, > or >=; in against a current_user.* array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with && / ||; or the bare allow-all true. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites = to == and IN to in (deprecated under ADR-0058 D1); SQL AND / OR / NOT IN / IS NULL / LIKE are NOT bridged and fail closed. Needed on a select or delete policy (a check there is refused); optional on an insert, update or all policy that declares check.
checkstringoptionalValidation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its beforeInsert hooks leave it, and each row an update changes, by id or multi: true, as the prior row merged with the final payload after its beforeUpdate hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. The default to using is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares check, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares check does each applicable policy's using stand in as its check (OR-combined). Applicable = not enabled: false, object matches or is '*', operation matches or is 'all', and the caller holds one of its positions when it lists any. Refused on a policy whose operation is select or delete, which write no new row to check: limit the rows such a policy admits with using, and declare the check on an insert, update or all policy.
positionsstring[]optionalPositions this policy applies to (omit for all)
enabledbooleanoptional (default: true)Whether this policy is active
priorityneveroptional[REMOVED] rowLevelSecurity[].priority was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
tagsneveroptional[REMOVED] rowLevelSecurity[].tags was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in positions. A policy is identified by its name and its object; say why it exists in description. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.

On this page