Skip to content

Roles and permissions

Roles decide what a signed-in user can see and change in the dashboard. Every project has an admin role with full access to all content and to user management. Add your own roles to give editors less than that.

The users screen with an admin, editor, translator and viewer, each with their roleThe users screen with an admin, editor, translator and viewer, each with their role
The users screen of the demo, with a role for each user: Admin, Editor, Translator and Viewer.

Define a role

Create a role with Config.role and register it under roles in your CMS config. The permissions function receives a policy to fill in:

cms.ts
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'

const editor = Config.role('Editor', {
  description: 'Edits and publishes content, cannot change settings',
  permissions(policy) {
    policy.set(
      {
        allow: {
          read: true,
          create: true,
          update: true,
          publish: true,
          archive: true,
          reorder: true,
          move: true,
          upload: true
        }
      },
      {
        root: cms.workspaces.main.settings,
        deny: {create: true, update: true, publish: true, archive: true}
      }
    )
  }
})

export const cms = createCMS({
  roles: {editor},
  // schema, workspaces ...
})

The key in roles (editor) is the name stored on users. label and description are shown in the dashboard. The permissions function runs when a user's policy is built, so it can refer to cms.workspaces even though cms is defined below it.

Assign roles to users

  • Local development: you are signed in as a local admin. Open the profile menu at the bottom of the sidebar and pick one or more roles under Role to see the dashboard as that user would.

  • Self-hosted: with a database in your backend, users with the manageMembers permission (admins) get a Manage users screen in the profile menu to add users by email and give them roles. Users that are not in that list keep the roles from their sign-in: from the token claims with OAuth2, or from what your auth.basic function returns (returning true signs the user in as admin).

  • Alinea Cloud: users and their access are managed in Alinea Cloud.

A user can have several roles. Their policy combines the rules of all of them, see Combining roles.

Write rules

policy.set takes one or more rules. A rule has a target, the actions it allows or denies, and optionally a grant mode:

policy.set(
  // No target: applies to everything
  {allow: {read: true}},
  // A workspace or root, as defined in your config
  {root: cms.workspaces.main.pages, allow: {update: true}},
  // Every entry of a type, or one field of a type
  {type: BlogPost, allow: {create: true}},
  {field: BlogPost.price, deny: {update: true}},
  // One entry (and everything below it)
  {id: entryId, allow: {all: true}},
  // Every entry in a locale, null for untranslated entries
  {locale: 'fr', allow: {update: true}}
)

A rule has one target. Setting a rule for the same target again replaces the earlier one. policy.allowAll() grants everything, like the admin role.

Actions

  • read: see the workspace, root, entry or field in the dashboard.

  • create: create entries.

  • update: edit entries. Denying update on a field makes it read-only.

  • publish: publish drafts and archived entries.

  • archive: archive entries.

  • delete: delete entries and media files.

  • reorder: change the order of entries within their parent.

  • move: move entries to another parent or root.

  • upload: upload media files. Uploads are checked against rules without a target, so allow it at the top level.

  • manageMembers: manage users and their roles.

  • explore: deprecated and never checked. What a role can browse in the entry tree and media library follows read.

  • all: every action above.

How rules combine

For an entry, the policy walks from broad to specific: the rules without a target, then the workspace, the root, the parent entries, the type (and field), the locale and finally the entry itself. Each level adds its allows to what it inherited.

  • Deny wins. A denied action stays denied for everything below it, and an allow further down can't undo it. Put denies on the most specific target you can.

  • Explicit grants. With grant: 'explicit', the allows of a level and the levels above it apply to that level only, not to what is below it. Children, and the entries of a root, then need their own allow. Denies still flow down.

Combining roles

A user with several roles gets the union of their rules: an action allowed by any role is allowed. Denies are combined the same way, so a deny in one role also blocks what another role allows. Keep denies in roles that are meant to restrict.

What permissions protect

Your handler checks the policy on every save, so a user can't create, update, publish, archive, move, reorder, delete or upload beyond their roles, even outside the dashboard. read hides workspaces, roots, entries and fields in the dashboard.

Permissions don't make content secret. Your content lives in your git repository and the dashboard syncs all of it to the browser, so treat read as a way to keep the dashboard focused, not as access control for confidential data.

Examples

Read-only access

const viewer = Config.role('Viewer', {
  permissions(policy) {
    policy.set({allow: {read: true}})
  }
})

Translators

Allow editing in one locale. The rest of the content stays visible for reference:

const translator = Config.role('Translator', {
  description: 'Translates pages into French',
  permissions(policy) {
    policy.set(
      {allow: {read: true}},
      {locale: 'fr', allow: {create: true, update: true, publish: true}}
    )
  }
})

Rules for a locale apply to creating entries too: with create allowed in fr, this role can start the French translation of a page. Leave it out to limit translators to French versions that already exist.

Read-only fields

Editors see the price but can't change it:

const editor = Config.role('Editor', {
  permissions(policy) {
    policy.set(
      {allow: {read: true, update: true, publish: true}},
      {field: BlogPost.price, deny: {update: true}}
    )
  }
})

Deny read on a field to hide it from the role.

Access to specific entries

Use grant: 'explicit' to show a workspace and root without opening up everything inside them, then allow the entries the role works on:

const landingPageEditor = Config.role('Landing page editor', {
  permissions(policy) {
    policy.set(
      {
        workspace: cms.workspaces.main,
        allow: {read: true},
        grant: 'explicit'
      },
      {
        root: cms.workspaces.main.pages,
        allow: {read: true},
        grant: 'explicit'
      },
      {
        id: landingPageId,
        allow: {all: true}
      }
    )
  }
})

The dashboard's content tree only shows entries the role can read. If the entry is nested, allow read on its parents too, or the editor can't navigate to it.

Permissions from your content

The second argument of permissions is a graph to query your content, so rules can follow your data instead of hardcoded ids. This role lets authors edit the posts that link to them, and picks up new posts automatically:

import {Config, Query} from 'alinea'

const guestAuthor = Config.role('Guest author', {
  async permissions(policy, graph) {
    const posts = await graph.find({
      type: BlogPost,
      filter: {author: {has: {_entry: guestAuthorId}}},
      select: Query.id
    })
    policy.set(
      {workspace: cms.workspaces.main, allow: {read: true}, grant: 'explicit'},
      {root: cms.workspaces.main.pages, allow: {read: true}, grant: 'explicit'}
    )
    for (const id of posts) policy.set({id, allow: {read: true, update: true}})
  }
})

The query runs whenever a policy is built, so keep it small.

Test your roles

cms.createPolicy builds the policy for a list of role names, which makes roles easy to unit test:

roles.test.ts
import {expect, test} from 'bun:test'
import {cms} from '@/cms'

test('editors cannot change settings', async () => {
  const policy = await cms.createPolicy(['editor'])
  expect(policy.canUpdate({workspace: 'main', root: 'pages'})).toBe(true)
  expect(policy.canUpdate({workspace: 'main', root: 'settings'})).toBe(false)
})

The policy has a can* method for every action (canRead, canCreate, canUpdate, ...), which takes the workspace, root, type, field, entry id, parent ids and locale to check.

Good to know

  • Defining your own role named admin replaces the built-in one.

  • Users without any role can sign in but can't see or change anything.