Security Model Guide
Complete guide to implementing enterprise-grade security in ObjectStack with fine-grained permissions and data access controls
Security Model Guide
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. End-to-end verified onpnpm dev:crmacrosssys_organization,sys_member,sys_user,sys_user_permission_set,sys_role_permission_set. Anonymous traffic still bypasses enforcement until a default-deny pass lands; Sharing Rules, 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.
Table of Contents
- Security Architecture
- Profiles
- Permission Sets
- Role Hierarchy
- Sharing Rules
- Field-Level Security
- Best Practices
Security Architecture
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
└─────────────────────────────────────┘Security Layers
- 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
Profiles define a user's baseline permissions.
Profile Structure
import type { Profile } from '@objectstack/spec/security';
export const SalesRepProfile: Profile = {
name: 'sales_rep',
label: 'Sales Representative',
description: 'Standard sales rep permissions',
// Object-level permissions
objectPermissions: {
account: {
create: true,
read: true,
update: true,
delete: false,
viewAll: false, // See all records regardless of owner
modifyAll: false, // Edit all records regardless of owner
},
opportunity: {
create: true,
read: true,
update: true,
delete: false,
viewAll: false,
modifyAll: false,
},
},
// Field-level permissions
fieldPermissions: {
account: {
annual_revenue: { read: true, update: false },
description: { read: true, update: true },
},
},
// Tab visibility
tabVisibility: {
account: 'default', // Default tab
lead: 'default',
opportunity: 'default',
case: 'hidden', // Not visible
product: 'available', // Available but not default
},
// Application access
applicationVisibility: {
crm_example: true,
},
};Object Permission Levels
| Permission | Description |
|---|---|
create | Create new records |
read | View records they own or have access to |
update | Edit records they own or have access to |
delete | Delete records they own or have access to |
viewAll | View ALL records regardless of ownership |
modifyAll | Edit ALL records regardless of ownership |
Standard Profiles
Sales Representative
export const SalesRepProfile: Profile = {
name: 'sales_rep',
objectPermissions: {
lead: { create: true, read: true, update: true, delete: false },
account: { create: true, read: true, update: true, delete: false },
contact: { create: true, read: true, update: true, delete: false },
opportunity: { create: true, read: true, update: true, delete: false },
quote: { create: true, read: true, update: true, delete: false },
product: { create: false, read: true, update: false, delete: false },
},
fieldPermissions: {
account: {
annual_revenue: { read: true, update: false }, // Read-only
},
},
};Sales Manager
export const SalesManagerProfile: Profile = {
name: 'sales_manager',
objectPermissions: {
lead: {
create: true, read: true, update: true, delete: true,
viewAll: true, modifyAll: true
},
account: {
create: true, read: true, update: true, delete: true,
viewAll: true, modifyAll: true
},
// ... full access to sales objects
},
};Service Agent
export const ServiceAgentProfile: Profile = {
name: 'service_agent',
objectPermissions: {
case: { create: true, read: true, update: true, delete: false },
task: { create: true, read: true, update: true, delete: true },
account: { create: false, read: true, update: false, delete: false },
contact: { create: false, read: true, update: true, delete: false },
},
fieldPermissions: {
case: {
is_sla_violated: { read: true, update: false },
resolution_time_hours: { read: true, update: false },
},
},
};Permission Sets
Permission sets extend profile permissions without changing the profile.
import type { PermissionSet } from '@objectstack/spec/security';
export const AdvancedReportingPermissionSet: PermissionSet = {
name: 'advanced_reporting',
label: 'Advanced Reporting',
description: 'Additional permissions for advanced reporting',
objectPermissions: {
opportunity: {
viewAll: true, // Override profile restriction
},
account: {
viewAll: true,
},
},
fieldPermissions: {
opportunity: {
amount: { read: true },
probability: { read: true },
},
},
systemPermissions: {
runReports: true,
exportReports: true,
createDashboards: true,
},
};
export const BulkDataPermissionSet: PermissionSet = {
name: 'bulk_data_access',
label: 'Bulk Data Access',
description: 'Permissions for bulk data operations',
systemPermissions: {
bulkApiEnabled: true,
viewAllData: true,
},
};Assigning Permission Sets
// Assign to user
await assignPermissionSet({
userId: 'user123',
permissionSetName: 'advanced_reporting',
});
// Assign to multiple users
await assignPermissionSetToGroup({
permissionSetName: 'bulk_data_access',
userGroup: 'data_analysts',
});Role Hierarchy
Roles control record-level access through a hierarchy.
export const RoleHierarchy = {
name: 'crm_role_hierarchy',
label: 'CRM Role Hierarchy',
roles: [
// Top level
{
name: 'executive',
label: 'Executive',
parentRole: null,
},
// Sales hierarchy
{
name: 'sales_director',
label: 'Sales Director',
parentRole: 'executive',
},
{
name: 'sales_manager',
label: 'Sales Manager',
parentRole: 'sales_director',
},
{
name: 'sales_rep',
label: 'Sales Representative',
parentRole: 'sales_manager',
},
// Service hierarchy
{
name: 'service_director',
label: 'Service Director',
parentRole: 'executive',
},
{
name: 'service_manager',
label: 'Service Manager',
parentRole: 'service_director',
},
{
name: 'service_agent',
label: 'Service Agent',
parentRole: 'service_manager',
},
],
};How Role Hierarchy Works
Executive
/ \
Sales Director Service Director
| |
Sales Manager Service Manager
| |
Sales Rep Service AgentGrant 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
Sharing rules extend access beyond the role hierarchy.
Organization-Wide Defaults (OWD)
Set baseline access for all users:
export const OrganizationDefaults = {
lead: {
internalAccess: 'private', // Users see only their own records
externalAccess: 'private',
},
account: {
internalAccess: 'private',
externalAccess: 'private',
},
contact: {
internalAccess: 'controlled_by_parent', // Access controlled by Account
externalAccess: 'private',
},
opportunity: {
internalAccess: 'private',
externalAccess: 'private',
},
campaign: {
internalAccess: 'public_read_only', // All users can read
externalAccess: 'private',
},
product: {
internalAccess: 'public_read_only',
externalAccess: 'private',
},
};Access Levels
| Level | Description |
|---|---|
private | Owner only (+ role hierarchy) |
public_read_only | All users can read |
public_read_write | All users can read and edit |
controlled_by_parent | Controlled by parent object |
Criteria-Based Sharing Rules
Share records based on field criteria:
export const AccountTeamSharingRule: SharingRule = {
name: 'account_team_sharing',
label: 'Share Active Customers with Sales Team',
objectName: 'account',
type: 'criteria_based',
// Criteria: Which records to share
criteria: {
type: { $eq: 'customer' },
is_active: { $eq: true },
},
// Who to share with
sharedWith: {
type: 'role',
roles: ['sales_manager', 'sales_director'],
},
// Access level granted
accessLevel: 'read_write',
// Also share related records
includeRelatedObjects: [
{ objectName: 'contact', accessLevel: 'read_only' },
{ objectName: 'opportunity', accessLevel: 'read_only' },
],
};Owner-Based Sharing Rules
Share based on record owner characteristics:
export const OpportunityOwnerSharingRule: SharingRule = {
name: 'opportunity_owner_sharing',
label: 'Share Opportunities within Same Territory',
objectName: 'opportunity',
type: 'owner_based',
// Share records owned by users in these roles
ownedBy: {
type: 'role',
roles: ['sales_rep'],
},
// Share with users in these roles
sharedWith: {
type: 'role',
roles: ['sales_rep'],
sameTerritory: true, // Only same territory
},
accessLevel: 'read_only',
};Territory-Based Sharing
Share based on geographic territories:
export const TerritorySharingRules = [
{
name: 'north_america_territory',
label: 'North America Territory',
objectName: 'account',
type: 'territory_based',
criteria: {
billing_address: {
country: { $in: ['US', 'CA', 'MX'] },
},
},
sharedWith: {
type: 'territory',
territory: 'north_america',
},
accessLevel: 'read_write',
},
];Field-Level Security
Control visibility and editability of specific fields.
Field Permissions in Profiles
fieldPermissions: {
account: {
// Field-level permissions
annual_revenue: {
read: true, // Can view
update: false // Cannot edit
},
description: {
read: true,
update: true
},
ssn: {
read: false, // Hidden field
update: false
},
},
opportunity: {
amount: { read: true, update: true },
probability: { read: true, update: false },
},
}Hidden vs. Read-Only
// Hidden: Field not visible at all
{ read: false, update: false }
// Read-Only: Field visible but not editable
{ read: true, update: false }
// Editable: Field visible and editable
{ read: true, update: true }Server-side enforcement (fail-closed)
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.
Best Practices
1. Profile Design
✅ 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
2. Permission Sets
✅ 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
3. Role Hierarchy
✅ 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
4. Sharing Rules
✅ 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
5. Field-Level Security
✅ 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
Security Checklist
Initial Setup
- Define user roles and hierarchies
- Create profiles for each role
- Set organization-wide defaults
- Configure field-level security
- Create sharing rules
Ongoing Maintenance
- Review user access quarterly
- Audit permission changes
- Remove inactive users promptly
- Update sharing rules as needed
- Monitor security health checks
Compliance
- Document security model
- Maintain access request process
- Log security changes
- Conduct security reviews
- Train users on security policies
Real-World Example
Complete security setup for a sales team:
// 1. Organization-Wide Defaults
OrganizationDefaults = {
account: { internalAccess: 'private' },
opportunity: { internalAccess: 'private' },
contact: { internalAccess: 'controlled_by_parent' },
};
// 2. Role Hierarchy
RoleHierarchy = {
roles: [
{ name: 'sales_vp', parentRole: null },
{ name: 'sales_manager', parentRole: 'sales_vp' },
{ name: 'sales_rep', parentRole: 'sales_manager' },
],
};
// 3. Profiles
SalesRepProfile = {
objectPermissions: {
account: { create: true, read: true, update: true, delete: false },
opportunity: { create: true, read: true, update: true, delete: false },
},
fieldPermissions: {
account: {
annual_revenue: { read: true, update: false }, // Read-only
},
},
};
// 4. Sharing Rules
AccountSharingRule = {
// Share high-value accounts with all sales reps
criteria: { annual_revenue: { $gte: 1000000 } },
sharedWith: { type: 'role', roles: ['sales_rep'] },
accessLevel: 'read_only',
};
// 5. Permission Set
AdvancedReportingPermissionSet = {
// For sales analysts
systemPermissions: {
runReports: true,
exportReports: true,
viewAllData: true,
},
};Next: Automation →