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
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:
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) |
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:
| 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".