Permissions

Substrio uses three permission layers for requests from your app's users and public callers.

Docs menu

A collection rule takes precedence when one is set for an operation. Otherwise, the caller's role keyring applies. Record-level ownership also applies where configured.

Layer Name Checks
1 Keyrings (RBAC) Role's compiled keyring, one AND against the operation's bit when no rule applies
2 Security rules Per-collection deny/auth/role/owner/allow flags, which take precedence when set
3 Record-level ACL Per-document owner bitmask

Your app key administers the app and passes every layer. Keep it on your backend.



Layer 1: Role-based access control (keyrings)

Every user has a 64-bit keyring: a pre-compiled bitmask encoding their permissions across project, collection, record, and operation dimensions.

Built-in roles

Role Inherits Grants
guest none limited public access
viewer guest read-only access
editor viewer create and modify documents
admin editor manage collections, users and settings
owner admin full control of the project

role.create returns KEY_EXISTS (409) with field name for a built-in or existing role name. role.assign returns INVALID_ARGUMENT (400) with field role when the app does not have the requested role.

Role hierarchy

owner inherits admin inherits editor inherits viewer inherits guest

A role includes the permissions of the role it inherits. For example, an admin passes a rule requiring role:editor or role:viewer, but a viewer does not pass role:editor. A custom role created with role.create specifies its parent with inherits, which defaults to viewer. Creating the role does not create a collection. A person who has not been assigned a role passes no role:<name> rule. The app's first user has owner.

Assigning roles

# Assign "editor" role to user 42 (operation role.assign, MCP tool substrio_role_assign)
curl -X PUT $SUBSTRIO_URL/v1/_users/42/role \
  -H "Authorization: Bearer $SUBSTRIO_KEY" \
  -H "X-Substrio-Tenant: my-app" \
  -d '{"role": "editor"}'

With the CLI: substrio op role.assign id=42 role=editor.

How a keyring works

A keyring is 64 bits, split into four 16-bit fields: project, collection, record and operation. A permission check is one AND: (user_keyring & required_keyring) != 0.

Roles compile to keyrings at assignment time. The keyring gates a collection operation with no rule. A role:<name> rule uses the person's role instead. On the wire, a keyring is a 0x hex string because a JSON number cannot preserve every 64-bit value.


Layer 2: Security rules

Set a per-collection access policy declaratively, over the API. Rules compile down to the same ACL flag bitmasks collection creation sets, so a request pays no per-request evaluation cost beyond the flag check.

Setting rules

curl -X POST $SUBSTRIO_URL/v1/_rules \
  -H "Authorization: Bearer $SUBSTRIO_KEY" \
  -H "X-Substrio-Tenant: my-app" \
  -H "Content-Type: application/json" \
  -d '{
    "rules": [
      {
        "collection": "posts",
        "read": "allow",
        "write": "role:editor",
        "create": "auth",
        "delete": "role:admin"
      },
      {
        "collection": "admin_logs",
        "read": "role:admin",
        "write": "deny",
        "create": "deny",
        "delete": "deny"
      },
      {
        "collection": "*",
        "read": "allow",
        "write": "auth",
        "create": "auth",
        "delete": "auth"
      }
    ]
  }'

Access levels

Level Behavior
allow Everyone, a caller with no session included (public)
auth Must be signed in (have a valid token)
deny Blocked entirely (403 Forbidden)
owner Must own the document
role:<name> Must hold role <name> or a role that inherits it: role:viewer admits viewer, editor, admin, owner; role:admin admits admin and owner; a custom role role:host admits host and the roles that inherit host

An operation omitted from a rule follows the defaults and is not public. Only an explicit allow makes it public. An unknown value, such as a typo, is refused when the rules are set. A role rule returns 401 for a caller with no session and 403 for a signed-in caller without the required role.

Operations

Apply an access level independently to each of four operations:

Operation HTTP Methods Description
read GET Query, fetch documents
write PUT, PATCH Update existing documents
create POST Insert new documents
delete DELETE Remove documents

From the SDK

const db = new Substrio({ url, tenant, key });

await db.ops.security.rules.set({
  rules: [
    { collection: 'posts', read: 'allow', write: 'role:editor', delete: 'role:admin' },
    { collection: 'settings', read: 'role:admin', write: 'role:admin' },
  ]
});

const config = await db.ops.security.rules.get();
console.log(config.rules);

await db.ops.security.rules.delete();

How it behaves

Set rules with security.rules.set (POST /v1/_rules, substrio op security.rules.set, or collections/<name>.json in your project). They apply to the next request. deny returns 403 Forbidden, allow permits everyone, auth requires a signed-in caller, and role:<name> requires that role or one that inherits it. The same check applies to every path that reads or writes the collection for an end user: data operations, queries, counts, aggregates, chains and joins, searches, batches, imports and exports, and realtime subscriptions. The rules are compiled once into flag bits; a role rule reads the caller's role and walks its inheritance chain. Rules persist across server restarts.


Layer 3: Record-level ACL (per-document owner)

For owner-gated collections, each document is tagged with its creator's unique bitmask. At query time, only documents whose ACL mask matches the caller's mask are returned: one AND, (doc_acl_mask & caller_mask) != 0, per document.

A regular user's mask is a bit unique to that user. The check applies in every query path: $knn, $search, scalar, $count, and pagination.


Permission catalog

Beyond the built-in roles, an app can declare named permissions (substrio deploy's [permissions] table, operation permissions.set): up to 64 names, each one bit. A name can imply others, and one name can be declared all.

[permissions]
names = ["READ", "WRITE", "ADMIN"]
all = "ADMIN"

[permissions.implies]
ADMIN = ["WRITE"]
WRITE = ["READ"]

The catalog is append only. A later deploy may add names at the end; it may never reorder, rename or remove one, since a stored name's bit never changes meaning once declared. A deploy that tries is refused, naming the position at fault, and the stored catalog is left exactly as it was. A deploy with no [permissions] table also leaves it exactly as it was (it does not clear it).

all closes to every bit, including one declared later. all does not mean "every name declared today": it closes to all 64 bits, so a grant of all made before a name is appended still holds that name once it exists, with nothing stored rewritten.

substrio permissions get (or operation permissions.get) returns the stored catalog:

{
  "names": [
    { "name": "READ", "bit": 0, "implies": [] },
    { "name": "WRITE", "bit": 1, "implies": ["READ"] },
    { "name": "ADMIN", "bit": 2, "implies": ["READ", "WRITE"] }
  ],
  "all": "ADMIN"
}

Each name's implies is its closure: every name it implies, transitively, itself excluded.

A grant is stored as the names given, never closed. Implications are applied once, at sign in, when a session's keyring is built: the OR of the held names' closures. A later change to implies takes effect at the next sign in, not by rewriting any stored grant. A permission check at request time is then one AND against that already-closed mask ((held & (1 << bit)) != 0): no catalog lookup and no closure walk on the request path.


Enforcement priority

On a request, the layers apply in this order:

  1. A security rule set for the operation applies: allow, auth, role:<name> or deny (Layer 2). A collection with no rule for the operation falls to the next layer.
  2. The caller's role keyring is checked for the operation's bit (Layer 1).
  3. For an owner-gated collection, results are filtered by the caller's ACL bitmask (Layer 3).

Every check is a flag test, except a role:<name> rule: it reads the caller's one role record (and, for a custom role, its parent records). No policy evaluation, no expression parsing.


Platform collections

A platform collection (currently only _users) is managed by Substrio. It exists without a collection.create call and has a platform-declared schema that the app cannot weaken. The app can add fields and indexes, but it cannot redeclare a platform field with a different type or remove the uniqueness of email. Calls to collection.create and collection.delete are refused. collection.list includes the collection with "platform": true.

Layer 2's default differs for a platform collection: an ordinary collection with no declared rule falls through to Layer 1 (the keyring); _users with no app-declared rule is deny for every operation (read/write/create/delete), for every non-admin caller, signed in or not. An app opts in by declaring a rule for _users (collections/_users.json's rules, or POST /v1/_rules), exactly as for any other collection.

A function always acts as the app, so this default denial does not apply to its access to _users.