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:
- A security rule set for the operation applies:
allow,auth,role:<name>ordeny(Layer 2). A collection with no rule for the operation falls to the next layer. - The caller's role keyring is checked for the operation's bit (Layer 1).
- 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.