Skip to content

Latest commit

 

History

History
711 lines (561 loc) · 25 KB

File metadata and controls

711 lines (561 loc) · 25 KB
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 falls open by default (the default-deny flip is release-gated); since ADR-0056 D2 the server logs a boot-time warning when requireAuth is unset, and public forms self-authorize via a declaration-derived publicFormGrant (ADR-0056 Option A — see Public Forms). Organization-Wide Defaults (private / public_read / public_read_write / controlled_by_parent) and Sharing Rules (owner + criteria, with role_and_subordinates hierarchy 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. See CHANGELOG.md and concepts/implementation-status.mdx for the latest matrix.

Table of Contents

  1. Security Architecture
  2. Profiles
  3. Permission Sets
  4. Role Hierarchy
  5. Sharing Rules
  6. Field-Level Security
  7. 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

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 type { PermissionSet } from '@objectstack/spec/security';

export const SalesRepProfile: PermissionSet = {
  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
  },
};

Object Permission Levels

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

Standard Profiles

Sales Representative

export const SalesRepProfile: PermissionSet = {
  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
  },
};

Sales Manager

export const SalesManagerProfile: PermissionSet = {
  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
  },
};

Service Agent

export const ServiceAgentProfile: PermissionSet = {
  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

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',

  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: PermissionSet = {
  name: 'bulk_data_access',
  label: 'Bulk Data Access',

  objects: {},
  systemPermissions: ['bulk_api', 'view_all_data'],
};

Built-in Permission Sets

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_default grants read/create on org records but restricts edit and delete to the records the user created — via the owner_only_writes / owner_only_deletes RLS policies, keyed on created_by (the ownership column the engine stamps on every row). These are enforced on single-id update/delete by 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 gets 403. A permission set with modifyAllRecords (or no RLS, e.g. admin_full_access) bypasses this. An object that opts out of audit fields (systemFields.audit: false, no created_by) fails closed for member writes — grant modifyAllRecords or a per-object policy, or model transferable ownership with a dedicated owner field + custom policy.

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

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.


Role Hierarchy

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

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:

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

Access Levels

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

Criteria-Based Sharing Rules

Share records based on field criteria:

import type { SharingRule } from '@objectstack/spec/security';

export const AccountTeamSharingRule: SharingRule = {
  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',
};

Recipient types

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).

Owner-Based Sharing Rules

Share based on record owner characteristics:

export const OpportunityOwnerSharingRule: SharingRule = {
  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',
};

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

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 }

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 readfind / findOne results have non-readable fields stripped from every record before the response leaves the engine.

On writeinsert / 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 (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 →