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:
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 creatorStandard 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 / function | Meaning |
|---|---|
user.userId | The caller's user ID (empty string when unauthenticated) |
user.role | The 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:
# 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.
| Surface | What the rule gates | No rule set | Details |
|---|---|---|---|
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 authority | Denied — push refuses code for a function with no gate, and a function without one denies every caller with FUNCTION_ACCESS_DENIED | Server Functions |
| Groups and collections (rule sets) | Who can perform management operations on groups and collections | Permissive defaults — any member creates; the creator manages what they created | below |
Blob buckets (preset / ruleSetId) | Member-level access to a bucket's blobs, per operation | Cannot happen — a bucket carries a preset or a rule set | Blobs and Files |
Database types (rule set, ruleSetName) | Who may edit or delete a database type's configuration | Denied for non-admins | Working 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 owners | Locks |
Metadata categories (readRule + writeRule) | Who can read or write one category of a resource's metadata | Denied for non-admins | Resource Metadata |
Server-stamped fields (trigger when conditions) | Whether a computed field applies to this write | Not a caller gate — the field simply always applies | Server-Stamped Fields |
| Documents | Who may read, write or share a document | Not CEL — per-document permission grants (owner, read-write, reader) to users, emails and groups | Sharing Documents |
| Notification send, run reads, admin routes | Sending a notification, reading a function run, and the app's administrative routes | Not 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 role | Notifications |
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:
# 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"primitive config push --only rule-set/team-managementBind 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.
# 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:
# 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 createThe 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
- Default-deny, then open up. Start restrictive (
hasRole('admin')) and widen deliberately. A function with noaccessgate is denied to every caller. - One group type per concept. Separate
team,org, and role-like types keep expressions readable:size(memberGroups('team')) > 0 || isMemberOf('org', 'staff'). - Membership over IDs. Manage who's in the group, not the rules — rules referencing groups rarely need to change.
- Gate the function, scope in the code. The
accessgate says who may run a function; which rows or documents that caller may touch is the function's code, filtering onctx.user.
Next Steps
- Users and Groups — The groups your rules check membership against
- Server Functions — The
accessgate in practice