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
| 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. 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, neverallowExport.- 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).
A depth axis beside the super-user bit that bypasses it is REFUSED. The read-scope resolver answers
orgon 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:viewAllRecordsbypasses READ only, soreadScopebesideviewAllRecords: trueis refused whilewriteScopebeside it is accepted and honoured.modifyAllRecordsbypasses both, so bothreadScopeandwriteScopebeside 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 declaredcapabilitymetadata served by the registry (GET /api/v1/meta/capability); a capability a package declares withdefineCapabilityis served the same way, under its owning package. Neither has asys_capabilityrow. 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
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:
| 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. 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 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. This anchor is read under every posture. - An unscoped
admin_full_accessgrant row — asys_user_permission_setrow withorganization_id = NULL, the original anchor. It confers standing under thesingleposture 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 noPLATFORM_ADMINstanding, 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 atOS_PLATFORM_OWNER_EMAILas 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 acceptancestatus 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
everyoneanchor forbids is refused with403 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
isDefaultset) is markedconfirmedwithout a resolver — observed, not accepted — and cannot be dismissed here. - Only an
everyonesuggestion 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.tsfile, a stack'spermissionSets) or a platform default such asmember_default. Editing it through Setup or the metadata API answers403 NOT_OVERRIDABLEand 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.nameis a new snake_case machine name: a name failing that rule answers400, and a name any permission set already uses (the source's own included) answers409RESOURCE_CONFLICT. An unknown source answers404. - Every permission facet is copied —
objects,fields,systemPermissions,tabPermissions,rowLevelSecurity,description. - The server drops a declared set of keys: the package provenance
(
packageId,managedBy,protectionand the internal protection envelope), and two privilege decisions —adminScope(delegated administration) andisDefault(theeveryonesuggestion). 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 refused403PERMISSION_DENIED. Under agrouporisolatedtenancy posture the clone is the platform operator's alone (ADR-0131 D3): amanage_metadataholder inside one organization is refused403PERMISSION_DENIEDtoo.
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_metadataoverlay for the set's name (from a Studio save made while overlaying was still permitted, or through theOS_METADATA_WRITABLEescape 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-overlayslists every such row with no server running, and--applydeletes them, after which the package's definition is served again.
See also
Profiles (removed)
Profiles were removed. Author baseline access with the everyone audience anchor, package isDefault suggestions and permission sets given via positions.
Positions
Positions are flat groups that hand permission sets to users — the built-in identity positions, the everyone and guest anchors, and where hierarchy lives.