| 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:
- 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 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.