ObjectStackObjectStack

Permission Sets

The only capability container — object CRUD + FLS + scope depth + capabilities, union-merged across everything a user holds. Covers the built-in sets, assignment tables, access depth, the isDefault suggestion, and delegated-admin scopes.

Permission Sets

A permission set is the only capability container in the platform (ADR-0090): a named bundle of object CRUD grants, field-level security, scope depth, tab visibility, system capabilities, and (optionally) RLS policies. A user's effective permissions are the union of every set they reach — via their positions, via direct grants, via the everyone anchor, and via the additive baseline. Any set that allows, wins; restriction is done by not granting.

import { definePermissionSet } from '@objectstack/spec/security';

export const SalesUser = definePermissionSet({
  name: 'sales_user',
  label: 'Sales User',

  // Object-level permissions: <object> -> permissions
  objects: {
    account: {
      allowCreate: true,
      allowRead: true,
      allowEdit: true,
      allowDelete: false,
      readScope: 'unit',       // see "Access depth" below
    },
    opportunity: { allowCreate: true, allowRead: true, allowEdit: true },
  },

  // Field-level security: <object>.<field> -> permissions
  fields: {
    'account.annual_revenue': { readable: true, editable: false },
  },

  // Tab/app visibility
  tabPermissions: { app_crm: 'default_on', app_admin: 'hidden' },

  // System capabilities (flat strings, resolved against sys_capability)
  systemPermissions: ['export_reports'],
});

Object permission bits

PermissionDescription
allowCreate / allowRead / allowEdit / allowDeleteCRUD on records the user can see
allowTransfer / allowRestore / allowPurgeLifecycle class (RBAC-gated ahead of the M2 operations)
viewAllRecordsRead ALL records regardless of ownership (super-user read)
modifyAllRecordsEdit ALL records regardless of ownership (super-user write)

Access depth — readScope / writeScope (ADR-0057 D1)

An owner-scoped grant can widen the owner-match declaratively — the "see my own / my reports / my unit / my unit and below / the whole org" axis:

ScopeOwner-match widens to
ownthe caller (baseline; unset = this)
own_and_reportsthe caller + their sys_user.manager_id report chain
unitowners in the caller's business unit
unit_and_belowthe caller's BU + all descendant BUs
orgthe whole tenant (≈ viewAllRecords / modifyAllRecords)

It resolves to an owner_id IN (…) set at request time (ADR-0055 — no subqueries, no recursion) and composes with the object's OWD baseline; sharing rules still widen on top. When a user's position assignments carry a BU anchor, the anchor decides which unit unit* means.

Open-core boundary (ADR-0016). own and org are open-source. The hierarchy-relative scopes (own_and_reports / unit / unit_and_below) require the enterprise hierarchy resolver; without it they fail closed to own (never fail-open).

Capabilities & required permissions (ADR-0066)

  • Capabilities are first-class sys_capability records — named privileges such as manage_users, manage_metadata, setup.access, studio.access. Grant them via systemPermissions; declare them as prerequisites via an object/field/app requiredPermissions (an AND-gate checked before any CRUD grant).
  • The authoring lint validateCapabilityReferences warns when requiredPermissions names a capability registered nowhere.

Built-in permission sets

Auto-seeded on boot by plugin-security (objects/default-permission-sets.ts):

NameScopeWhat it unlocksNotes
admin_full_accessPlatform (no RLS)All objects, all system permissions, Studio + SetupThe ADR-0066 superuser wildcard — the only legitimate '*' + View/Modify All combination (the D7 linter rejects it in authored packages)
organization_adminPer-org (tenant-isolation RLS)Wildcard CRUD inside the org, manage_org_users, Setup shellRead-only on the RBAC tables (anti-escalation); does not see Studio
member_defaultPer-orgStandard end-user CRUD; writes owner-scoped via positions: ['org_member']-domained RLSThe additive baseline — applies to every authenticated request in addition to explicit grants (ADR-0090 D5)
viewer_readonlyPer-orgRead access onlyAuditors / read-only stakeholders

Assigning permission sets

Grants are plain data rows:

// Grant a set to a single user (direct grant)
await objectql.object('sys_user_permission_set').insert({
  user_id: 'user123',
  permission_set_id: psId,
});

// Bind a set to a position (everyone holding it gets the set)
await objectql.object('sys_position_permission_set').insert({
  position_id: positionId,
  permission_set_id: psId,
});

These writes are governed (ADR-0090 D12): tenant-level admins pass through to the ordinary checks; a delegated admin needs a covering adminScope; plain CRUD on the tables grants nothing by itself. Bindings to the everyone/guest anchors additionally reject high-privilege sets at the data layer for every caller.

Org-admin grants (sys_member.role owner/admin → organization_admin) are reconciled automatically by a lifecycle hook — you never insert those rows by hand.

Package suggestion — isDefault (ADR-0090 D5)

A package may mark one of its sets isDefault: true: an install-time suggestion to bind it to the everyone anchor. The admin confirms each suggestion individually; nothing auto-binds, and the D7 linter rejects an isDefault set that carries anchor-forbidden bits (VAMA, destructive bits, wildcards, system permissions).

Pending suggestions are materialized as sys_audience_binding_suggestion rows (one per package × set × anchor, read-only over the data API) and resolved through the security surface — both installing a package at runtime and declaring the set in the stack produce them:

GET  /api/v1/security/suggested-bindings?status=pending   # list (reconciles first)
POST /api/v1/security/suggested-bindings/:id/confirm      # create the everyone binding
POST /api/v1/security/suggested-bindings/:id/dismiss      # decline the prompt

All three require a tenant-level administrator (the anchors are tenant-level only — D12). Confirm writes the sys_position_permission_set row as the caller, so the audience-anchor gate re-checks the forbidden-bits predicate at the data layer; a suggestion whose binding was created out-of-band (boot baseline, manual bind) is marked confirmed automatically, and uninstalling the package prunes its pending suggestions. Studio surfaces pending suggestions after marketplace installs and in the Access pillar.

Delegated administration — adminScope (ADR-0090 D12)

A permission set may carry an adminScope, making its holders scoped administrators (full gate rules and runbook: Delegated Administration):

export const EastSubsidiaryAdmin = definePermissionSet({
  name: 'east_subsidiary_admin',
  label: '华东子公司管理员',
  // The scope authorizes WHAT may be administered…
  adminScope: {
    businessUnit: 'east',                 // WHERE: this BU subtree
    manageAssignments: true,              // user ↔ position rows
    manageBindings: true,                 // position ↔ set rows
    assignablePermissionSets: ['sales_user', 'support_user'],  // WHICH sets
  },
  // …and the CRUD bits let the requests through at all:
  objects: {
    sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
    sys_position_permission_set: { allowRead: true, allowCreate: true, allowDelete: true },
  },
});

The runtime gate enforces: assignments anchored inside the subtree only, allowlisted sets only (to others and to themselves — no self-escalation), single-row writes, granted_by audit stamping, and strict containment when granting or authoring a set that itself carries an adminScope (handing out your own exact scope is refused). Anchors and security publishes stay tenant-level.

Provenance — package vs environment sets (ADR-0086)

A package ships its own sets (managedBy: 'package' + packageId), seeded idempotently at boot and re-seeded on upgrade; environment-authored sets (platform/user) are never clobbered. The data layer refuses forging package provenance through the admin door (two-doors separation, evolved by ADR-0094 — ordinary edits of a packaged set become environment overlays, see below), which is what makes package uninstall well-defined — and enforced: uninstalling a package (DELETE /api/v1/packages/:id) revokes its own sets, their position/user bindings, and its pending audience-binding suggestions in the same request (no ghost grants); the uninstall response reports the revocation under cleanups. Environment-authored sets and other packages' rows survive.

One authoritative store — the record is a projection (ADR-0094)

A permission-set definition has a single authoritative store: the metadata layer (packaged declarations plus the sys_metadata overlay). The queryable sys_permission_set record — what Setup lists and user assignment reads — is a pure projection of that definition, never independently authoritative. Every non-system write on the record (Setup CRUD, a bulk import, any API that goes through the data engine) is redirected into a metadata write and the record is re-derived by an awaited projector, so the two can never drift. This has three author-visible consequences:

  • Editing any declared set through Setup — packaged sets included becomes an environment overlay of that definition (the standard metadata customization): it genuinely takes effect, where a record-only edit previously displayed but never enforced. The row keeps its package provenance; the Studio layered view diffs the shipped baseline against your customization, and removing the overlay resets to the baseline. Note the trade every overlay makes: while an overlay pins a set, the vendor's later baseline changes don't take effect for it until you reset or re-author.
  • The API name is immutable after creation. It is the definition's metadata identity, so the create form accepts it but the edit form locks it, and a rename through the data door is rejected. Clone the set to a new name instead.
  • Deleting through the data door depends on where the definition lives. A set you created in this environment is removed. A set that ships with an installed package/app cannot be removed from the environment — "delete" resets it to the shipped definition (the customization overlay is dropped), and the row remains. Uninstall the owning package to remove a packaged set entirely.

A cross-package job function needs no hand-authored cross-package set: bind each package's own sets to one position (the union model adds), and use an overlay where a packaged set must be narrowed.


See also

On this page