ObjectStackObjectStack

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 of owner, admin, delegated_admin, member and 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's positions placement, 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 by readScope/writeScope depth grants (unit, unit_and_below) and by unit_and_subordinates sharing-rule recipients;
  • the manager chain (sys_user.manager_id) — consumed by own_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):

PositionMeaning
platform_adminPlatform operator (SaaS admin) — not a tenant position
org_ownerOrganization owner (sys_member.role = owner)
org_adminOrganization administrator (sys_member.role = admin)
org_memberOrganization 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 holding manage_metadata accepted. Receiving an explicit grant never costs the baseline (the "fallback cliff" was abolished by D5). Packages suggest a default via a set's isDefault: 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's publicFormGrant, 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

On this page