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 in | No plugin required, and not gated behind a paid tier |
| Admin | A Roles screen for managing roles and their grants |
| Applies to | The 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.
Related rows
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 onlyread-postsdoes not see populated authors. usersand media have no rules of their own, so the grant decides: a signed-in user or an API key needsread-usersto see a populated user andread-mediato 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.
Related
- Authentication — how a user proves who they are
- Audit log — what they did