Positions — org hierarchy for sharing
Positions are flat groups that hand permission sets to users — the built-in identity positions, the everyone and guest anchors, and where hierarchy lives.
A position (岗位) is a named, assignable distribution group: users hold
positions (sys_user_position rows), a position names the permission sets it
distributes in permissionSets on its definition, and a user's capability is
the union of every set reached that way plus direct grants. The definition is
the only binding: the retired sys_position_permission_set junction object is
no longer registered, and a row an older database still holds grants nothing.
Positions carry no capability of their own — they only decide who gets
which sets.
Formerly "roles". ADR-0090 D3 renamed the concept in one step and reserved the word role platform-wide (the D7 publish linter rejects it in security identifiers and labels). The vocabulary is now unambiguous: permission set = capability, position = distribution, business unit = hierarchy. The single exception is better-auth's internal
sys_member.role— the org-membership tier, a closed list ofowner,admin,delegated_admin,memberand nothing else (ADR-0108). A tier decides what you can reach; a position decides what you may do. To invite someone straight into a position, use the invitation'spositionsplacement, not its membership tier — see Authentication.
import type { Position } from '@objectstack/spec/identity';
export const positions: Position[] = [
{ name: 'sales_manager', label: 'Sales Manager', permissionSets: ['crm_sales_manager'] },
{ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] },
{ name: 'service_agent', label: 'Service Agent', permissionSets: ['service_agent_access'] },
];Positions are flat — the hierarchy lives on business units
Positions deliberately have no parent link and form no tree (ADR-0090
D3, finalizing ADR-0057 D5). Re-adding a reporting tree here is the
position-per-department explosion every mature RBAC deployment regrets.
Visibility depth comes from two dedicated structures:
- the business-unit tree (
sys_business_unit) + membership (sys_business_unit_member) — consumed byreadScope/writeScopedepth grants (unit,unit_and_below) and byunit_and_subordinatessharing-rule recipients; - the manager chain (
sys_user.manager_id) — consumed byown_and_reports.
There is no implicit "managers see subordinates' records" grant. Upward visibility is always an explicit opt-in: a sharing rule targeting a position or BU subtree, or a scope-depth grant on the permission set. See Sharing Rules.
Assignments — and the business-unit anchor
Assigning a position is a plain data row:
await objectql.object('sys_user_position').insert({
user_id: 'user123',
position: 'sales_manager', // the position's name in the security catalog
business_unit_id: 'bu_east', // ADR-0090 Addendum: the assignment anchor
});business_unit_id is the assignment-level BU anchor with exactly three
consumers: it anchors where the assignment's readScope/writeScope depth
applies (a 华东 sales manager gets manager depth in 华东 only), it is the
delegated-administration boundary ("assignments you create must target your
subtree" — see below), and it is the audit fact ("manager of what").
Capability bits are never BU-scoped — positions never bind to a business unit
at the definition level.
Writes to sys_user_position are governed (ADR-0090 D12): tenant-level
admins pass; a delegated admin needs an adminScope covering the assignment
(subtree + allowlisted sets, granted_by auto-stamped); anyone else —
including holders of plain CRUD on the table — is denied.
Built-in identity positions (ADR-0068)
current_user.positions is a string array — the only canonical field (there
is no singular current_user.position at runtime). The built-in names are
declared by the platform — served by the security catalog, with no
sys_position row (ADR-0131 D2):
| Position | Meaning |
|---|---|
platform_admin | Platform operator (SaaS admin) — not a tenant position |
org_owner | Organization owner (sys_member.role = owner) |
org_admin | Organization administrator (sys_member.role = admin) |
org_member | Organization member (sys_member.role = member) |
Test membership in RLS/CEL with 'org_admin' in current_user.positions.
Audience anchors: everyone and guest (ADR-0090 D5/D9)
Two more built-in positions are implicit — they are never stored as assignment rows (the runtime rejects such rows) because whole principal classes hold them automatically:
everyone— every authenticated user. Its sets are the deployment's default grants: the configured baseline (member_default) plus each package set an admin holdingmanage_metadataaccepted. Receiving an explicit grant never costs the baseline (the "fallback cliff" was abolished by D5). Packages suggest a default via a set'sisDefault: true; the admin confirms each suggestion — nothing auto-binds (see Permission Sets).guest— every unauthenticated principal, which holds this position and nothing else. Its definition is the platform's and names no permission set, so a guest reaches data only through a surface that authorizes itself from a declaration — a public form'spublicFormGrant, a share link, a public book (see the anonymous-deny gate in Authorization).
No caller writes either anchor's definition — a platform admin and every
delegated admin scope included: the platform declares both and derives
everyone's sets (Authorization). The
high-privilege gate runs on that derived definition: accepting a set that
carries a bit the everyone anchor forbids — View/Modify All Data,
delete/purge/transfer, system permissions — is refused, and a later publish
that adds one is withheld. Grant powerful sets through ordinary positions
instead.
See also
- Permissions & Identity overview
- Permission Sets
- Sharing Rules
- Design reference:
docs/design/permission-model.md· Decision record: ADR-0090
Permission Sets
Permission sets are the only capability container: object CRUD, field security, scope depth and capabilities, union-merged across what a user holds.
Delegated Administration
Hand out scoped admin rights: a business-unit subtree, an action set and an assignable-set allowlist, with self-escalation structurally impossible.