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
  accessLevel: 'edit',
});

Recipient types

sharedWith accepts a { type, value } recipient. Every authorable recipient is enforced — each expands to concrete users at seed time and materializes sys_record_share grants:

typeShares with
userA single user
teamAll members of a sys_team (flat collaboration grouping)
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)
business_unitEveryone in exactly that business unit (no subtree)

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.

Rule administration requires manage_sharing (ADR-0111 D6)

A sharing rule is an org-wide grant generator, so the whole programmatic surface — GET/POST {basePath}/sharing/rules, GET/DELETE {basePath}/sharing/rules/:idOrName, and POST …/:idOrName/evaluate — requires the manage_sharing capability (seeded into admin_full_access; manage_platform_settings is honoured as the legacy equivalent). The gate is enforced in the service itself, not just at the route, so every caller is covered; an unauthorized call fails with 403 PERMISSION_DENIED. Boot seeding, lifecycle hooks, and backfills run as system context and are unaffected.

…and an organization to be scoped by. manage_sharing is declared scope: 'org', so the capability alone is not enough: the caller's session must also resolve an active organization, which is what scopes every rule read to "this organization ∪ the platform-global rows". A session that carries none — a user who has not selected an organization, or whose active organization was cleared — is refused with the same 403 PERMISSION_DENIED, naming the missing organization rather than answering with an empty list. Answering unscoped would hand that caller every organization's rules, and evaluate would reconcile grants across all of them (objectstack#8158). Two callers are deliberately unaffected, because neither is an org-scoped principal: system contexts, and platform operators — a holder of manage_platform_settings or of the built-in platform_admin position administers rules across the deployment whether or not an organization is selected, which is also what a single-tenant deployment looks like before its default organization is bootstrapped (ADR-0081 D1).

Switching a rule off withdraws the access it granted

A sharing rule's grants are materialized — evaluating a rule writes real sys_record_share rows with source: 'rule' and source_id set to the rule. Because those rows outlive the evaluation that produced them, withdrawal has to be an explicit act, and it happens at four moments:

WhenWhat is reconciled
The write that deactivates or edits the ruleThat rule's grants, immediately — deactivating with active: false (or POST {basePath}/sharing/rules with the same name) revokes them before the call returns
The next insert/update of a matching recordThat record's grants for every rule on the object — an inactive rule desires nothing, so its rows are revoked
A sys_business_unit or sys_business_unit_member writeThe grants of rules whose recipients read the BU graph — unit_and_subordinates and business_unit, and only those two. Recipients the rule no longer reaches are revoked before the write returns; the grant direction (a unit moved into a shared subtree) is queued and coalesced per rule (objectstack#7729)
Every bootAll rules, plus a sweep of source: 'rule' rows whose source_id no longer resolves to any rule

Deleting a rule withdraws its grants too, whether you delete it through DELETE {basePath}/sharing/rules/:idOrName (by id or by name) or through the plain data API in Setup.

The practical guarantee: an over-granting rule is always recoverable from the API surface. Switch it off or delete it, and the access it materialized is gone — not on the next time somebody happens to touch the record, and not only after a restart (objectstack#4433, #4434). A grant whose rule row has vanished entirely is retired by the boot sweep, so a database repaired by hand — or upgraded from a build that leaked these rows — converges on the next start.

Because withdrawal is materialized rather than computed at read time, the revocation is visible in sys_record_share itself. GET {basePath}/data/:object/:id/shares is the fastest way to confirm a rule's access is really gone, and POST {basePath}/sharing/rules/:idOrName/evaluate forces a reconcile on demand.

There is no "share every record" rule

The predicate is mandatory on every authoring path, whether you declare the rule in code, POST it to the REST API, or build it in Setup:

  • defineSharingRule({...})condition is required by the schema.
  • POST {basePath}/sharing/rules — a request whose criteria is missing, null, empty ({}), or unparsable fails with 400 VALIDATION_FAILED. So does a misspelled key such as criterias, which would otherwise be indistinguishable from "no criteria" (#3896).
  • Creating the rule in Setup — an empty Criteria field is rejected.

A rule that reached the table without a criteria — one stored before this gate existed — shares nothing and logs why: the next evaluation revokes the grants it had issued rather than re-granting the whole object. If you really do want everyone to read every record of an object, that is the object's organization-wide default (sharingModel), not a sharing rule.

Retired shapes. The pre-ADR-0090 group recipient was renamed to team, and the guest recipient was removed — anonymous access is served by the public-form grant and share links, not sharing rules. Owner-based rules (type: 'owner', ownedBy) were also removed: they depended on live membership the static seeder cannot track, so they validated but never materialized a share. Use a criteria rule or a scope-depth grant instead; none of these shapes parses anymore, so a stale definition fails loudly at authoring time instead of silently doing nothing (ADR-0078).

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