# 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](#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):

```ts
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](https://nextlyhq.com/docs/plugins/signed-in-people.md#let-a-person-read-and-write-your-collections). *Needs the release after 0.0.2-alpha.66.*

## Related rows

> [!WARNING]
> **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:

```ts
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:

```ts
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,
});
```

> [!WARNING]
> **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](https://nextlyhq.com/docs/guides/authentication.md) — how a user proves who they are
* [Audit log](https://nextlyhq.com/docs/audit-log.md) — what they did
