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
// …
});| Value | Description |
|---|---|
private | Owner only (+ scope-depth grants and record shares) |
public_read | All users can read; writes stay owner-scoped |
public_read_write | All users can read and edit |
controlled_by_parent | Access follows the master record (master-detail children) |
The default is
private, fail-closed (ADR-0090 D1). A custom object that declares nosharingModel— or that stores an unknown value — is treated asprivateat 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-unsetinos 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_defaultships owner-scopedupdate/deletepolicies (created_by == current_user.id, applicability domainorg_member), so rank-and-file members stay owner-scoped on writes even underpublic_read_write. The super-user bits short-circuit RLS only where the object's access posture permits it —access: { default: 'private' },tenancy.enabled: false(platform-global), or better-auth managed objects (ADR-0066 D2 ①). On an ordinary tenant business object,modifyAllRecordsdoes not lift those policies (plugin-security/src/security-plugin.tscomputeLayeredRlsFilter). Org admins (org_admin/org_owner) are outside theorg_memberapplicability 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
Extbadge 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:
type | Shares with |
|---|---|
user | A single user |
group | All members of a public group — declared but not enforced: skipped at seed time with a warning (ADR-0049) |
position | Everyone assigned that position (flat expansion — positions have no tree) |
unit_and_subordinates | Everyone in that business unit and every unit beneath it (the BU tree is the one hierarchy — ADR-0090 D3) |
team | Members 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 staticcriteria_jsonequivalent, 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".