Skip to content

Access Control ​

Access rules in Primitive are written in CEL — a one-line expression evaluated against the authenticated caller. CEL has two jobs: a server function's access gate, which decides who may run the function, and rule sets, which govern who may manage the platform's own resources — groups, collections, blob buckets, database types and named locks. One expression language, one identity context: learn it once and you can read and write the rules anywhere they appear.

CEL in Sixty Seconds ​

A CEL expression is a one-line condition that evaluates to true or false:

toml
access = "user.userId != ''"                                  # any authenticated user
access = "hasRole('admin') || hasRole('owner')"               # app admins
access = "isMemberOf('team', 'core')"                         # members of one group
access = "size(memberGroups('team')) > 0"                     # member of any team
edit = "user.userId == group.createdBy"                       # a rule-set rule: the group's creator

Standard operators work as you'd expect: ==, !=, <, &&, ||, !, in, plus functions like size(...). String literals use single quotes inside TOML double-quoted strings.

The Identity Context ​

Every CEL evaluation sees the authenticated caller:

Variable / functionMeaning
user.userIdThe caller's user ID (empty string when unauthenticated)
user.roleThe caller's app role
isAnonymous()True when the caller has no account at all (an unauthenticated request). Write !isAnonymous() to require any signed-in member; relevant where anonymous access is possible, such as a public blob bucket
hasRole(role)True if the caller's app role is "owner", "admin", or "member" as given (app-level role — distinct from the document owner permission)
isMemberOf(groupType, groupId)True if the caller belongs to that exact group
memberGroups(groupType)The list of group IDs of that type the caller belongs to

Group membership is the workhorse: model teams, roles, and relationships as groups, then write rules against membership instead of hard-coding user IDs. isMemberOf('team', 'core') stays correct as people join and leave; user.userId == '01ABC...' doesn't.

A function's access gate sees exactly this context. Each rule-set surface adds its own on top — the group.* a group rule set manages, the collection.* a collection rule set manages, the record.key a lock rule matches. The feature pages document their specific variables. A rule at a metadata-capable site also gains md.self.* — reading md.self.<category> needs no declaration (it's inferred), and md.<path>.* / md.caller.* are available where declared — see Resource Metadata. A rule can also read secrets.* and vars.*, but each is declared-only — it binds only the keys the owning database or collection type config lists (secrets = ["STRIPE_KEY"], vars = ["ADMIN_GROUP_ID"]), in every CEL rule, database triggers and stamps included. An undeclared secrets.KEY is absent, so a rule reading it denies; an unbound vars.KEY makes the rule error and access is refused — check with 'KEY' in vars or read vars.?KEY when a key may be missing. Setting the values themselves is on App Secrets.

A Rule Sees Identity, Not Stored Data ​

Every function above answers a question about the caller — their role, their group memberships. A rule has no way to read a database record. So an entitlement you want the server to enforce — a paid feature, a role gate — can't live as a flag on a row: storing isSubscribed = true and checking it in a rule doesn't work, because the rule can't read the row.

Model the entitlement as group membership instead, which a rule can check, and put the check in the access gate of every function behind the paywall:

toml
# primitive/dev/functions/export-report.toml
[function]
key = "export-report"
entry = "functions/export-report/index.ts"
access = "isMemberOf('subscription', 'trialing') || isMemberOf('subscription', 'active')"

Maintain that membership from your billing source — a function fired by the provider's webhook adds a member when a subscription starts and removes them when it lapses (ctx.api.groups.addMember, ctx.api.groups.removeMember) — and every paid feature becomes a one-line membership check. Give that webhook function access = "false": a webhook fire skips the gate, so a gate no member passes keeps members from calling it over HTTP to grant themselves the entitlement, while the provider's verified deliveries still run it.

Where Rules Appear ​

Every surface a signed-in member can reach that runs app-defined server-side code, manages a platform resource, or spends the app's credentials, and what happens on each when no rule is set. App owners and admins bypass every rule below.

SurfaceWhat the rule gatesNo rule setDetails
Server functions (access)Who may invoke or start a function — the rule is evaluated on every call, and it is the whole authorization: inside, the code acts with the app's authorityDenied — push refuses code for a function with no gate, and a function without one denies every caller with FUNCTION_ACCESS_DENIEDServer Functions
Groups and collections (rule sets)Who can perform management operations on groups and collectionsPermissive defaults — any member creates; the creator manages what they createdbelow
Blob buckets (preset / ruleSetId)Member-level access to a bucket's blobs, per operationCannot happen — a bucket carries a preset or a rule setBlobs and Files
Database types (rule set, ruleSetName)Who may edit or delete a database type's configurationDenied for non-adminsWorking with Databases
Named locks (locks/acquire, renew)Which keys a caller may acquire or renew — the requested key is matched as record.key by the app's single lock rule set (release is authorized by the acquire handle and is never rule-gated)Open until a lock rule set is installed: any member may take any key. Once one is installed, each operation it does not define is denied for everyone but app admins and ownersLocks
Metadata categories (readRule + writeRule)Who can read or write one category of a resource's metadataDenied for non-adminsResource Metadata
Server-stamped fields (trigger when conditions)Whether a computed field applies to this writeNot a caller gate — the field simply always appliesServer-Stamped Fields
DocumentsWho may read, write or share a documentNot CEL — per-document permission grants (owner, read-write, reader) to users, emails and groupsSharing Documents
Notification send, run reads, admin routesSending a notification, reading a function run, and the app's administrative routesNot a per-resource rule — a run read is gated on being the run's initiator, and the send and admin routes on the app admin roleNotifications

A prompt and an integration carry no rule of their own: nothing but a server function reaches them, so the calling function's access gate is their authorization (plus the function's integration:<key> capability for an integration).

Rule Sets: Governing Management Operations ​

A function's access gate decides who may run its code. Rule sets answer a different question: who may manage a platform resource directly — create groups of a type, add or remove members, edit or delete a collection, read or write a bucket's blobs, take a lock. A rule set is a named bundle of CEL rules per management operation:

toml
# primitive/dev/rule-sets/team-management.toml
[ruleSet]
name = "team-management"
resourceType = "group"

[rules.group]
create = "true"
edit = "user.userId == group.createdBy"
delete = "user.userId == group.createdBy"

[rules.member]
create = "isMemberOf(group.groupType, group.groupId)"
edit = "user.userId == group.createdBy"
delete = "user.userId == group.createdBy"
bash
primitive config push --only rule-set/team-management

Bind a rule set to a group type or collection type via its type config — in TOML (primitive/<env>/group-type-configs/<type>.toml, primitive/<env>/collection-type-configs/<type>.toml) or from the client (client.groupTypeConfigs.create({ groupType, ruleSetId })). A blob bucket attaches one directly via its ruleSetId, where it governs member access per operation — see Blobs and Files.

Two behaviors to know:

  • App owners and admins bypass rule-set evaluation — rules govern regular members.
  • Types without a config row use permissive defaults (any member can create; the creator manages what they created). To deny an operation for everyone except admins, attach a rule set with that operation set to "false".

Rules That Follow a Group's Own Memberships ​

The functions above answer questions about the caller. In a group rule set you can also ask about the group being managed — the groups it itself belongs to — with memberGroupsOf(group.groupId, '<groupType>'), which returns the list of group IDs of that type the group is a member of. This lets a rule follow a relationship graph: for example, grant a teacher access to a group when the group belongs to a class the teacher teaches.

toml
# Allow a caller who teaches any class this group is enrolled in.
get = "memberGroupsOf(group.groupId, 'class-students').exists(c, isMemberOf('class-teachers', c))"

Both arguments are checked when the rule is saved: argument 1 must be the literal path group.groupId (any other expression is rejected), and argument 2 must be a literal group type. Because the group's identity is only trusted once the group exists, memberGroupsOf is available on existing-group operations (get, edit, delete, and the member operations) but not on group.create. The same function works in metadata category rules, where the subject id is a loaded md.self.* value instead.

Testing Rules ​

Rule sets are versioned and testable — evaluate rules against simulated requests before deploying:

bash
# Dry-run a rule set against a simulated scenario
primitive rule-sets test <rule-set-id> --scenario '{"user": {"userId": "u-1"}}'

# Trace an evaluation using real user and group data
primitive rule-sets debug --user <userId> --group-type team --category member --operation create

The same is available from the client as client.ruleSets.test() and client.ruleSets.debug(). For a function's access gate, the fastest loop is invoking it as a specific app user — primitive functions invoke <key> --user <user-id> — or signing in as different test users.

Test gated states as a plain member. App owners and admins bypass function access gates, rule sets, metadata read/write rules, and bucket presets, so you can't exercise a locked or entitlement-gated state while signed in as one: a paywalled feature reads as open to an admin even when the gate is correct. That includes primitive functions invoke without --user, which runs as your own admin app user. Verify that a gate actually denies using a member test user.

Trusting External Identifiers ​

When the server acts on an identifier — opening a billing portal from a payment customer_id, calling a provider API on a user's behalf — it must not trust a value the client supplied. A caller could substitute someone else's id and act on their account.

Keep the authoritative user-to-external-id mapping in a database only your functions write, and resolve it inside the function from the authenticated caller — ctx.user — never from the function's input: query the mapping model filtered on ctx.user!.userId, the same ctx.db access pattern used to read and write records.

A function reads every row of the app on the app's own authority, so the scoping is the code's job: the filter on ctx.user is what keeps one caller from reaching another's row. The same rule holds for ids the app minted itself — a householdId, an itemId — once they arrive as function input rather than as something the server derived. "External" describes where the id came from into this request, not who defined it.

Patterns That Hold Up ​

  1. Default-deny, then open up. Start restrictive (hasRole('admin')) and widen deliberately. A function with no access gate is denied to every caller.
  2. One group type per concept. Separate team, org, and role-like types keep expressions readable: size(memberGroups('team')) > 0 || isMemberOf('org', 'staff').
  3. Membership over IDs. Manage who's in the group, not the rules — rules referencing groups rarely need to change.
  4. Gate the function, scope in the code. The access gate says who may run a function; which rows or documents that caller may touch is the function's code, filtering on ctx.user.

Next Steps ​

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30