superapp
ReferenceServer API

Permission Object

Complete permission shape reference.

A permission object defines what a user can do with a specific table. Permissions are identified by snake_case slugs and assigned to users through roles.

TypeScript Interface

interface Permission {
  /** Human-readable name for display in admin UI */
  name: string

  /** Optional description explaining the permission's purpose */
  description?: string

  /** Target table in connection.table format */
  table: string

  /** Which CRUD operations this permission grants */
  operations: {
    select?: boolean
    insert?: boolean
    update?: boolean
    delete?: boolean
  }

  /** Allowed columns (allowlist). Omit to allow all columns. */
  columns?: string[]

  /** Row-level filter injected into WHERE clauses (SELECT, UPDATE, DELETE) */
  filter?: Record<string, any>

  /** Validation condition for incoming data (INSERT, UPDATE) */
  check?: Record<string, any>

  /** Server-injected values that override client data (INSERT, UPDATE) */
  preset?: Record<string, any>

  /** Maximum number of rows this permission can return per query */
  limit?: number

  /** Custom SQL fragment for advanced filtering (use with caution) */
  customSql?: string

  /** Custom TypeScript that wraps query execution */
  middleware?: (
    params: { user, db, table, operation, columns, query, input?, filter? },
    next: (overrides?) => Promise<any[]>,
  ) => Promise<any[]>
}

Field Reference

name

Typestring
RequiredYes
Used inAdmin UI display, audit logs

Human-readable label for the permission. Shown in the admin UI and audit logs.

name: 'View own orders'

description

Typestring
RequiredNo
Used inAdmin UI tooltip, documentation

Explains what this permission does and why it exists.

description: 'Allows users to read orders belonging to their organization. Restricted to non-financial columns.'

table

Typestring
RequiredYes
Formatconnection_name.table_name

The fully qualified table name this permission applies to. Must match a table in one of your configured connections.

table: 'main.orders'
table: 'warehouse.events'
table: 'imports.products'

operations

Type{ select?: boolean; insert?: boolean; update?: boolean; delete?: boolean }
RequiredYes
Default per keyfalse

Which CRUD operations this permission grants. At least one must be true.

// Read-only
operations: { select: true }

// Full CRUD
operations: { select: true, insert: true, update: true, delete: true }

// Write-only (insert and update, no read)
operations: { insert: true, update: true }

columns

Typestring[]
RequiredNo
DefaultAll columns (no restriction)
Applies toSELECT (returned columns), INSERT/UPDATE (writable columns)

An allowlist of column names. For SELECT, only these columns are returned. For INSERT/UPDATE, only these columns can be set by the client. Omit to allow all columns.

// Only these columns are visible/writable
columns: ['id', 'amount', 'status', 'created_at']

// All columns allowed (omit the field)
// columns: undefined

filter

TypeRecord<string, any>
RequiredNo
Applies toSELECT (WHERE), UPDATE (WHERE), DELETE (WHERE)
Supports$user.* substitution, all filter operators

A condition injected into the query's WHERE clause. The user cannot see, modify, or remove this filter. Supports all filter operators and $user.* variable substitution.

// Simple equality
filter: { customer_id: { $eq: '$user.customer_id' } }

// Array membership
filter: { organization_id: { $in: '$user.org_ids' } }

// Multiple conditions (AND)
filter: {
  organization_id: { $eq: '$user.current_org_id' },
  status: { $ne: 'archived' },
}

// Nested relationship filter
filter: {
  organization: {
    members: { user_id: { $eq: '$user.id' } },
  },
}

check

TypeRecord<string, any>
RequiredNo
Applies toINSERT (validates data), UPDATE (validates new values)
SupportsAll filter operators (applied to the incoming data, not existing rows)

A validation condition that the incoming data must satisfy. If the data fails the check, the operation is rejected with a permission error. Unlike filter, check validates the new data, not existing rows.

// Ensure amount is non-negative
check: { amount: { $gte: 0 } }

// Ensure status is one of the allowed values
check: { status: { $in: ['draft', 'active', 'cancelled'] } }

// Multiple validations
check: {
  amount: { $gte: 0, $lte: 1_000_000 },
  status: { $in: ['draft', 'active', 'cancelled'] },
  priority: { $in: ['low', 'medium', 'high'] },
}

preset

TypeRecord<string, any>
RequiredNo
Applies toINSERT (injects values), UPDATE (injects values)
Supports$user.* substitution, literal values

Key-value pairs injected into the data before writing. Preset values override anything the client sends for those columns. Use this for server-controlled fields like created_by, organization_id, or updated_at.

// Inject the current user's ID
preset: { created_by: '$user.id' }

// Inject organization and timestamp
preset: {
  organization_id: '$user.current_org_id',
  updated_by: '$user.id',
  updated_at: '$now',
}

// Literal value
preset: { source: 'api' }

limit

Typenumber
RequiredNo
DefaultUses global limits.maxLimit
Applies toSELECT

Maximum number of rows this permission can return in a single query. Overrides the global maxLimit for this specific permission. The client's limit is capped to whichever is lower: the permission limit or the global limit.

// Cap to 100 rows per query
limit: 100

// Allow up to 50,000 rows (for export-type permissions)
limit: 50_000

customSql

Typestring
RequiredNo
Applies toSELECT (appended to WHERE clause)

A raw SQL fragment appended to the WHERE clause. Use this for advanced filtering that cannot be expressed with the standard filter operators. The fragment is parameterized with $user.* substitution.

// Spatial query
customSql: "ST_Distance(location, ST_Point($user.longitude, $user.latitude)) < 10000"

// Date range
customSql: "created_at >= CURRENT_DATE - INTERVAL '30 days'"

customSql bypasses the filter operator validation. Use it sparingly and ensure the SQL is safe. Never interpolate user input directly.

middleware

Type(params, next) => Promise<any[]>
RequiredNo
Applies toAll operations

A function that wraps query execution. Receives destructured parameters (user, db, table, operation, columns, query, input, filter) and a next() function. Pass overrides to next({ filter, input, columns, db }) to modify the query. Transform rows after next() to shape results. Wrap in db.transaction() for atomic operations.

middleware: async ({ user, db, operation, input }, next) => {
  // Before: validate
  if (operation === 'insert') {
    const org = await db.query.organizations.findFirst({
      where: eq(organizations.id, user.current_org_id),
    })
    if (org.order_count >= org.order_limit) {
      throw new PermissionError('Order limit reached')
    }
  }

  const rows = await next()

  // After: redact
  if (!user.roles.includes('admin')) {
    return rows.map(({ ssn, ...row }) => row)
  }
  return rows
},

See Middleware for full documentation and examples.

Complete Examples

Read-Only Permission

view_own_orders: {
  name: 'View own orders',
  description: 'Read orders belonging to the user\'s organization',
  table: 'main.orders',
  operations: { select: true },
  columns: ['id', 'amount', 'status', 'customer_id', 'created_at'],
  filter: {
    organization_id: { $eq: '$user.current_org_id' },
  },
  limit: 1000,
}

Full CRUD Permission

manage_team_tasks: {
  name: 'Manage team tasks',
  description: 'Full CRUD on tasks for the user\'s team',
  table: 'main.tasks',
  operations: { select: true, insert: true, update: true, delete: true },
  columns: ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'due_date'],
  filter: {
    team_id: { $in: '$user.team_ids' },
  },
  check: {
    status: { $in: ['todo', 'in_progress', 'done', 'cancelled'] },
    priority: { $in: ['low', 'medium', 'high', 'critical'] },
  },
  preset: {
    organization_id: '$user.current_org_id',
    updated_by: '$user.id',
  },
}

Insert-Only Permission

submit_feedback: {
  name: 'Submit feedback',
  description: 'Users can submit feedback but cannot read or modify existing entries',
  table: 'main.feedback',
  operations: { insert: true },
  columns: ['message', 'category', 'rating'],
  check: {
    rating: { $gte: 1, $lte: 5 },
    category: { $in: ['bug', 'feature', 'general'] },
  },
  preset: {
    user_id: '$user.id',
    submitted_at: '$now',
    status: 'pending',
  },
}

Delete with Restrictions

delete_draft_orders: {
  name: 'Delete draft orders',
  description: 'Users can only delete their own orders that are still in draft status',
  table: 'main.orders',
  operations: { delete: true },
  filter: {
    customer_id: { $eq: '$user.customer_id' },
    status: { $eq: 'draft' },
  },
}

On this page