Access Rules
Decide what an app's visitors may read and change in the database, files, runs, sessions, and approvals of its environment, and which Retrieval namespaces they may search. Rules run on every request, and anything without a rule is denied.
On this page
The data API gives an app's browser code the project data of the app's environment. access in apps/<key>.yaml holds one rule per resource and operation: an expression about the visitor and the record. An app without access can't read or change any data. The app's Access tab in the dashboard shows the rules that are deployed.
Rules
lookups:
member:
collection: members
where: doc.email == user.email
access:
database:
tickets:
read: doc.author_id == user.id or member.role == "agent"
insert: user.signed_in == true
update: doc.author_id == user.id and doc.status == "open"
delete: member.role == "admin"
set:
author_id: user.id
status: '"open"'
files:
attachments:
read: member.role == "agent"
runs:
read: run.context.end_user.id == user.id
retrieval:
help:
search: true| Resource | Keys | Operations |
|---|---|---|
| database | collection name | read, insert, update, delete, set |
| files | folder | read, write, update, delete |
| runs | read, cancel | |
| sessions | read, delete | |
| approvals | read, decide | |
| retrieval | namespace | search |
A missing resource, collection, folder, namespace, or operation is denied. YAML true and false are rules too: read: true lets every visitor read. Quote a rule that starts with a quotation mark, as in read: '"finance" in user.groups', so YAML reads it as text.
Names
Each rule sees user, the lookups, and the record of its operation. Using a name that isn't available fails connic lint.
| Name | Available in | Fields |
|---|---|---|
| user | every rule | id, email, name, groups, claims, signed_in |
| <lookup> | every rule except a lookup's where | the document's fields plus id, created_at, updated_at; null when nothing matched |
| doc | database read, update, delete | the stored fields plus id, created_at, updated_at |
| new | database insert, update; files write, update | insert: each document sent; update: the update object; files write: path, size, mime_type, metadata of the upload; files update: the same fields after the change |
| file | files read, update, delete | path, folder, name, size, mime_type, metadata, created_at, updated_at |
| run | runs read, cancel | id, agent, status, context, input, output, queued_at, ended_at, duration_ms |
| session | sessions read, delete | id, agent, state, created_at, updated_at |
| approval | approvals read, decide | id, agent, tool, params, status, context (the run's context), created_at |
user is the signed-in visitor; see Sign-in for what id, groups, and claims hold in each mode. run.input and run.output can be read as JSON, as in run.input.order_id. Runs started from an app carry the visitor in run.context.end_user, and approvals carry it in approval.context.end_user.
Grammar
Rules use connic's expression language, the same one as approval rules and conditional tools: comparisons, and, or, not, in, not in, paths with dots or subscripts, and literals, without function calls or arithmetic, up to 2,000 characters. Because connic turns the record part of a rule into a query filter, these limits apply to the record names doc, file, run, session, and approval:
- Compare a field with a value, as in
doc.done == true. A baredoc.doneis rejected. - A rule can't compare two fields of the record, or a field of the record with
new. doc.id,run.id,run.status,approval.id, andapproval.statustake only==,!=,in, andnot in.- Field names can't start with
_or$or contain dots. Stored system fields areid,created_at, andupdated_at.
Every rule also follows these limits:
isandis notwork only with the literalsnull,true, andfalse.- A comparison can't take another condition as a value, so
(doc.done == true) == falseis rejected. Combine comparisons withand,or, andnot. - Database and file rules, and the
whereof lookups, become database filters with these limits: 10 levels of nesting, of which the filter connic wraps around access rules takes one, 100 branches perandoror, 256 terms, and 1,000 values perinlist.
connic lint checks syntax and names. Deploys of public apps warn in the deployment log about rules that let every visitor read everything, write without depending on the visitor, or upload without a size limit. The deploy checks that connic can enforce every rule and fails with the rule's path, for example with use is and is not only with null, true or false or it compiles to a database filter that is too large: <reason>:
App validation failed:
- apps/portal.yaml: access.database.tickets.read: can't compare two fields of docHow rules apply
For each request, connic evaluates every part of a rule that doesn't involve the stored record: user, the lookups, and new. What's left about the record becomes a filter that connic adds to the browser's own. Lists, counts, totals, updates, and deletes therefore only ever touch allowed records.
- A rule that is false whatever the record denies the request with 403. For a single run, file, session, or approval, a record the rule doesn't allow answers 404, like one that doesn't exist.
insertand fileswritechecknewbefore the write. Every document of an insert must pass, or nothing is inserted.- Database
updatechanges only the documents thedocpart allows, and thenewpart checks the update. A rule onnew.statusfails when the update doesn't setstatus. - Files
updatesees the file before the change asfileand after it asnew. Moving a file to another folder also needs that folder'swriterule. - A rule can still make a filter that is too large for one visitor, for example
doc.team in user.groupsfor someone in more than 1,000 groups. The request then fails with 500This app's access rule makes a database filter that is too large: <reason>. Simplify the rule or the values it compares with.
Missing values and null
A comparison is unknown when one of its sides is missing, such as an absent field or a field of a lookup that found nothing, or when it is null. not leaves unknown unknown. and is false when any part is false, or is true when any part is true, and otherwise an unknown part makes the result unknown. A rule that ends unknown denies. So not (member.blocked == true) denies a visitor without a member document.
Only a comparison with the literal null tests for null. doc.x == null and doc.x is null are true when the field exists and is null, false when it holds another value, and unknown when it is missing; != and is not give the opposite for an existing field. A lookup that found nothing counts as present and null, so member == null is true. doc.status != "archived" matches only documents whose status holds another value; add or doc.status == null to include those where it is null.
Comparisons
- Values compare as JSON. Each type equals only its own:
trueisn't1, and"1"isn't1, but1equals1.0. <,<=,>, and>=compare two numbers or two texts, texts by code point. Any other pair is unknown.x in listchecks the list's items with the same typed equality, and an empty list gives false.x in objectchecks the object's keys.x in textis true only when x equals the whole text, never for a part of it, so"vip" in doc.tagsfits a list of tags. In anything else,inis unknown.- Timestamps, such as
doc.created_at,file.updated_at,run.queued_at, orapproval.created_at, compare as points in time with ISO 8601 text, as indoc.created_at >= "2026-01-01T00:00:00Z". Text without an offset is UTC. - A value on its own as a condition, such as
user.signed_in, must betrueorfalse; anything else is unknown.
set
set lists fields that connic writes on every insert, over whatever the browser sent. Each value is an expression over user and the lookups, so author_id: user.id records who wrote a document and status: '"open"' writes a fixed text. When a value can't be evaluated, for example because the lookup it reads found nothing, the insert is denied. An update that names a set field fails with 400 Field 'author_id' is set by the app and can't be changed.
Lookups
A lookup finds one document in a collection for rules that depend on stored data, such as a member's role. collection names the collection, and where is an expression over doc and user. connic runs a lookup only when a rule of the request uses it, at most once per request, and takes the oldest matching document. The lookup collection needs no access rule of its own, and the browser can't read it unless one allows it.
Lookup names are identifiers, such as member, and can't be user, doc, new, file, run, session, approval, true, false, or null.
Folders
Keys under files are folders, with or without slashes. A rule covers its folder and everything below it, and the most specific folder wins: with rules for reports and reports/private, files in reports/private/2026 follow reports/private. The root folder can't have a rule, so files directly in it stay out of reach.
A list shows one level: the files of the folder that its rule lets the visitor read, and the subfolders that hold at least one file the visitor can read under that file's own rule. Files under a folder whose rule denies reading stay hidden, unless a more specific folder below has a rule that allows them. Listing a folder without a rule of its own, such as the root, returns no files but the subfolders that hold readable files. Only when no rule at or below the folder can allow reading is the list denied with 403.
Retrieval namespaces
Keys under retrieval are Retrieval namespaces, such as help or docs.public. A search rule covers its namespace and every namespace below it, because a search returns passages from all of them. The rule sees user and the lookups, not the entries, so it decides whether the visitor may search the whole namespace.
A listed namespace can't sit inside another listed one: next to help, help.internal fails the deploy with help already covers it; declare only one of them. Keep content for different audiences in separate namespaces, such as public.help and internal.help. Entries without a namespace can't be searched from an app.
Public visitors
On a public app, user.signed_in is false, user.email and user.name are null, user.groups is empty, and user.id is the browser's visitor ID.
Comparisons with a public visitor's email are unknown, so doc.owner == user.email matches no document, not even one whose owner is null, and a lookup with where: doc.email == user.email finds nothing.
connic derives the visitor ID from a secret in the browser's cookie, separately for each app, so knowing someone's ID doesn't let anyone act as them. The ID changes when the cookies are cleared, and anyone can get a new one. Rules on user.id fit data that belongs to one browser, such as a draft, not data that needs an account.
Protect public forms
Anyone can submit the forms of a public app, bots included. Writes through the data API are checked against your rules but run no middleware, so they can't check a challenge. For forms that bots target, protect the form yourself and let an agent do the write:
Add a challenge service, such as a CAPTCHA, to the form. It adds a token to the submission.
Send the form to a sync webhook connector with
apps_only: trueat/_connic/c/<key>, instead of inserting it through the data API.In the agent's middleware, verify the token with the challenge service and stop the run when the check fails. The agent then writes with database tools.
Leave
insertout of the collection's rules, so the browser can't write past the check.
import os
import httpx
from connic import StopProcessing
async def before(content, context):
token = context["payload"].get("challenge_token")
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
os.environ["CHALLENGE_VERIFY_URL"],
data={"secret": os.environ["CHALLENGE_SECRET"], "response": token},
)
if not response.json().get("success"):
raise StopProcessing("The form check failed. Reload the page and try again.")
return contentThe verification request depends on the challenge service; the example sends the token and a secret to the service's verification URL, both stored as Variables. StopProcessing ends the run before the agent starts, and the form gets the message as the run's output. A rate_limit on the connector caps how many runs the form can start per minute. connic doesn't check challenges itself.
Examples
Own records
Signed-in visitors see and change only the notes they created. set stamps the owner on insert, and updates can't change it.
access:
database:
notes:
read: doc.owner_id == user.id
insert: user.signed_in == true
update: doc.owner_id == user.id
delete: doc.owner_id == user.id
set:
owner_id: user.idAdmins through a lookup
On an app that requires sign-in, a members collection, maintained by agents or imported, holds each person's customer and role. Customers see their own orders, and admins see and change all of them.
lookups:
member:
collection: members
where: doc.email == user.email
access:
database:
orders:
read: doc.customer_id == member.customer_id or member.role == "admin"
update: member.role == "admin"Published content on a public app
Anyone can read published articles. Drafts stay hidden, and nobody can write.
auth:
mode: public
access:
database:
articles:
read: doc.status == "published"Files by group and metadata
The billing group reads invoices. Every visitor uploads PDFs of up to 10 MB into uploads, tagged with their own ID from /_connic/me, and sees only their own uploads.
access:
files:
invoices:
read: '"billing" in user.groups'
uploads:
read: file.metadata.uploaded_by == user.id
write: >-
new.metadata.uploaded_by == user.id
and new.mime_type == "application/pdf"
and new.size <= 10000000Runs the visitor started
Visitors follow the runs they started through the app's connectors and cancel them while they wait or run.
access:
runs:
read: run.context.end_user.id == user.id
cancel: run.context.end_user.id == user.id and run.status in ["queued", "started"]Approvals for a group
The finance group sees every approval of the environment and decides refunds under 5,000.
access:
approvals:
read: '"finance" in user.groups'
decide: '"finance" in user.groups and approval.tool == "issue_refund" and approval.params.amount < 5000'Help center search
Anyone can search the articles in help and the namespaces below it through retrieval/search. On an app that requires sign-in, a rule such as search: '"support" in user.groups' limits search to a group.
auth:
mode: public
access:
retrieval:
help:
search: true