| title | Permission Sets |
|---|---|
| description | 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. |
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'],
});| Permission | Description |
|---|---|
allowCreate / allowRead / allowEdit / allowDelete |
CRUD on records the user can see |
allowExport |
Bulk data egress — an opt-in grant on top of read, see below |
allowTransfer / allowRestore / allowPurge |
Lifecycle class (RBAC-gated ahead of the M2 operations) |
viewAllRecords |
Read ALL records regardless of ownership (super-user read) |
modifyAllRecords |
Edit ALL records regardless of ownership (super-user write) |
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:
| Value | Meaning |
|---|---|
true |
Export granted — still bounded by read. |
| unset | No export. Read alone never confers it. |
false |
No 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: truewithout a read grant exports nothing —export = list ∧ allowExport. viewAllRecords/modifyAllRecordsdo 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-inadmin_full_accessandorganization_adminsets carryallowExport: trueexplicitly; remove that line to get the separation.member_defaultdeliberately 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/exportanswers403 EXPORT_NOT_PERMITTEDbefore it reads the first row.- A report rendered as
csvorjsonis the same bulk copy and is gated the same way, whether run interactively or delivered by a schedule.html_tableis 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.
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:
| Scope | Owner-match widens to |
|---|---|
own |
the caller (baseline; unset = this) |
own_and_reports |
the caller + their sys_user.manager_id report chain |
unit |
owners in the caller's business unit |
unit_and_below |
the caller's BU + all descendant BUs |
org |
the 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).
ownandorgare open-source. The hierarchy-relative scopes (own_and_reports/unit/unit_and_below) require the enterprise hierarchy resolver; without it they fail closed toown(never fail-open).
- Capabilities are first-class
sys_capabilityrecords — named privileges such asmanage_users,manage_metadata,setup.access,studio.access. Grant them viasystemPermissions; declare them as prerequisites via an object/field/apprequiredPermissions(an AND-gate checked before any CRUD grant). - The authoring lint
validateCapabilityReferenceswarns whenrequiredPermissionsnames a capability registered nowhere.
Auto-seeded on boot by plugin-security
(objects/default-permission-sets.ts):
| Name | Scope | What it unlocks | Notes |
|---|---|---|---|
admin_full_access |
Platform (no RLS) | All objects, all system permissions, Studio + Setup | The ADR-0066 superuser wildcard — the only legitimate '*' + View/Modify All combination (the D7 linter rejects it in authored packages) |
organization_admin |
Per-org (tenant-isolation RLS) | Wildcard CRUD inside the org, manage_org_users, Setup shell |
Read-only on the RBAC tables (anti-escalation); does not see Studio |
member_default |
Per-org | Standard end-user CRUD; writes owner-scoped via positions: ['org_member']-domained RLS |
The additive baseline — applies to every authenticated request in addition to explicit grants (ADR-0090 D5) |
viewer_readonly |
Per-org | Read access only | Auditors / read-only stakeholders |
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.
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,
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.
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 promptAll 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.
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.
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.
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.