Permissions

Three layers check every request, in order, each a single bitmask AND or flag check.

Docs menu
Layer Name Checks
1 Keyrings (RBAC) Role's compiled keyring, one AND against the operation's bit
2 Security rules Per-collection deny/auth/role/owner/allow flags
3 Record-level ACL Per-document owner bitmask

The layers check the people who sign in to your app and its public callers. Your app's own 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 of a built-in name, or of a role that exists, is KEY_EXISTS (409) with field name. role.assign of a role the app does not have is INVALID_ARGUMENT (400) with field role.

Role hierarchy

owner inherits admin inherits editor inherits viewer inherits guest

A role holds everything the role it inherits holds: an admin passes a rule that asks for role:editor or role:viewer; a viewer does not pass role:editor. A custom role (role.create) names the role it inherits with inherits (default viewer) and is a role record only: it makes no collection. A person who was never assigned a role holds none, and passes no role:<name> rule (the app's first user holds 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 that has no rule. A collection's role:<name> rule is decided by the role a person holds, not by the keyring. A keyring on the wire is a 0x hex string (a 64-bit value does not survive a JSON number).


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 a rule does not name follows the defaults (it is not public); only an explicit allow is. An unknown value (a typo) is refused when the rules are set. A role rule asked of a caller with no session answers 401; a signed-in caller without the role gets 403.

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 answers 403 Forbidden; allow passes everyone; auth needs a signed-in caller; role:<name> needs that role (or one that inherits it). The same check runs on 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 its own 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) answers 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 owned by Substrio, not an app: it exists with no collection.create call, carries a platform-declared schema the app cannot weaken (it may add its own fields and indexes, but not redeclare a platform field with a different type or drop email's uniqueness), and refuses collection.create/collection.delete. It is listed by collection.list like any other collection, marked "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 its own 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 (never as an HTTP caller), so this default-deny never applies to a function's own access to _users.