| title | Security Model Guide |
|---|---|
| description | Complete guide to implementing enterprise-grade security in ObjectStack with fine-grained permissions and data access controls |
Complete guide to implementing enterprise-grade security in ObjectStack with fine-grained permissions and data access controls.
Implementation status — Phase-1 RBAC is live. REST → ObjectQL now propagates a populated
ExecutionContext(userId, tenantId, roles, permissions) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks actually fire on every authenticated request. The defaultmember_defaultpermission set ships a wildcard RLS ruleorganization_id == current_user.organization_idplus explicit per-object overridessys_organization_self(id == current_user.organization_id) andsys_user_self(id == current_user.id) for the two global tables that lack anorganization_idcolumn. RLS expressions, the physical column, andRLSUserContext.organization_idall use the same canonical name — there is notenantFieldrewrite indirection (schemas with a different physical tenant column should fork the defaults). The legacyobjectql.registerTenantMiddlewarehas been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics also reuses the same read scope:@objectstack/service-analyticsauto-bridges tosecurity.getReadFilter(object, context)when the security service is registered, so dataset-bound dashboards/reports do not bypass RLS. End-to-end verified onpnpm dev:crmacrosssys_organization,sys_member,sys_user,sys_user_permission_set,sys_role_permission_set. Anonymous traffic still falls open by default (the default-deny flip is release-gated); since ADR-0056 D2 the server logs a boot-time warning whenrequireAuthis unset, and public forms self-authorize via a declaration-derivedpublicFormGrant(ADR-0056 Option A — see Public Forms). Organization-Wide Defaults (private/public_read/public_read_write/controlled_by_parent) and Sharing Rules (owner + criteria, withrole_and_subordinateshierarchy widening) are live and dogfood-proven (ADR-0056); the Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. SeeCHANGELOG.mdandconcepts/implementation-status.mdxfor the latest matrix.
- Security Architecture
- Profiles
- Permission Sets
- Role Hierarchy
- Sharing Rules
- Field-Level Security
- Best Practices
ObjectStack implements a multi-layered security model:
┌─────────────────────────────────────┐
│ Application Visibility │ ← Which apps can users access?
├─────────────────────────────────────┤
│ Object Permissions │ ← CRUD on objects
├─────────────────────────────────────┤
│ Record-Level Security │ ← Which records can be accessed?
│ - Organization-Wide Defaults │
│ - Role Hierarchy │
│ - Sharing Rules │
│ - Manual Sharing │
├─────────────────────────────────────┤
│ Field-Level Security │ ← Read/Write specific fields
└─────────────────────────────────────┘
- Object Permissions: Control create, read, update, delete on entire objects
- Record-Level Security: Control access to specific records
- Field-Level Security: Control visibility and editability of fields
- Row-Level Security: Control access based on data values
Profiles define a user's baseline permissions.
A profile is modelled as a PermissionSet with isProfile: true (see
packages/spec/src/security/permission.zod.ts). There is no distinct
Profile type — author it as a *.profile.ts metadata entry validated by
PermissionSetSchema.
import { definePermissionSet } from '@objectstack/spec/security';
export const SalesRepProfile = definePermissionSet({
name: 'sales_rep',
label: 'Sales Representative',
isProfile: true,
// Object-level permissions: <object> -> permissions
objects: {
account: {
allowCreate: true,
allowRead: true,
allowEdit: true,
allowDelete: false,
viewAllRecords: false, // See all records regardless of owner
modifyAllRecords: false, // Edit all records regardless of owner
},
opportunity: {
allowCreate: true,
allowRead: true,
allowEdit: true,
allowDelete: false,
viewAllRecords: false,
modifyAllRecords: false,
},
},
// Field-level security: <object>.<field> -> permissions
fields: {
'account.annual_revenue': { readable: true, editable: false },
'account.description': { readable: true, editable: true },
},
// Tab/app visibility: <app or tab name> -> visibility
tabPermissions: {
app_crm: 'default_on', // Shown by default
app_admin: 'hidden', // Not visible
app_sales: 'visible', // Available
},
});| Permission | Description |
|---|---|
allowCreate |
Create new records |
allowRead |
View records they own or have access to |
allowEdit |
Edit records they own or have access to |
allowDelete |
Delete records they own or have access to |
allowTransfer |
Change record ownership |
allowRestore |
Restore soft-deleted records from trash (undelete) |
allowPurge |
Permanently delete records (hard delete / GDPR) |
viewAllRecords |
View ALL records regardless of ownership |
modifyAllRecords |
Edit ALL records regardless of ownership |
Beyond the boolean grants above, an owner-scoped object grant can carry a
readScope / writeScope that widens the owner-match declaratively — the
ERP "see my own / my reports / my unit / my unit and below / the whole org" axis,
without hand-writing one RLS policy per object:
| 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 (sys_business_unit) |
unit_and_below |
the caller's BU + all descendant BUs (BFS) |
org |
the whole tenant (≈ viewAllRecords / modifyAllRecords) |
objects: {
account: { allowRead: true, allowEdit: true, readScope: 'unit_and_below', writeScope: 'own' },
}It resolves to an owner_id IN (…) set at request time and AND-injects alongside
RLS (no compiler change; ADR-0055). Sharing rules still widen on top.
Open-core boundary (ADR-0016).
ownandorgare open-source. The hierarchy-relative scopes (own_and_reports/unit/unit_and_below) require the paid@objectstack/security-enterpriseplugin (BU-subtree + manager-chain resolver); without it they fail closed toown(never fail-open), anddefineStackerrors if a grant uses one withoutrequires: ['hierarchy-security'].
import { definePermissionSet } from '@objectstack/spec/security';
export const SalesRepProfile = definePermissionSet({
name: 'sales_rep',
isProfile: true,
objects: {
lead: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
contact: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
opportunity: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
quote: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
product: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false },
},
fields: {
'account.annual_revenue': { readable: true, editable: false }, // Read-only
},
});import { definePermissionSet } from '@objectstack/spec/security';
export const SalesManagerProfile = definePermissionSet({
name: 'sales_manager',
isProfile: true,
objects: {
lead: {
allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true,
viewAllRecords: true, modifyAllRecords: true
},
account: {
allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true,
viewAllRecords: true, modifyAllRecords: true
},
// ... full access to sales objects
},
});import { definePermissionSet } from '@objectstack/spec/security';
export const ServiceAgentProfile = definePermissionSet({
name: 'service_agent',
isProfile: true,
objects: {
case: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
task: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true },
account: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false },
contact: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false },
},
fields: {
'case.is_sla_violated': { readable: true, editable: false },
'case.resolution_time_hours': { readable: true, editable: false },
},
});Permission sets extend profile permissions without changing the profile.
import { definePermissionSet } from '@objectstack/spec/security';
export const AdvancedReportingPermissionSet = definePermissionSet({
name: 'advanced_reporting',
label: 'Advanced Reporting',
objects: {
opportunity: {
viewAllRecords: true, // Override profile restriction
},
account: {
viewAllRecords: true,
},
},
fields: {
'opportunity.amount': { readable: true },
'opportunity.probability': { readable: true },
},
// System permissions are a flat array of capability strings
systemPermissions: ['run_reports', 'export_reports', 'create_dashboards'],
});
export const BulkDataPermissionSet = definePermissionSet({
name: 'bulk_data_access',
label: 'Bulk Data Access',
objects: {},
systemPermissions: ['bulk_api', 'view_all_data'],
});The framework ships four canonical permission sets that are auto-seeded on
boot by plugin-security (see
packages/plugins/plugin-security/src/objects/default-permission-sets.ts). Use
these as the baseline for any new org — tier custom sets on top rather
than redefining the basics.
| Name | Scope | What it unlocks | Notes |
|---|---|---|---|
admin_full_access |
Platform (no RLS) | All objects, all system permissions, Studio + Setup, cross-org reads | Reserved for platform operators. Auto-granted to the bootstrap admin (bootstrapPlatformAdmin) — the first registered human. The non-loginable seed-data identity usr_system (role system, provisioned before the first sign-up to own seeded rows) is deliberately skipped, so the real admin wins the promotion. |
organization_admin |
Per-org (tenant_isolation RLS, only active when @objectstack/plugin-org-scoping is loaded) |
Wildcard CRUD inside the org, manage_org_users, Setup app shell (only org-scoped entries visible), invite members, manage roles assignment |
Read-only on sys_role, sys_permission_set, sys_role_permission_set, sys_user_permission_set, sys_user_role to prevent self-elevation. Does not see Studio. |
member_default |
Per-org | Standard end-user CRUD on org records; writes are owner-scoped | Default profile for invited members. |
viewer_readonly |
Per-org | Read access only | For auditors / read-only stakeholders. |
Owner-scoped writes.
member_defaultgrants read/create on org records but restricts edit and delete to the records the user created — via theowner_only_writes/owner_only_deletesRLS policies, keyed oncreated_by(the ownership column the engine stamps on every row). These are enforced on single-idupdate/deleteby a pre-image authorization check: before the mutation the middleware re-reads the target row through the write-operation RLS filter, and a member attempting to modify another user's record gets403. A permission set withmodifyAllRecords(or no RLS, e.g.admin_full_access) bypasses this. An object that opts out of audit fields (systemFields.audit: false, nocreated_by) fails closed for member writes — grantmodifyAllRecordsor a per-object policy, or model transferable ownership with a dedicated owner field + custom policy.
Single-org vs. multi-org runtimes. The wildcard
tenant_isolationRLS row above only takes effect when@objectstack/plugin-org-scopingis registered (typically viaOS_MULTI_TENANT=true). In single-org modeplugin-securitydetects the missingorg-scopingservice and strips the wildcard policy so reads / writes aren't accidentally filtered by an unsetcurrent_user.organization_id. See the Org-Scoping Plugin README for boot order and per-org seed-replay details.
Any user whose sys_member.role is owner, admin, or comma-set
"owner,admin" automatically receives an org-scoped grant of
organization_admin (one sys_user_permission_set row per (user, org)
pair). The grant is reconciled by a lifecycle hook in plugin-security
on every sys_member insert / update / delete, and a kernel:ready
backfill sweep cleans up orphans on boot. Demoting a member to member
revokes the grant in the same transaction; you do not manage these rows
by hand.
Setup nav items and the corresponding global settings manifests
(ai, knowledge, mail, storage) require the new
manage_platform_settings system permission, which is granted only to
admin_full_access. organization_admin sees the Setup shell —
Apps, People & Organization, Roles (read-only) — but not platform-wide
configuration. Tenant-scoped settings (branding, feature_flags)
remain gated by setup.access so org admins can manage them.
Permission-set grants are plain data rows, not helper-function calls. Assign a
set to a user by inserting a sys_user_permission_set row, or to a role by
inserting a sys_role_permission_set row:
// Grant a permission set to a single user
await objectql.object('sys_user_permission_set').insert({
user_id: 'user123',
permission_set: 'advanced_reporting',
});
// Grant a permission set to everyone in a role
await objectql.object('sys_role_permission_set').insert({
role: 'data_analysts',
permission_set: 'bulk_data_access',
});Org-admin grants are reconciled automatically by a plugin-security lifecycle
hook (see Auto-grant for organization admins)
— you do not insert those rows by hand.
Roles control record-level access through a hierarchy.
The hierarchy is derived from the parent link on each role — there is no
RoleHierarchy container schema. Author roles as individual *.role.ts
entries (each matching RoleSchema), using parent to point at the role they
report to:
import type { Role } from '@objectstack/spec/identity';
export const roles: Role[] = [
// Top level
{ name: 'executive', label: 'Executive' },
// Sales hierarchy
{ name: 'sales_director', label: 'Sales Director', parent: 'executive' },
{ name: 'sales_manager', label: 'Sales Manager', parent: 'sales_director' },
{ name: 'sales_rep', label: 'Sales Representative', parent: 'sales_manager' },
// Service hierarchy
{ name: 'service_director', label: 'Service Director', parent: 'executive' },
{ name: 'service_manager', label: 'Service Manager', parent: 'service_director' },
{ name: 'service_agent', label: 'Service Agent', parent: 'service_manager' },
]; Executive
/ \
Sales Director Service Director
| |
Sales Manager Service Manager
| |
Sales Rep Service Agent
Grant Access UP: Users see records owned by:
- Themselves
- Their subordinates
- Their subordinates' subordinates (all levels down)
Example: Sales Director sees all records owned by Sales Managers and Sales Reps.
Sharing rules extend access beyond the role hierarchy.
Set baseline access for all users:
Each object's OWD is a single value from the OWDModel enum (private,
public_read, public_read_write, controlled_by_parent):
export const OrganizationDefaults = {
lead: 'private', // Users see only their own records
account: 'private',
contact: 'controlled_by_parent', // Access controlled by parent (Account)
opportunity: 'private',
campaign: 'public_read', // All users can read
product: 'public_read',
};| Level | Description |
|---|---|
private |
Owner only (+ role hierarchy) |
public_read |
All users can read |
public_read_write |
All users can read and edit |
controlled_by_parent |
Controlled by parent object |
Share records based on field criteria:
import { defineSharingRule } from '@objectstack/spec/security';
export const AccountTeamSharingRule = defineSharingRule({
name: 'account_team_sharing',
label: 'Share Active Customers with Sales Team',
type: 'criteria',
object: 'account',
// Predicate (CEL): which records to share
condition: P`record.type == "customer" && record.is_active == true`,
// Who to share with (a single recipient — see the recipient types below)
sharedWith: {
type: 'role',
value: 'sales_manager',
},
// Access level granted: read | edit | full
accessLevel: 'edit',
});sharedWith (and ownedBy) accept a { type, value } recipient. The supported
type values are:
type |
Shares with |
|---|---|
user |
A single user |
group |
All members of a public group |
role |
Everyone assigned that role |
role_and_subordinates |
Everyone in that role and every role below it in the hierarchy (ADR-0056 D6) — configurable per rule, so one rule can cascade down a branch of the org chart |
// Share with a sales manager AND everyone reporting up to them.
sharedWith: { type: 'role_and_subordinates', value: 'sales_manager' },The recipient set is expanded by @objectstack/plugin-sharing when the rule is
evaluated (afterInsert / afterUpdate); role_and_subordinates walks the
sys_role.parent graph (cycle-safe).
Share based on record owner characteristics:
import { defineSharingRule } from '@objectstack/spec/security';
export const OpportunityOwnerSharingRule = defineSharingRule({
name: 'opportunity_owner_sharing',
label: 'Share Sales Rep Opportunities with Managers',
type: 'owner',
object: 'opportunity',
// Share records owned by this group/role
ownedBy: {
type: 'role',
value: 'sales_rep',
},
// Share with this recipient
sharedWith: {
type: 'role',
value: 'sales_manager',
},
accessLevel: 'read',
});Dataset-bound dashboards and reports often execute through the analytics
service, including the NativeSQL strategy. That path still has to honor the
same row-level security rules as ObjectQL. @objectstack/plugin-security
therefore registers a public security service with
getReadFilter(object, context), and @objectstack/service-analytics
auto-bridges to it unless an explicit getReadScope option is supplied.
The bridge is context-aware and may be async because permission-set and RLS resolution can hit the database. The analytics service applies the returned filter to the base object and to joined objects declared by a dataset. If read scope resolution fails or an unsupported predicate would have to be translated into SQL, the analytics query fails closed instead of dropping the filter.
Control visibility and editability of specific fields.
Field permissions are keyed <object>.<field> and use readable / editable:
fields: {
// Read-only: visible but not editable
'account.annual_revenue': { readable: true, editable: false },
'account.description': { readable: true, editable: true },
// Hidden: not visible at all
'account.ssn': { readable: false, editable: false },
'opportunity.amount': { readable: true, editable: true },
'opportunity.probability': { readable: true, editable: false },
}Hidden vs. Read-Only
// Hidden: Field not visible at all
{ readable: false, editable: false }
// Read-Only: Field visible but not editable
{ readable: true, editable: false }
// Editable: Field visible and editable
{ readable: true, editable: true }The client-side ObjectForm / inline grid hides non-editable fields from the UI — but that is a UX layer only. The SecurityPlugin middleware enforces field-level write rules on the server, regardless of how the request arrived (REST, GraphQL, raw ObjectQL call).
On read — find / findOne results have non-readable fields
stripped from every record before the response leaves the engine.
On write — insert / update requests are checked before the
operation reaches the driver. If the payload contains any field the
caller is not permitted to edit, the engine throws
PermissionDeniedError (mapped to HTTP 403) with the offending field
names in details.forbiddenFields:
{
"error": {
"code": "PERMISSION_DENIED",
"message": "[Security] Field write denied: not permitted to edit [salary, ssn] on 'employee'",
"details": {
"operation": "insert",
"object": "employee",
"forbiddenFields": ["salary", "ssn"]
}
}
}Why throw instead of silently stripping? Silent strip hides the security boundary from honest clients (their update "doesn't save" and they cannot tell why) AND gives a probing client no signal that the field exists. Throwing makes the boundary observable in both directions — legitimate UIs get an actionable error to fix; probing clients learn nothing they could not already infer.
Allow-list semantics. Fields without an explicit rule pass through untouched. Permission sets only constrain fields they explicitly enumerate.
Bulk inserts. Arrays are checked row-by-row; a single offending field in any row rejects the whole batch atomically.
System operations. ExecutionContext { isSystem: true } bypasses
the check entirely — used for migrations, seed loading, and audit log
writes.
✅ DO:
- Create minimal profiles (start restrictive, extend with permission sets)
- Use descriptive names
- Document the purpose of each profile
- Assign one profile per user
❌ DON'T:
- Create too many profiles
- Give excessive permissions "just in case"
- Mix unrelated permissions in one profile
✅ DO:
- Use for temporary or special access
- Group related permissions
- Name clearly (e.g., "Advanced Reporting", "Bulk Data Access")
- Remove when no longer needed
❌ DON'T:
- Use as a replacement for profiles
- Grant system-wide permissions unnecessarily
✅ DO:
- Mirror your organizational structure
- Keep hierarchies simple
- Document reporting relationships
- Review regularly
❌ DON'T:
- Create overly deep hierarchies
- Mix different org structures
- Grant too much upward access
✅ DO:
- Start with most restrictive OWD
- Use sharing rules to open up access
- Document business justification
- Test thoroughly
❌ DON'T:
- Set OWD to Public unless necessary
- Create redundant sharing rules
- Grant more access than needed
✅ DO:
- Protect sensitive data (SSN, salary, etc.)
- Use read-only for calculated fields
- Document security classifications
- Audit regularly
❌ DON'T:
- Hide required fields
- Restrict access unnecessarily
- Forget about API access
- [ ] Define user roles and hierarchies
- [ ] Create profiles for each role
- [ ] Set organization-wide defaults
- [ ] Configure field-level security
- [ ] Create sharing rules
- [ ] Review user access quarterly
- [ ] Audit permission changes
- [ ] Remove inactive users promptly
- [ ] Update sharing rules as needed
- [ ] Monitor security health checks
- [ ] Document security model
- [ ] Maintain access request process
- [ ] Log security changes
- [ ] Conduct security reviews
- [ ] Train users on security policies
Complete security setup for a sales team:
// 1. Organization-Wide Defaults (one OWDModel value per object)
const OrganizationDefaults = {
account: 'private',
opportunity: 'private',
contact: 'controlled_by_parent',
};
// 2. Role Hierarchy (flat roles linked by `parent`)
const roles = [
{ name: 'sales_vp', label: 'Sales VP' },
{ name: 'sales_manager', label: 'Sales Manager', parent: 'sales_vp' },
{ name: 'sales_rep', label: 'Sales Rep', parent: 'sales_manager' },
];
// 3. Profile (a PermissionSet with isProfile: true)
const SalesRepProfile = {
name: 'sales_rep',
isProfile: true,
objects: {
account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
opportunity: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
},
fields: {
'account.annual_revenue': { readable: true, editable: false }, // Read-only
},
};
// 4. Sharing Rule (criteria type with a CEL condition)
const AccountSharingRule = {
name: 'high_value_accounts',
type: 'criteria',
object: 'account',
// Share high-value accounts with sales reps
condition: P`record.annual_revenue >= 1000000`,
sharedWith: { type: 'role', value: 'sales_rep' },
accessLevel: 'read',
};
// 5. Permission Set (systemPermissions is a string array)
const AdvancedReportingPermissionSet = {
name: 'advanced_reporting',
objects: {},
// For sales analysts
systemPermissions: ['run_reports', 'export_reports', 'view_all_data'],
};Next: Automation →