---
title: "Security Model Guide"
description: "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 default `member_default` permission set ships a wildcard RLS rule `organization_id = current_user.organization_id` plus explicit per-object overrides `sys_organization_self` (`id = current_user.organization_id`) and `sys_user_self` (`id = current_user.id`) for the two global tables that lack an `organization_id` column. RLS expressions, the physical column, and `RLSUserContext.organization_id` all use the same canonical name — there is no `tenantField` rewrite indirection (schemas with a different physical tenant column should fork the defaults). The legacy `objectql.registerTenantMiddleware` has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics also reuses the same read scope: `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` when the security service is registered, so dataset-bound dashboards/reports do not bypass RLS. End-to-end verified on `pnpm dev:crm` across `sys_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. See `CHANGELOG.md` and `concepts/implementation-status.mdx` for the latest matrix.

## Table of Contents

1. [Security Architecture](#security-architecture)
2. [Profiles](#profiles)
3. [Permission Sets](#permission-sets)
4. [Role Hierarchy](#role-hierarchy)
5. [Sharing Rules](#sharing-rules)
6. [Field-Level Security](#field-level-security)
7. [Best Practices](#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

1. **Object Permissions**: Control create, read, update, delete on entire objects
2. **Record-Level Security**: Control access to specific records
3. **Field-Level Security**: Control visibility and editability of fields
4. **Row-Level Security**: Control access based on data values

---

## Profiles

Profiles define a user's baseline permissions.

### Profile Structure

```typescript
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

```typescript
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

```typescript
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

```typescript
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.

```typescript
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,
  },
};
```

### Built-in Permission Sets

The framework ships four canonical permission sets that are auto-seeded on
boot by `plugin-security` (see
`packages/platform-objects/src/security/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 [`plugin-org-scoping`](#objectstackplugin-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 | Default profile for invited members. |
| `viewer_readonly` | Per-org | Read access only | For auditors / read-only stakeholders. |

> **Single-org vs. multi-org runtimes.** The wildcard `tenant_isolation`
> RLS row above only takes effect when `@objectstack/plugin-org-scoping`
> is registered (typically via `OS_MULTI_TENANT=true`). In single-org
> mode `plugin-security` detects the missing `org-scoping` service and
> strips the wildcard policy so reads / writes aren't accidentally
> filtered by an unset `current_user.organization_id`. See the
> [Org-Scoping Plugin README](/packages/plugins/plugin-org-scoping/README.md)
> for boot order and per-org seed-replay details.

#### Auto-grant for organization admins

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 app gating

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.

### Assigning Permission Sets

```typescript
// 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.

```typescript
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 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

Sharing rules extend access beyond the role hierarchy.

### Organization-Wide Defaults (OWD)

Set baseline access for all users:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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',
  },
];
```

### Analytics and Dataset Read Scope

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.

---

## Field-Level Security

Control visibility and editability of specific fields.

### Field Permissions in Profiles

```typescript
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

```typescript
// 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`:

```json
{
  "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:

```typescript
// 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 →](./automation)
