You're reading docs for Nextly Alpha. APIs may change between releases.

Roles and Permissions

Roles and Permissions

Control who can do what, down to individual fields.

Parts of this page need the release after 0.0.2-alpha.66. Read the warning under Related rows before relying on it: 0.0.2-alpha.66 does not check related rows the way that section describes.

Access control answers three questions, at three levels of detail: what a role may do, which documents it may do it to, and which fields inside those documents it may see or change.

Availability

Built inNo plugin required, and not gated behind a paid tier
AdminA Roles screen for managing roles and their grants
Applies toThe REST API and the admin. The Direct API runs as trusted server code and bypasses collection and document rules by default — see below

Roles and grants

A role holds grants named <action>-<resource>: create-posts, delete-media, publish-content-releases, update-users. A user's roles are the union of their grants.

Because grants are per-resource, a role can be narrow in a way that is actually useful — a release manager who may schedule releases without holding a general read grant over everything.

Collection access

Grants say what a role may do. When a collection declares an access rule for an operation, that rule is the whole answer for a signed-in user: the grant is not checked beside it, so a rule that returns true admits someone whose role lacks the grant. So for staff and API keys, make the rule check the permission itself, then add your own condition, and it only ever narrows what the Roles screen grants. A rule that also admits plugin-defined people checks for them in a separate branch, since they hold no permissions (see the end of this section):

import type { AccessControlFunction } from "nextly";
import { defineCollection, relationship, text } from "nextly/config";

/** The caller holds this operation's permission on this collection. */
const permitted: AccessControlFunction = ({ permissions, collection, operation }) =>
  permissions.includes(`${collection}:${operation}`);

export const Posts = defineCollection({
  slug: "posts",
  fields: [
    text({ name: "title" }),
    relationship({ name: "author", relationTo: "users" }),
  ],
  access: {
    read: true,
    create: permitted,
    // Narrowed: the permission, and not while a content freeze is on.
    update: args => permitted(args) && process.env.CONTENT_FREEZE !== "on",
  },
});

A rule is given the caller's permissions as resource:action — posts:update, the grant the Roles screen calls update-posts — for a signed-in user and an API key alike. A visitor holds none, so a permission check refuses them. A super-admin is admitted before any rule runs.

Don't check a role's name in a rule. roles.includes("editor") refuses a role an admin created that holds update-posts, and every API key whose roles carry none of the names you listed, while the Roles screen says both may act. The role's permissions are what the admin manages, so a rule that reads them stays in step with that screen.

true means anyone, including unauthenticated readers.

A collection whose access says nothing about an operation falls back to grants: a signed-in user needs read-posts to read posts.

An API key must hold the permission in its own grants, and then satisfy the rule if there is one.

A plugin-defined signed-in person, such as a shop's customer, is different: they hold no grants, so a rule is the only way in. A rule sees them as principal beside a null user; a collection with no rule for the operation refuses them, and a rule set to true admits them as it admits any visitor. See Signed-in people of your own kind. Needs the release after 0.0.2-alpha.66.

Needs the release after 0.0.2-alpha.66. 0.0.2-alpha.66 has only part of this protection. On 0.0.2-alpha.66 only the related collection's own access.read rule is checked. A related collection with no read rule, users and media included, is populated for every caller, visitors and API keys too, with no grant needed. Until the next release, read with depth: 0 wherever a visitor or a narrowly scoped key can reach the result, or give the related collection an access.read rule.

A populated relationship is read with the same check as reading the related collection directly. Reading posts at a depth that populates author returns the author only to a caller who could read that users row by name:

  • The related collection declares access.read: that rule decides, for signed-in users and visitors alike. No per-collection grant is needed.
  • It declares no read rule: a signed-in user needs read-<collection>, as they would to read it directly.
  • An API key always needs read-<collection> in its own grants, and then the collection's rule if it has one. A key granted only read-posts does not see populated authors.
  • users and media have no rules of their own, so the grant decides: a signed-in user or an API key needs read-users to see a populated user and read-media to see an upload's media row. Visitors keep media, which public pages embed, but never see a populated user: no visitor can read a user directly. A page your server renders with the Direct API reads as the system by default, so it still shows authors.

This holds at every depth, inside groups and repeaters, for each collection of a polymorphic relationship separately, on a Single, and for the entry a create or an update returns.

A related row the caller may not read comes back exactly as a reference to a row that no longer exists would: the stored id for a single relationship, left out of a list, null for an upload. The rest of the response is served. A read made with overrideAccess, including one that runs as the system, keeps populating related rows; a route that names its trusted collections keeps populating those.

Field-level access

Any field takes its own access, with separate create, read and update rules:

import { defineCollection, text } from "nextly/config";

export const Users = defineCollection({
  slug: "users",
  fields: [
    text({ name: "name" }),
    text({
      name: "internalNotes",
      // Only callers who may manage users see or change the notes.
      access: {
        read: ({ permissions }) => permissions.includes("users:update"),
        update: ({ permissions }) => permissions.includes("users:update"),
      },
    }),
  ],
});

A field the reader may not see is removed from the response rather than blanked, and a field they may not change is rejected on write rather than silently dropped.

A field the reader may not see cannot be used to choose rows either: filtering, sorting or grouping on it is refused with a 400 that names the field, and a collection's search leaves it out. Before a query there is no entry yet, so the read rule is asked without one. A rule that allows the reader outright, like the one above, lets an admin filter on internalNotes. A rule that needs the entry to decide, such as ({ req, data }) => data?.owner === req.user?.id, cannot allow, so the field can't be queried at all, not even by its owner. That keeps a filter or an ordering from revealing the value on entries where the reader can't see it.

This is the level most systems either do not have or sell separately. It is what lets one collection serve two audiences — an editor who sees the article, and a finance user who also sees the fields describing what it cost.

The Direct API bypasses these rules by default

The Direct API is trusted server code, so overrideAccess defaults to true and collection and document rules are skipped. This is deliberate — server code fetching content for a page should not be filtered as though it were an anonymous visitor — but it means a query written on behalf of a request does not enforce anything unless you say so:

const posts = await nextly.find({
  collection: "posts",
  // Both are required. Without a user, an owner rule has nobody to compare
  // against, so it is skipped rather than failed.
  overrideAccess: false,
  user: requestUser,
});

Leaving overrideAccess at its default is the case that leaks. Reading on behalf of a visitor without setting overrideAccess: false returns rows that visitor was not allowed to see. This is the easiest way to expose data with Nextly.

Forgetting the user is a different and much less dangerous mistake. An owner-based rule has nobody to compare against, so the request is refused rather than answered loosely — you get an authorization error, not somebody else's rows. If a query that should return data returns "authentication required", a missing user alongside overrideAccess: false is the first thing to check.

Field-level rules behave differently: they run whenever overrideAccess is false, user or not, because "which fields may this writer set" has a sensible answer for nobody. Internal code that genuinely needs to write a protected field says so with overrideAccess: true.