Grant CRUD access per object with boolean bits on a permission set:
import { definePermissionSet } from '@objectstack/spec';
export const salesUser = definePermissionSet({
name: 'sales_user',
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true },
contact: { allowRead: true },
},
});
// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })- Stack key:
permissions. The collection is named for the metadata kind, not for the factory, sodefinePermissionSet()output goes intodefineStack({ permissions: [...] }).permissionSets:is refused at load — the top level is strict, so the stack fails with anUnrecognized key(s) on this stack definitionerror naming the key, never a silent drop.ObjectStackDefinitionSchema(node_modules/@objectstack/spec/src/stack.zod.ts) is the enumeration of record;objectstack-platformlists every top-level key. - Bits:
allowCreate/allowRead/allowEdit/allowDelete, plusallowTransfer(ownership change),viewAllRecords/modifyAllRecords(super-user, bypass sharing). - Source:
node_modules/@objectstack/spec/src/security/permission.zod.ts - Combine with
enable.apiMethodsto also restrict the HTTP surface.
Declaring a set grants nobody anything — an assignment is data: one row in
the join object sys_user_permission_set (@objectstack/plugin-security),
carrying user_id, permission_set_id, and an optional organization_id
(null = every org context). Optional valid_from / valid_until bound a
half-open window checked at resolution time; granted_by is stamped by the
gate on insert — never author it.
permission_set_id takes the sys_permission_set RECORD ID, not the set's
name. Grants resolve by loading sys_permission_set by id, so a name
in that field matches nothing, raises no error, and silently grants nothing.
Declared sets are upserted by name with a generated id on kernel:ready
(ADR-0086 D5) — that id differs per environment, so resolve it first.
Assignment is therefore two calls, both POST /api/v1/data/{object}
(…/query with a QueryAST body for the read): look up the set's id in
sys_permission_set by name, then insert
{ user_id, permission_set_id, organization_id } into
sys_user_permission_set. Only a tenant admin — or a delegated adminScope
carrying manageAssignments for that set and user (ADR-0090 D12) — may write
it; plain CRUD bits on the table are not enough.
Grant looks inert? Check in order: a name in permission_set_id; the set
is active: false; the validity window has passed; organization_id mismatch.
GET /api/v1/security/explain?object=&operation=&userId= answers from the
enforcing code path (explaining another user needs manage_users).
The enforced RLS surface is a list of rowLevelSecurity policies on a
permission set / profile (PermissionSetSchema.rowLevelSecurity), not a
CEL predicate on the object. Each policy carries a using (read filter) and/or
check (write filter) string predicate. The compiler ANDs using into
every read for users carrying that set; check gates writes. (@objectstack/plugin-security
re-reads the target row through the write filter before single-id update/delete.)
// in a permission set (definePermissionSet)
rowLevelSecurity: [
{
name: 'own_records',
object: 'account', // REQUIRED per policy
operation: 'all', // singular: select|insert|update|delete|all
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
object: 'contact',
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
]Predicates are canonical CEL (ADR-0058): field == current_user.<prop>,
field == 'literal', field in current_user.<array>, comparisons (>/</>=/<=),
&&/||/!, and == null checks all lower to a pushdown filter. No cross-object
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
A legacy SQL-style = / IN (...) predicate still compiles via a deprecated bridge
(emits a warning) but should be authored in CEL. The compiler resolves these
current_user.* placeholders:
| Placeholder | Resolves to |
|---|---|
current_user.id |
the caller's user id (ownership) |
current_user.email |
the caller's email (ADR-0056) |
current_user.organization_id |
the caller's tenant |
current_user.org_user_ids |
ids of users in the same org (for IN) |
current_user.positions |
the caller's positions (for IN; ADR-0090 D3) |
- Source:
node_modules/@objectstack/spec/src/security/permission.zod.ts(policy shape),node_modules/@objectstack/spec/src/security/rls.zod.ts(predicate grammar). - Owner-scoping shortcut: the built-in
member_defaultset already owner-scopes writes viaowner_only_writes/owner_only_deletes, and an object'ssharingModel(ADR-0056 D1) is the declarative way to set the org-wide default — prefer those over hand-written policies for the common cases.
maskingRule is live (plugin-security's FieldMasker enforces it). The real
channels are:
Encrypted-at-rest values — type: 'secret' (ADR-0100). For reversible
machine credentials (DB passwords, API keys, tokens): the engine encrypts the
value on write via the registered ICryptoProvider, stores the ciphertext
handle in sys_secret, persists only an opaque ref on the row, and masks the
value on read. Fail-closed: with no crypto provider registered, writes
throw rather than persist cleartext.
fields: {
api_key: { type: 'secret', label: 'API Key' },
}Per-field access gating — requiredPermissions (ADR-0066 D3). Capabilities
required to READ/EDIT the field. A field declaring requiredPermissions is
masked on read and denied on write unless the caller holds ALL listed
capabilities — an AND-gate that is strictest-wins over permission-set field
grants. Enforced by plugin-security's FieldMasker.
fields: {
ssn: {
type: 'text',
requiredPermissions: ['view_pii'], // mask on read / deny on write without it
},
}- Source:
node_modules/@objectstack/spec/src/data/field.zod.ts(secretfield type,requiredPermissions)
For SaaS, set tenancy on the object schema for row-level tenant isolation
(the tenant field is injected on write and enforced on read). The block is
strict — exactly two keys:
tenancy: {
enabled: true, // enable row-level tenant isolation
// tenantField — NO default; omit it and the driver uses `organization_id`
}- Database-per-tenant isolation is not object metadata — it is an environment/deployment choice (each environment carries its own database URL).
Some system/config objects are env-global (not partitioned per org) and
should be visible to a platform admin env-wide but hidden from members —
e.g. identity tables a plugin writes via its own adapter (sys_sso_provider,
OAuth clients). These hit a non-obvious interaction:
- Reads of a tenant object pass the Layer 0 tenant wall (ADR-0095 D1): an
organization_id == <the caller's organization>filter AND-composed ahead of every business RLS policy. Any row whoseorganization_idis null or absent (common for adapter-written rows that never get the tenant stamp) is denied — the list renders empty. Single-tenant deployments never hit this; the wall is inert there. - The
viewAllRecordssuperuser bit is posture-gated and wall-blind: it short-circuits business RLS only, and only on objects whose posture allows it (access.default: 'private',tenancy: { enabled: false }, or a better-auth-managed identity table). It never crosses the Layer 0 wall — crossing takes a true platform admin (the superuser bit and a platform-exclusive capability:manage_metadata,manage_platform_settings,studio.access,manage_users) on one of those same postures. So an org admin holding the superuser bit stays org-scoped, and on an ordinary tenant object nobody crosses — the admin sees 0 rows too.
Recipe — env-global, admin-only object that admins can fully see:
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
⚠️ Both keys are load-bearing — neither works alone.tenancy: { enabled: false }by itself switches the wall off for every caller, and any permission set carrying a wildcard ('*') read grant then reads every row env-wide — the shippedviewer_readonlystill carries one, as may an app-declared default profile or a customer-authored set. (Themember_defaultbaseline is not one of them: it is explicit-allow and grants only the objects it names.)requiredPermissionsby itself leaves the object a tenant object, so the wall keeps denying the untagged rows and even a platform admin sees nothing. The pair is the correct combo (admin sees all, non-admins 403), andrequiredPermissionsis the half that holds however permissive the caller's grants are — it is an AND-gate checked before the CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.