ObjectStackObjectStack

Permission sets — grant access in bundles

Permission sets are the only capability container: object CRUD, field security, scope depth and capabilities, union-merged across what a user holds.

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, matched by capability name)
  systemPermissions: ['export_reports'],
});

Object permission bits

PermissionDescription
allowCreate / allowRead / allowEdit / allowDeleteCRUD on records the user can see
allowExportBulk data egress — an opt-in grant on top of read, see below
allowTransferLifecycle class: change record ownership — enforced today via the owner_id guard (#3004). The former allowRestore / allowPurge keys were retired (#12497, ADR-0049 — the operations they claimed to gate do not exist yet) and return with the M2 lifecycle batch (#1883)
viewAllRecordsRead ALL records regardless of ownership (super-user read)
modifyAllRecordsEdit ALL records regardless of ownership (super-user write)

allowExport — the export axis

Read and export are not the same privilege. Reading a record on screen and pulling the whole table down as a CSV differ in blast radius, which is why Salesforce ("Export Reports"), Dynamics ("Export to Excel"), NetSuite ("Export Lists") and SAP (S_GUI 61) all carry a separate export permission. allowExport is that axis here: export = list ∧ allowExport.

It is an opt-in grant, like every other allow* bit:

ValueMeaning
trueExport granted — still bounded by read.
unsetNo export. Read alone never confers it.
falseNo export. Same outcome as unset; write it when you want the intent on the record.

Across several permission sets the merge is most-permissive, exactly like the CRUD bits: any set granting true grants export. false is therefore documentation rather than a veto — permission sets are additive capability containers, and nothing in them is a deny.

Two consequences worth stating plainly:

  • It narrows read; it never widens it. A set granting allowExport: true without a read grant exports nothing — export = list ∧ allowExport.
  • viewAllRecords / modifyAllRecords do not imply it. Separating "may see all data" from "may take a bulk copy of it" is the segregation-of-duties case the axis exists for. The built-in admin_full_access and organization_admin sets carry allowExport: true explicitly; remove that line to get the separation. member_default deliberately does not carry it, so ordinary authenticated users have no export by default.

A set carrying allowExport is treated as high-privilege and cannot be bound to the everyone or guest audience anchors — binding it there would hand bulk egress to every authenticated user (or every visitor) and undo the opt-in. Grant it through an ordinary position-distributed set instead.

Enforcement is server-side and covers both egress doors:

  • GET /api/v1/data/:object/export answers 403 EXPORT_NOT_PERMITTED before it reads the first row. Its import template (?template=true) is not a copy of data — it carries no records — so it is gated by the import instead: the create permission on the object, never allowExport.
  • A report rendered as csv or json is the same bulk copy and is gated the same way, whether run interactively or delivered by a schedule. html_table is a rendered view and stays a read.

The same decision is published on /me/permissions as the object's effective apiOperations, which is what makes the client hide its Export button — the button and the refusal are one decision, not two. To ask why a particular user was refused, use explain with operation: 'export'; operation: 'read' will answer allowed and tell you nothing, because reading is exactly what they are still permitted to do.

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

A depth axis beside the super-user bit that bypasses it is REFUSED. The read-scope resolver answers org on the super-user bit before it consults the depth key, so such a declaration would be stored and never read — and a capability census over the deployed shape would count it as coverage. The accept set now rejects it at the offending key, naming both remedies. Note the asymmetry, which follows the resolver exactly: viewAllRecords bypasses READ only, so readScope beside viewAllRecords: true is refused while writeScope beside it is accepted and honoured. modifyAllRecords bypasses both, so both readScope and writeScope beside it are refused. Declare the depth, or declare the bypass — not both on the same axis.

Capabilities & required permissions (ADR-0066)

  • Capabilities are named privileges such as manage_users, manage_metadata, setup.access, studio.access. The platform's curated ones are declared capability metadata served by the registry (GET /api/v1/meta/capability); a capability a package declares with defineCapability is served the same way, under its owning package. Neither has a sys_capability row. 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

Declared by plugin-security on its own manifest (objects/default-permission-sets.ts) and served from the security catalog — the boot writes no row for them:

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

Who holds admin_full_access is not (only) a grant row. PLATFORM_ADMIN standing is derived at a single site from two anchors. Which of them is sufficient depends on the deployment's tenancy posture:

  • Configuration — OS_PLATFORM_OWNER_EMAIL. The deployment declares its administrators as one email address, or a comma-separated list of them. A caller holds standing when their own stored sys_user row carries a declared address and that row reads email-verified; an unverified account holding a declared address confers nothing, so registering the operator's address first gains an attacker no standing. Both sides are compared trimmed and lower-cased, so Ada@Example.com and ada@example.com are one administrator, not two half-matches. Nothing is stored and nothing is granted — the answer is recomputed from configuration on every resolution. This anchor is read under every posture.
  • An unscoped admin_full_access grant row — a sys_user_permission_set row with organization_id = NULL, the original anchor. It confers standing under the single posture only. Under the walled postures (group / isolated) the row is no longer an anchor: the set it names still grants what any held set grants, but it confers no PLATFORM_ADMIN standing, and nothing is logged about it per request. If a walled deployment holding such a row ends up with no usable configured administrator, the boot log says it has zero platform administrators, names the account that holds the row, and points at OS_PLATFORM_OWNER_EMAIL as the way to restore that account's standing.

Which anchor a fresh deployment gets depends on its tenancy posture. Under single the first human account to register is still promoted and its grant row written. Under the walled postures (group / isolated) no grant row is ever written — first-registrant promotion is removed there, because on a deployment with self-registration reachable, whoever signs up first would otherwise receive cross-tenant access. Administrators come from configuration alone. The operator procedure, including what an unusable value does, is on Self-hosting → First boot.

The resolved list is readable, never writable: a platformAdmin service answers the configured addresses and, per address, whether an account exists, whether it is verified, and which account holds standing — for Setup, discovery and health surfaces. There is deliberately no runtime endpoint that changes who a platform administrator is; revocation is a configuration change plus a process reload.

Assigning permission sets

A direct grant is a data row; a position's sets are declared on the position's definition:

// Grant a set to a single user (direct grant) — by the set's name
await objectql.object('sys_user_permission_set').insert({
  user_id: 'user123',
  permission_set: 'crm_sales_user',
});

// A position names the sets it distributes (everyone holding it gets them)
import { definePosition } from '@objectstack/spec/identity';

export const salesRep = definePosition({
  name: 'sales_rep',
  label: 'Sales Representative',
  permissionSets: ['crm_sales_user'],
});

Each name in permissionSets is resolved in the environment's catalog when a holder's grants are resolved; a name the catalog does not hold confers nothing. The position's definition is the only binding that grants: the retired sys_position_permission_set junction object is no longer registered, and a row an older database still holds confers nothing.

The assignment 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. A position's permissionSets is part of its definition, so changing it is a metadata write (manage_metadata; the platform operator under group or isolated). No caller binds a set to the everyone or guest anchor: everyone's sets are derived from the deployment's baseline and the package suggestions an admin accepted (next section), and the high-privilege gate runs on that derived definition.

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 give it to every signed-in member through the everyone anchor. An admin holding manage_metadata accepts each suggestion once, for the whole deployment; nothing auto-binds, and the D7 linter rejects an isDefault set that carries anchor-forbidden bits (VAMA, destructive bits, system permissions). A plain '*' wildcard grant is not an anchor-forbidden bit for everyone — the platform's own member_default baseline is exactly that shape; the wildcard ban is the stricter guest tier's rule.

Suggestions are materialized as sys_audience_binding_suggestion rows (one per package × set × anchor for the deployment, owned by the Default Organization, read-only over the data API) and resolved through the security surface — any isDefault set the security catalog serves with an owning package produces one:

GET  /api/v1/security/suggested-bindings?status=pending   # list (reconciles first)
POST /api/v1/security/suggested-bindings/:id/confirm      # accept: everyone carries the set
POST /api/v1/security/suggested-bindings/:id/dismiss      # decline, or revoke an acceptance

status accepts pending, confirmed or dismissed, and may be omitted to list every suggestion. Any other value is rejected with a 400 naming the accepted set — it previously reached the query as a filter nothing matched, so a mistyped status returned an empty list that read as "no suggestions".

All three require the manage_metadata capability, and under a walled tenancy posture the platform operator: the decision reaches every member of every organization, so a tenant administrator cannot make it. An acceptance takes effect at once. What everyone grants is the permissionSets its definition names, and the security plugin derives that definition as the deployment's baseline plus every accepted set — again on each accept, revoke, uninstall and package publish. No caller writes the definition itself (see Authorization).

  • The high-risk gate is on the definition. Accepting a set that carries a bit the everyone anchor forbids is refused with 403 PERMISSION_DENIED; if a later publish adds such a bit to an accepted set, the derivation withholds it and logs why.
  • Revoke an acceptance by dismissing the accepted suggestion; uninstalling the package removes its suggestions and its sets leave everyone.
  • A set the baseline already names (the served app's own isDefault set) is marked confirmed without a resolver — observed, not accepted — and cannot be dismissed here.
  • Only an everyone suggestion can be accepted. A decision a tenant administrator recorded per organization before acceptances were deployment-level is never carried over: the suggestion is pending again.

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, direct grants
    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_user_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 as registry items stamped with the owning package (packageId), served from the security catalog — the boot writes no row for them, and an upgrade is served as soon as the package registers its new version; environment-authored sets are never clobbered. An environment write cannot take over a package's set (two-doors separation — an edit of a set shipped in code is refused; clone it instead, see below), which is what makes package uninstall well-defined — and enforced: uninstalling a package (DELETE /api/v1/packages/:id) takes its sets and positions out of the catalog, revokes every sys_user_permission_set grant naming one of its sets, and removes its audience-binding suggestions in the same request (no ghost grants); the uninstall response reports the revocation under data.persisted.cleanups (the dispatcher's /packages domain answers the route, and it nests the protocol's uninstall report under persisted). Environment-authored sets and other packages' rows survive. Under a group or isolated tenancy posture every package write is the platform operator's (ADR-0131 D3, D12 items 7–8): the delete, an install-local install, upgrade or uninstall, and every other /packages write verb refuse any other caller 403 before anything changes, whatever the package carries.

One authoritative store — the definition (ADR-0094 D1, ADR-0131 D3)

A permission-set definition has a single authoritative store: the metadata layer — a package's declaration, or a definition authored in this environment and saved in sys_metadata — served by name from the security catalog. Nothing mirrors it into a row: the sys_permission_set record ADR-0094 kept as a projection is retired, its object is no longer registered, and no data route serves it (ADR-0131 D3). Setup → Access Control → Permission Sets lists the catalog (/apps/setup/metadata/permission?scope=environment), a grant names its set by name (sys_user_permission_set.permission_set), and a write goes through the metadata door (PUT or DELETE /api/v1/meta/permission/:name), which requires manage_metadata — and, under a group or isolated tenancy posture, the platform operator. This has three author-visible consequences:

  • A set shipped in code cannot be edited in the environment. That is a set an installed package declares (a *.permission.ts file, a stack's permissionSets) or a platform default such as member_default. Editing it through Setup or the metadata API answers 403 NOT_OVERRIDABLE and stores nothing. Clone it to a new name instead (the Clone action on the set, or the clone door below) and edit the clone: the clone is your organization's own set, and package upgrades keep reaching the original. Every other set (one created in this environment, a clone, or one saved into a writable runtime package) is edited in place: the edit lands on its one stored definition.
  • The API name is immutable after creation. It is the definition's metadata identity, and the metadata door addresses the set by it, so there is no rename. Clone the set to a new name instead.
  • A delete 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: its definition stays served from the package. Uninstall the owning package to remove a packaged set entirely.

An older database keeps the sys_permission_set table and its rows, and nothing serves or reads them. os migrate organization-ownership --apply (ADR-0131 D10) converts the sets an organization authored in Setup into environment metadata under single and deletes the seeded copies.

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). Where a packaged set grants too much, clone it, narrow the clone, and bind the clone in its place: restriction is done by not granting. Narrowing the packaged set itself is the package author's change: they edit the declaration in the package and re-publish it (ADR-0086 two-doors).

Cloning a permission set (ADR-0126 §7.1)

POST /api/v1/security/permission-sets/:name/clone    body: { "name": "...", "label": "..." }

The clone door copies a set's whole definition under a new machine name. It reads the source from the security catalog, where every packaged and environment-authored set lives. It saves the copy through the metadata layer as a set authored in this environment: no package, and no link back to the set it was copied from, so changes to the source never reach the copy.

  • The body is { name, label }, both required, and nothing else. name is a new snake_case machine name: a name failing that rule answers 400, and a name any permission set already uses (the source's own included) answers 409 RESOURCE_CONFLICT. An unknown source answers 404.
  • Every permission facet is copied — objects, fields, systemPermissions, tabPermissions, rowLevelSecurity, description.
  • The server drops a declared set of keys: the package provenance (packageId, managedBy, protection and the internal protection envelope), and two privilege decisions — adminScope (delegated administration) and isDefault (the everyone suggestion). Grant either deliberately on the copy if it needs one. The response, { permissionSet, notice }, says what was not copied.
  • The copy is held by nobody until you add it to a position or assign it.
  • Who may call it is who may save a permission set through the metadata API: a holder of manage_metadata. Anyone else is refused 403 PERMISSION_DENIED. Under a group or isolated tenancy posture the clone is the platform operator's alone (ADR-0131 D3): a manage_metadata holder inside one organization is refused 403 PERMISSION_DENIED too.

Declared ≠ enforced — diagnosing a frozen package set

A package-declared set's enforced grants (what the security catalog serves and the evaluator resolves) can diverge from its shipped artifact (what the package currently declares) in one way:

  • Overlay shadow. An active sys_metadata overlay for the set's name (from a Studio save made while overlaying was still permitted, or through the OS_METADATA_WRITABLE escape hatch) answers ahead of the package's definition — the overlay wins forever, regardless of what the package ships next. No door creates such an overlay any more: the metadata door refuses a save over a packaged set (clone the set instead). The boot names every such set in its log. Remedy: delete the overlay row. From the release that adds ADR-0048's cold-boot check, a deployment that still holds one does not boot: os migrate security-catalog-overlays lists every such row with no server running, and --apply deletes them, after which the package's definition is served again.

See also

On this page