ObjectStackObjectStack

Sharing Rules

Record-level access: the organization-wide default (OWD) baseline per object, the external sharing dial, criteria sharing rules and recipient types, and the RLS-safe analytics read scope.

Sharing & Organization-Wide Defaults

Record-level access starts from each object's organization-wide default (OWD) and can only be widened from there — by scope-depth grants, manual record shares, criteria sharing rules, and team grants. Row-level security is the one layer that narrows.

Organization-Wide Defaults (OWD)

Each object declares its baseline with sharingModel — one of exactly four canonical values (ADR-0090 D4; the legacy aliases read / read_write / full were removed):

export const LeaveRequest = ObjectSchema.create({
  name: 'leave_request',
  sharingModel: 'private',   // owner + shares only
  // …
});
ValueDescription
privateOwner only (+ scope-depth grants and record shares)
public_readAll users can read; writes stay owner-scoped
public_read_writeAll users can read and edit
controlled_by_parentAccess follows the master record (master-detail children)

The default is private, fail-closed (ADR-0090 D1). A custom object that declares no sharingModel — or that stores an unknown value — is treated as private at runtime: without a C/R/U permission grant plus an explicit baseline decision, records are owner-visible only. This inverts the pre-v2 behavior (unset used to mean org-public), which caused the leave_request incident: an object with CRUD grants but no OWD silently exposed every employee's records org-wide. The D7 publish linter additionally makes an unset OWD a build error (security-owd-unset in os compile), so the baseline is always an authored decision.

How viewAllRecords / modifyAllRecords interact with the OWD

The View All / Modify All super-user bits widen the sharing axis to org-wide (depth org): under private they lift the read and write owner filter, under public_read the write one. Under public_read_write the sharing baseline is already org-wide read + write, so on this layer the bits have nothing left to widen — "granting viewAllRecords makes no visible difference" is the expected outcome of that OWD choice, not a failed grant. If some rows are confidential, the object's OWD should be private (or public_read), with the super-user bits (or shares) doing the widening.

Two things the bits do not override, regardless of the OWD:

  • Baseline row-level security. RLS is a separate, narrowing layer. The platform baseline member_default ships owner-scoped update/delete policies (created_by == current_user.id, applicability domain org_member), so rank-and-file members stay owner-scoped on writes even under public_read_write. The super-user bits short-circuit RLS only where the object's access posture permits itaccess: { default: 'private' }, tenancy.enabled: false (platform-global), or better-auth managed objects (ADR-0066 D2 ①). On an ordinary tenant business object, modifyAllRecords does not lift those policies (plugin-security/src/security-plugin.ts computeLayeredRlsFilter). Org admins (org_admin / org_owner) are outside the org_member applicability domain and are not narrowed by the baseline.
  • The Layer 0 tenant wall (ADR-0095 D1) — always ANDs on top.

None of this is an enterprise/open-core split: both bits are enforced by the open-source plugin-security. The only enterprise-resolved axis is the hierarchy depth scopes (own_and_reports / unit / unit_and_below), which fail closed to owner-only without the @objectstack/security-enterprise resolver (ADR-0057).

Recipe — "owners edit their own records, supervisors edit all": bind an OR-widening RLS policy to a supervisor position in a permission set, e.g. { object: '*', operation: 'update', using: 'organization_id == current_user.organization_id', positions: ['supervisor'] } (policies OR-combine within an object, so members keep the owner gate while supervisors widen to the org) — or model the object with access: { default: 'private' } + explicit grants, where modifyAllRecords bypasses RLS by design.

The external dial — externalSharingModel (ADR-0090 D11)

Portal/partner scenarios get a second, independent dial with the same enum:

sharingModel: 'public_read',            // internal baseline
externalSharingModel: 'private',        // external principals: own + shares only
  • Defaults to private; external must never be wider than internal (private < public_read < public_read_write) — the linter errors on a wider external dial (security-external-wider-than-internal).
  • "External" is a property of the principal (audience: 'external'), never of the object. The BU depth axis does not apply to externals; their visibility = own records + explicit shares + the external OWD.
  • Status: declared, not yet evaluated at runtime. Today the dial is authoring-validated (the linter above) and surfaced in Studio (the Ext badge per object row); the evaluator branch that substitutes it for external principals lands with the principal-taxonomy semantics phase (tracked on #2696, liveness: planned). Until then no request is evaluated as external — authoring the dial prepares your model without changing behavior.

Criteria-Based Sharing Rules

Share records matching a predicate with a recipient:

import { defineSharingRule } from '@objectstack/spec/security';

export const AccountTeamSharingRule = defineSharingRule({
  name: 'account_team_sharing',
  label: 'Share Active Customers with Sales Managers',
  type: 'criteria',
  object: 'account',

  // Predicate (CEL): which records to share
  condition: P`record.type == "customer" && record.is_active == true`,

  // Who to share with (a single recipient — see the recipient types below)
  sharedWith: { type: 'position', value: 'sales_manager' },

  // Access level granted: read | edit | full
  accessLevel: 'edit',
});

Recipient types

sharedWith (and ownedBy) accept a { type, value } recipient:

typeShares with
userA single user
groupAll members of a public group — declared but not enforced: skipped at seed time with a warning (ADR-0049)
positionEveryone assigned that position (flat expansion — positions have no tree)
unit_and_subordinatesEveryone in that business unit and every unit beneath it (the BU tree is the one hierarchy — ADR-0090 D3)
teamMembers of a sys_team — teams receive sharing; they never carry capability (ADR-0090 D8)

A criteria condition must be compilable by the CEL → filter pushdown compiler. A condition the compiler cannot lower is skipped and logged — never seeded as a permissive match-all (ADR-0049): a bad condition under-shares rather than over-shares.

Owner-Based Sharing Rules

[Experimental — not enforced.] Owner-based (type: 'owner') rules depend on live position membership and have no static criteria_json equivalent, so the seeder skips them with a warning — they materialize no record shares today. Use a criteria rule or a scope-depth grant.

Analytics and Dataset Read Scope

Dataset-bound dashboards execute through the analytics service (including NativeSQL) and must honor the same row security as ObjectQL. @objectstack/plugin-security registers the security service — getReadFilter(object, context) returns the caller's composed row filter and @objectstack/service-analytics auto-bridges to it for the base object and every join. Resolution failures fail closed (zero rows), never filter-less. The same service also exposes explain(request) — the per-layer answer to "why can this user see that row".


See also

On this page