Sharing rules and organization-wide defaults
Record-level access in ObjectStack: organization-wide defaults per object, the external sharing dial, criteria sharing rules and RLS-safe analytics reads.
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.
On a private OWD object this recipe needs a matching read grant. A
caller may not write a row they cannot read: a single-record update first
re-reads its target under the caller's own read scope, and on a private
object that scope is the supervisor's own records, their depth and their
shares — so the update policy above would admit a colleague's record by
predicate and the write would still be refused (403 PERMISSION_DENIED).
Pair the widener with read reach over the same rows, in the same permission
set:
objects: {
// `viewAllRecords` (or a `readScope` depth) makes the rows readable;
// the RLS policy below decides which of them the supervisor may edit.
project: { allowRead: true, allowEdit: true, viewAllRecords: true },
},
rowLevelSecurity: [
{
name: 'supervisor_edit_org',
object: 'project',
operation: 'update',
using: 'organization_id == current_user.organization_id',
positions: ['supervisor'],
},
],A criteria sharing rule that shares those records with the supervisor
position, or a record share to the supervisor, works as the read half too
(accessLevel: 'read' is enough — the policy supplies the edit). An extra select policy does not: RLS
only narrows, so it cannot make a private row visible. On public_read and
public_read_write objects every row is already readable (unless an authored
select policy hides it), and the recipe works as written.
Known limitation. App-authored write wideners (
update/deleterowLevelSecuritypolicies) do not function on aprivateOWD object without a matching read grant — the rows they admit stay unwritable until the caller can also read them. See RLS — write targets.
External principals — no external org-wide default
There is one org-wide default, sharingModel. An external (portal or
partner) principal reaches explicitly shared rows only — the authorization
kernel's EXTERNAL rung (ADR-0095), never derived from an org-wide default.
Grant it a row with a manual share or a sharing rule, or publish a record
through a share link (publicSharing).
externalSharingModel, the external dial ADR-0090 D11 declared, was removed
in @objectstack/spec 18 (ADR-0049 enforce-or-remove): no request ever
resolved an external principal, so the value changed no answer. The schema
refuses it with the prescription "delete the key";
os migrate meta --from 17 lists the mechanical edits for existing sources.
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 recipient expands to
concrete users and materializes sys_record_share grants — once per rule
for the first five, once per matched record for field:
type | Shares with |
|---|---|
user | A single user |
team | All members of a sys_team (flat collaboration grouping) |
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) |
business_unit | Everyone in exactly that business unit (no subtree) |
field | The user or users named by a user-typed field on each matched record — value is that field's name (assignees). A multiple: true field shares with every user it holds; an empty column shares with nobody. There is deliberately no manager recipient: "the owner's manager" is a user field the application stores on the record, named here (maintainer ruling 2026-09-02, objectstack#14103). Expanded once per matched record by the sharing-rule executor (objectstack#15072), and re-materialised when the record's own write changes that field |
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 (cloud 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:
| When | What is reconciled |
|---|---|
| The write that deactivates or edits the rule | That 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 record | That 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 write | The 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 boot | All 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({...})—conditionis required by the schema.POST {basePath}/sharing/rules— a request whosecriteriais missing,null, empty ({}), or unparsable fails with400 VALIDATION_FAILED. So does a misspelled key such ascriterias, 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
grouprecipient was renamed toteam, and theguestrecipient 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
Delegated Administration
Hand out scoped admin rights: a business-unit subtree, an action set and an assignable-set allowlist, with self-escalation structurally impossible.
Row-Level Security (RLS)
Declarative per-row policies compiled into query filters — the grammar, the context variables, how policies compose, and the fail-closed contract.