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.
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
| 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 | Lifecycle 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) |
viewAllRecords | Read ALL records regardless of ownership (super-user read) |
modifyAllRecords | Edit 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:
| 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.
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:
| 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 & required permissions (ADR-0066)
- 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.
Built-in permission sets
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 |
Who holds admin_full_access is not (only) a grant row. PLATFORM_ADMIN
standing is derived at a single site from two anchors, and either one is
sufficient:
- 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 storedsys_userrow 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, soAda@Example.comandada@example.comare one administrator, not two half-matches. Nothing is stored and nothing is granted — the answer is recomputed from configuration on every resolution. - An unscoped
admin_full_accessgrant row — asys_user_permission_setrow withorganization_id = NULL, the original anchor. It still confers standing, and it is deprecated: a deployment resolving through it logs a pointer, once per process, at the configuration line that re-anchors it.
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
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,
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 organization × 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 promptstatus 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 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.
Declared ≠ enforced — diagnosing a frozen package set
A package-declared set's enforced grants (what sys_permission_set stores
and what the evaluator resolves) can diverge from its shipped artifact
(what the package currently declares) through two independent mechanisms —
either can be live on one row, and they need different remedies:
-
Overlay shadow. An active
sys_metadataoverlay for the set's name (from a Studio save made while overlaying was still permitted, or through theOS_METADATA_WRITABLEescape hatch) is re-projected onto the record on every boot, unconditionally — the overlay wins forever, regardless of what the package ships next. Remedy: the record's Discard Overlay Setup action (POST /api/v1/security/permission-sets/:id/discard-overlay, tenant-admin only) removes the stale overlay and resyncs the record to the current artifact immediately. It refuses on any set that is not currently package-declared, so it can never destroy a genuinely environment-authored set. -
Provenance skip. The record's
managed_bycolumn predates package provenance tracking (a legacy insert withoutmanaged_by: 'package'— typically a database first initialized on an older release line), so boot seeding treats it as environment-authored and never reconciles it with the package — permanently. There is currently no automated adoption path for this case (boot-time auto-adoption and a bulkos meta adopt-permission-setscommand were both deliberately deferred as migration machinery for a customer population that does not yet exist — see the discussion on the tracking issue for this section). The manual recovery is the same one-time SQL a field remediation used, run against the environment's database with a backup first:UPDATE sys_permission_set SET managed_by = 'package', package_id = '<owning package id>' WHERE name = '<permission set name>';Restart the environment afterward so boot seeding re-adopts the row as package-owned and reconciles its grants to the shipped artifact.
Both mechanisms are surfaced on the record itself: drift_status
('overlay_shadow' / 'provenance_skip') and drift_detail name the cause,
recomputed every boot, and the Needs Attention list view collects every
currently drifted set — an in-sync set is never listed there.