Access control

FFDB exposes access rules through the client so your app can make smart UI and query decisions before a user hits denied operations. This page shows how to read permissions and why each part matters.

Why this matters

Application access control is not only a backend concern. Reading access context on the client helps you:

  • Name
    Hide invalid actions early
    Description

    Disable or hide buttons for operations the current user cannot perform.

  • Name
    Prevent invalid query shapes
    Description

    Respect column and pagination constraints before sending a query.

  • Name
    Explain behavior clearly
    Description

    Show users why actions are unavailable instead of failing silently.

Access interface shape

type TableAccess = {
  table: string
  access: {
    read: boolean
    insert: boolean
    update: boolean
    delete: boolean
  }
  constraints: {
    allowedReadColumns?: string[]
    deniedReadColumns?: string[]
    writableColumns?: string[]
    requiredPredicateColumns?: string[]
    maxLimit?: number
    maxOffset?: number
    allowOffset?: boolean
  }
  explicitlyConfigured: boolean
  adminOverride: boolean
}

type AccessInfo = {
  userId: string
  role: string
  blockedStatementTypes: string[]
  tables: TableAccess[]
}

End-to-end access flow

Use getAccess at app startup or after auth changes, then map permissions into UI and query logic.

import { createClient } from 'ffdb-client'
import type { Database } from './ffdb.types'

const { getAccess, db } = await createClient<Database>({
  config: { apiUrl: import.meta.env.VITE_FFDB_API_URL },
})

const access = await getAccess()
const userTable = access.tables.find((t) => t.table === 'user')

if (!userTable?.access.read) {
  throw new Error('Current user cannot read user table')
}

const selectedColumns =
  userTable.constraints.allowedReadColumns ?? ['id', 'email', 'name']

const limit = Math.min(25, userTable.constraints.maxLimit ?? 25)

const rows = await db
  .selectFrom('user')
  .select(selectedColumns as Array<'id' | 'email' | 'name'>)
  .limit(limit)
  .execute()

console.log(rows)

UI gating pattern

This is a practical pattern for forms and actions.

function UserActions({ access }: { access: AccessInfo }) {
  const userTable = access.tables.find((t) => t.table === 'user')

  const canCreate = Boolean(userTable?.access.insert)
  const canEdit = Boolean(userTable?.access.update)
  const canDelete = Boolean(userTable?.access.delete)

  return (
    <div>
      <button disabled={!canCreate}>Create user</button>
      <button disabled={!canEdit}>Edit user</button>
      <button disabled={!canDelete}>Delete user</button>
    </div>
  )
}

Constraint-aware query guidance

  • Name
    allowedReadColumns / deniedReadColumns
    Description

    Build select lists from allowed columns where possible.

  • Name
    writableColumns
    Description

    Restrict edit forms and patch payload builders to writable fields.

  • Name
    requiredPredicateColumns
    Description

    Ensure filters include required columns for scoped reads.

  • Name
    maxLimit / maxOffset / allowOffset
    Description

    Clamp pagination controls to policy-allowed ranges.

  • Name
    blockedStatementTypes
    Description

    Avoid exposing unavailable operation classes in power-user workflows.

Practical tradeoffs

  1. Caching access info reduces repeated checks, but refresh it after sign-in, sign-out, and role changes.
  2. Strict client-side gating improves UX, but never replace backend enforcement with client checks.
  3. Dynamic query shaping gives flexibility, but define safe fallbacks when constraints are missing.

Next pages

  1. Offline overview
  2. Local cache
  3. Sync lifecycle

Was this page helpful?