Skip to content

Permissions ​

One flat list of permissions comes from your API. Four places read it, and none of them is a security boundary on its own.

Open in demo

The shape ​

/auth/me returns the signed-in user and a flat permission list:

json
{
  "user": { "id": "usr_7", "name": "Amina Haddad", "email": "amina@example.com", "roleId": "role_editor" },
  "permissions": [
    { "action": "read", "subject": "User" },
    { "action": "create", "subject": "Invoice" },
    { "action": "update", "subject": "Invoice" }
  ]
}

updateAbility turns that into the CASL ability the whole app reads. An entry may also carry conditions, which CASL matches against the object you pass to can.

Two strings are special: the action manage means every action, and the subject all means every subject. { "action": "manage", "subject": "all" } is an administrator.

CASL matches raw strings. User and Users are two different subjects, and the mismatch is silent, so pick one spelling and keep it.

Roles ​

A role is a name and a list of permissions. The demo ships four:

RoleWhat it holds
Administratormanage all.
EditorRead, create and update on content, plus read on users and the activity log.
ViewerRead on everything.
AuditorRead the activity log and invoices, read and create reports.

The role editor at /roles renders a grid of resources against four actions: read, create, update and delete. manage is deliberately not a column. It is a wildcard that overlaps the other four, so ticking it alongside an unticked delete would mean nothing coherent. Roles built in the editor emit explicit pairs, which is what makes load, edit and save lossless. A role that already holds the wildcard shows it.

The eight resources in the catalog are User, Role, Setting, ActivityLog, Dashboard, Report, Invoice and File. It is a constant in the code, not an endpoint, because it changes when a developer ships a module, not when data changes. Add your own subjects there.

Gate a route ​

Put the permission in the page's <route> block, as "<action> <subject>":

json
{
  "path": "/invoices",
  "name": "invoices",
  "meta": { "title": "invoice.title", "breadcrumb": "invoice.title", "permission": "read Invoice" }
}

The global guard checks two things in order: a route without meta.public sends a signed-out visitor to /auth/login with the original path in ?redirect=, and a route with meta.permission that the ability does not grant goes to /forbidden.

Gate a menu item ​

A NavItem takes the same string:

ts
{ label: 'Users', labelKey: 'nav.users', to: '/users', icon: Users, permission: 'read User' }

On a group it hides the whole subtree.

One deliberate exception: a signed-out visitor sees the entire menu. Hiding items is right for a signed-in user with a narrow role, but a page missing from the menu of a signed-out visitor reads as a page that was never built. Nothing is unlocked by this — the guard still bounces every gated route to the sign-in page.

NavItem.feature is the other gate, and it applies to everyone: an item tied to a switched-off feature is hidden for guests too, because a feature that is off is off for everybody.

Gate an element ​

vue
<script setup lang="ts">
import { Button } from '@shared/components/ui/button'
</script>

<template>
  <Button v-permission="'create User'">
    Add user
  </Button>
</template>

The directive is registered globally, takes one "<action> <subject>" string, and sets display: none rather than unmounting, so a permission change shows or hides without re-mounting the node.

Ask in code ​

ts
import { usePermissions } from '@shared/composables/usePermissions'

const { can, cannot } = usePermissions()

const canInvoice = can('create', 'Invoice')
const readOnly = cannot('update', 'Invoice')

These are plain functions, not computed refs. @casl/vue already re-renders templates when the rules change; wrap a call in computed when you need a derived ref.

None of this is security ​

Every check here runs in the browser, on a bundle the reader can edit. The directive hides a button. The guard redirects a route. Both are there so people are not shown things they cannot use.

The load-bearing check is your backend. Authorize every request there, whatever the UI did or did not show.

Where to go next ​