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
| Permission | Description |
|---|---|
allowCreate / allowRead / allowEdit / allowDelete | CRUD on records the user can see |
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) |
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 |
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 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.
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.