Permissions & Role Guards

Control collection and field visibility with role-based permission guards. Understand what is enforced in the UI, what requires server-side protection, and how to wire up your own role resolver.

Permissions & Role Guards

nuxt-protokit includes a lightweight permission system that lets you guard collections and fields based on the current user's roles. The guards drive UI behaviour — hiding collections, disabling controls, and making fields read-only — without requiring you to build this logic into every component by hand.

Frontend guard only. All permission checks run in the browser. A user who knows the underlying Y.js CRDT API can read and modify document data directly, regardless of what the guards say. Read the Limitations section before relying on this system for sensitive data.

How it works

The permission system has two parts:

  1. A role resolver — a function you provide that returns the current user's roles.
  2. Permission guards — declarative rules in your schema that reference those roles.

The two are connected via configureProtoPermissions(), which you call once in a Nuxt plugin.

Setup

Register your role resolver before any component that uses permission-aware composables is mounted. A client-side plugin is the right place for this.

plugins/permissions.client.ts
export default defineNuxtPlugin(() => {
  configureProtoPermissions({
    resolveRoles: () => useAuthUser().value?.roles ?? [],
  })
})

resolveRoles is called reactively every time a permission is evaluated, so it will always reflect the current authentication state.

Permission guards

A guard can be written in three ways:

// Single role — user must hold this exact role
permissions: { read: 'admin' }

// Role array — user must hold at least one of these roles
permissions: { read: ['admin', 'editor'] }

// Custom function — full control, receives a PermissionContext
permissions: {
  read: (ctx) => ctx.roles.includes('admin') || someOtherCondition,
}

Omitting a guard means allow — no guard defined is always open access.

Collection permissions

Add a permissions key to any CollectionSchema. The four available actions are read, create, update, and delete.

schemas/my-tool.ts
defineCollection({
  key: 'tasks',
  title: 'Tasks',
  // ...
  permissions: {
    read:   ['admin', 'editor', 'viewer'],
    create: ['admin', 'editor'],
    update: ['admin', 'editor'],
    delete: ['admin'],
  },
})

Consuming collection permissions

useProtoCollection returns a reactive permissions object. Use it to show or hide controls in your template:

components/TaskList.vue
<script setup lang="ts">
const { items, add, remove, permissions } = useProtoCollection(tasksSchema)
</script>

<template>
  <div v-if="permissions.canRead.value">
    <ul>
      <li v-for="(task, i) in items" :key="i">
        {{ task.title }}
        <button
          v-if="permissions.canDelete.value"
          @click="remove(i)"
        >
          Delete
        </button>
      </li>
    </ul>
    <button
      v-if="permissions.canCreate.value"
      @click="add({ title: '' })"
    >
      Add task
    </button>
  </div>
</template>

When using usePrototype, each collection's permissions are available on collectionPermissions:

const { collections, collectionPermissions } = usePrototype(schema)

// collectionPermissions.tasks.canCreate.value → boolean
// collectionPermissions.tasks.canDelete.value → boolean

Field permissions

Add a permissions key to any field definition. Two actions are available: read (visibility) and write (editability).

schemas/my-tool.ts
definePrototype({
  key: 'budget-tool',
  // ...
  fields: {
    name: {
      type: 'text',
      label: 'Name',
      default: '',
    },
    internalBudget: {
      type: 'number',
      label: 'Internal Budget',
      default: 0,
      permissions: {
        read:  ['admin', 'finance'],
        write: ['admin'],
      },
    },
  },
})

Consuming field permissions

useProtoMap and usePrototype return a fieldPermissions object keyed by field name:

components/BudgetForm.vue
<script setup lang="ts">
const { state, fieldPermissions } = usePrototype(schema)
</script>

<template>
  <input v-model="state.name.value" />

  <input
    v-if="fieldPermissions.internalBudget.canRead.value"
    v-model="state.internalBudget.value"
    :disabled="!fieldPermissions.internalBudget.canWrite.value"
  />
</template>

The ProtoForm component reads fieldPermissions automatically — fields with canRead: false are hidden, and fields with canWrite: false are rendered as read-only inputs.

Ad-hoc checks

useProtoPermissions() exposes the underlying can and canField helpers for one-off checks outside a schema context:

const { can, canField, getRoles } = useProtoPermissions()

// Check a collection action
const allowed = can('delete', myCollectionPermissions, { collection: 'tasks' })

// Check a field action
const visible = canField('read', myFieldPermissions, { field: 'budget' })

// Inspect current roles
const roles = getRoles() // string[]

Limitations

Understanding what this system does not provide is as important as knowing what it does.

Field-level permissions are cosmetic

Field guards hide or disable UI elements. The Y.js document itself is always fully loaded in the browser — every field value is present in memory and accessible via the browser console or devtools. A user who understands the Y.js CRDT API can read or write any field directly without going through your Vue components.

Treat field permissions as a UX convenience, not a security boundary. They are appropriate for:

  • Hiding irrelevant fields from users who should not be concerned with them.
  • Preventing accidental edits to fields a role should not normally change.
  • Reducing visual clutter for limited-access roles.

They are not appropriate for:

  • Hiding genuinely sensitive values (API keys, private identifiers, confidential figures).
  • Preventing a motivated user from modifying data.

Collection-level permissions need server enforcement

Collection guards disable the UI controls for adding, editing, and deleting items. The Y.js document is still synced to the server and other peers, so a user who bypasses the UI can still push changes.

For collections that must be protected, implement access control in your server sync handler (e.g. validate the user's role in your /api/yjs route before applying updates). The read guard hides the collection in the UI, but the underlying data array is still in the document.

The role resolver runs in the browser

resolveRoles executes client-side. Any role information it returns came from the browser — it can be tampered with. Never use browser-derived role data as the sole gate on a destructive or privileged server operation.

No encryption

Permission guards do not encrypt document data. Restricting what the UI renders does not prevent a user from reading raw IndexedDB entries or inspecting network payloads.


Summary

ScopeWhat the guard doesEnforcement required elsewhere
Collection readHides the collection UIServer: validate before syncing
Collection writeDisables add / edit / delete controlsServer: validate before applying update
Field readHides the field from the formNothing reliable; avoid sensitive data
Field writeRenders the field as read-onlyNothing reliable; treat as UX only

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.