Skip to main content
connic
Build

Access Rules

Decide what an app's visitors and an agent's tools may read and change in the database, files, and Retrieval of an environment, and in apps also in runs, sessions, and approvals. Rules run on every request and tool call, and anything without a rule is denied.

Last updated

Apps and agents use the same access rules. access in apps/<key>.yaml or agents/<name>.yaml holds one rule per resource and operation: an expression about who asks and the record.

  • An app's rules check every request of its data API. 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.
  • An agent's rules check every call of the database, file, and Retrieval tools, also when your own tools call them through connic.tools. Without access, these tools can use all data of the agent's environment. See Agents.

Rules

apps/portal.yaml
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
agents/support.yaml
access:
  database:
    "*":
      read: true
    tickets:
      read: user == null or doc.author_id == user.id
      insert: true
      update: user == null or doc.author_id == user.id
  files:
    uploads:
      read: file.session_id == session.id
      write: true
  retrieval:
    help:
      search: true
    notes:
      search: true
      store: true
ResourceKeysAppsAgents
databasecollection name, or "*"read, insert, update, delete, set, fieldsread, insert, update, delete, set, fields
filesfolder, or "*"read, write, update, delete, setread, write, update, delete, set
retrievalnamespace, or "*"searchsearch, store, delete
runsread, cancel
sessionsread, delete
approvalsread, decide

A missing resource, collection, folder, namespace, or operation is denied. YAML true and false are rules too: read: true lets everyone read. Quote a rule that starts with a quotation mark, as in read: '"finance" in user.groups', so YAML reads it as text.

The entry "*" covers every collection, folder, or namespace without an entry of its own. A named entry wins over it, even an empty one: secrets: {} next to "*": {read: true} denies every operation on secrets. Quote the key, because YAML reads a bare * as an alias.

Names

Each rule sees user, the lookups, and the record of its operation; agent rules also see context and session. Using a name that isn't available fails connic lint.

NameAvailable inFields
userevery ruleapps: the visitor, with id, email, name, groups, claims, signed_in. Agents: the app visitor the run acts for, with the same fields, or null
contextagent rulesthe run's context
sessionagent rulesid of the agent's current session; null without a session
<lookup>every rule except a lookup's wherethe document's fields plus id, created_at, updated_at; null when nothing matched. With values: a list
docdatabase read, update, delete, fieldsthe stored fields plus id, created_at, updated_at
newdatabase insert, update; files write, updatethe document or file after the write, including the fields set writes; see How rules apply
filefiles read, update, deletepath, folder, name, size, mime_type, metadata, session_id, created_at, updated_at
runapp runs read, cancelid, agent, status, context, input, output, queued_at, ended_at, duration_ms
sessionapp sessions read, deleteid, agent, state, created_at, updated_at
approvalapp approvals read, decideid, agent, tool, params, status, context (the run's context), created_at

In apps, user is the 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.

In agents, user is the visitor of the app whose request started the run, also in the agents that run starts with trigger_agent. connic takes it from the signed app request, so middleware and tools can't change it. Runs no app visitor started, such as from cron, a webhook, or the dashboard, have user null.

Agents

Rules in agents/<name>.yaml decide what the agent's built-in tools may do, whether the model calls them or your own tools call them through connic.tools. A denied call returns an error instead of a result, and lists and counts only see allowed records.

  • Database tools use database rules. db_upsert needs insert and update, and is denied when its filter matches only documents the rules hide.
  • File tools use files rules, and so do browser downloads into files, browser uploads from files, files that retrieval_store imports, and share links in retrieval_query results.
  • Retrieval tools use retrieval rules: search for retrieval_query, store for retrieval_store, and delete for retrieval_delete. A search without a namespace needs the entry "*".
  • Once an agent has access, connic lint fails when it lists a built-in tool whose resource has no entries, because every call would be denied. Each resource takes up to 100 entries.
  • Rules on user deny runs without a visitor. Write user == null or ... to let those runs through, and keep in mind that a set value that reads user denies the write when there is no visitor.
  • lookups sit next to access in the agent file. Their where can also use context and session, and a lookup can't be called context.

Deploys check agent rules like app rules and fail with the agent file and the rule's path. See Replace database, retrieval, and files for agents that still use the older settings.

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 bare doc.done is rejected.
  • A rule can't compare two fields of the record, or a field of the record with new.
  • In database update rules, use new through its fields, as in new.status, and compare each with a value: two fields of new can't be compared.
  • doc.id, file.session_id, run.id, run.status, approval.id, and approval.status take only ==, !=, in, and not in.
  • Field names can't start with _ or $ or contain dots. Stored system fields are id, created_at, and updated_at. The same goes for the names under set and fields, and for each part of a lookup's values path, which may have several parts, as in team.id. connic lint and the deploy reject other names.

Every rule also follows these limits:

  • is and is not work only with the literals null, true, and false.
  • A comparison can't take another condition as a value, so (doc.done == true) == false is rejected. Combine comparisons with and, or, and not.
  • Database and file rules, and the where of lookups, become database filters with these limits: 10 levels of nesting, of which the filter connic wraps around access rules takes one, 100 branches per and or or, 256 terms, and 1,000 values per in list.

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

deployment log
App validation failed:
  - apps/portal.yaml: access.database.tickets.read: can't compare two fields of doc

How rules apply

For each request or tool call, connic evaluates every part of a rule that doesn't involve the stored record: user, the lookups, new, and in agents context and session. What's left about the record becomes a filter that connic adds to the caller'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 in apps. For a single run, file, session, or approval, a record the rule doesn't allow answers 404, like one that doesn't exist.
  • insert and files write check new, the document or upload with the fields set writes, before the write. Every document of an insert must pass, or nothing is inserted.
  • Database update changes only the documents the doc part allows. new is each document after the change: the fields the update sends, and the stored values for the rest. A field the update sets to null is gone from new. So update: new.status == "open" lets an update of the title through on open tickets.
  • Files update sees the file before the change as file and after it as new. Moving a file to another folder also needs that folder's write rule.
  • An upsert needs insert and update. It updates the first document the rules let change, or inserts one the insert rule allows, and is denied when its filter matches only documents the rules don't let change.
  • A rule can still make a filter that is too large for one request, for example doc.team in user.groups for someone in more than 1,000 groups. The request then fails, in apps with 500 An 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, and so is user == null in an agent run without a visitor. 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: true isn't 1, and "1" isn't 1, but 1 equals 1.0.
  • <, <=, >, and >= compare two numbers or two texts, texts by code point. Any other pair is unknown.
  • x in list checks the list's items with the same typed equality, and an empty list gives false. x in object checks the object's keys. x in text is true only when x equals the whole text, never for a part of it, so "vip" in doc.tags fits a list of tags. In anything else, in is unknown.
  • Timestamps, such as doc.created_at, file.updated_at, run.queued_at, or approval.created_at, compare as points in time with ISO 8601 text, as in doc.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 be true or false; anything else is unknown.

set

set lists fields that connic writes itself, over whatever the caller sent. A plain value writes on insert. The form with value and writes also writes on every update, or only then. Under files, set writes metadata: on insert means on upload or file_write, and on update means on every files update, renames included.

apps/portal.yaml
access:
  database:
    tickets:
      set:
        author_id: user.id            # on insert
        updated_by:
          value: user.id
          writes: [insert, update]
  files:
    uploads:
      set:
        uploaded_by: user.id          # metadata of each upload

Each value is an expression over user and the lookups, and in agents also context and session. 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 write is denied. On insert, connic overwrites the set fields the caller sent, and an insert that sends a field connic writes only on update fails. An update that names any set field fails, in apps with 400 Field 'author_id' is set by the app and can't be changed.

fields

fields holds a rule per top-level field of a collection. The rule sees doc, so it can depend on the document as well as on who asks.

apps/support.yaml
access:
  database:
    tickets:
      read: doc.author_id == user.id or member.role == "agent"
      update: doc.author_id == user.id or member.role == "agent"
      fields:
        internal_notes: member.role == "agent"
  • Reads return the field only from documents where its rule holds. Other documents come back without it.
  • The rule is checked against the document as returned. When a find's fields list leaves out a field the rule reads, the field it guards is left out too, so include the fields your field rules read.
  • A filter, a sort, or a distinct on a field whose rule doesn't hold for every document is denied, so a field can't be found out by searching for its value.
  • An insert that sends the field needs its rule to hold for the new document. An update that sends the field changes only the documents where its rule holds.
  • Fields that set writes don't need a rule here. Fields without a rule are read and written under the collection's rules alone.

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, and in agents also context and session. connic runs a lookup only when a rule uses it, at most once per request or tool call, and takes the oldest matching document. The lookup collection needs no access rule of its own, and the caller can't read it unless one allows it.

With values, a lookup is the list of one field's values in every matching document instead, for rules such as membership in several teams. Documents without the field add nothing. A lookup that matches more than 1,000 documents fails the request with The lookup <name> found more than 1000 documents. Narrow its where.

apps/projects.yaml
lookups:
  teams:
    collection: memberships
    where: doc.user_id == user.id
    values: team_id
access:
  database:
    projects:
      read: doc.team_id in teams

Lookup names are identifiers, such as member, and can't be user, doc, new, file, run, session, approval, true, false, or null, and in agents not context.

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 entry "*" covers the whole tree, the root folder included, wherever no other entry does.

A list shows one level: the files of the folder that its rule lets the caller read, and the subfolders that hold at least one file the caller 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 that no entry covers 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.

Retrieval namespaces

Keys under retrieval are Retrieval namespaces, such as help or docs.public. A namespace follows the rules of the most specific entry that holds it, and "*" covers the rest. The rules see who asks and the lookups, not the entries.

A search returns passages from the namespace searched and every namespace below it, so it covers them whatever their own rules: with help and help.internal listed, a search in help also returns passages from help.internal. Keep content for different audiences in separate namespaces, such as public.help and internal.help. store and delete follow the entry of the namespace they write to. Apps always search in a namespace, so entries without one 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:

  1. Add a challenge service, such as a CAPTCHA, to the form. It adds a token to the submission.

  2. Send the form to a sync webhook connector with apps_only: true at /_connic/c/<key>, instead of inserting it through the data API.

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

  4. Leave insert out of the collection's rules in the app, so the browser can't write past the check.

middleware/contact-form.py
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 content

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

Replace database, retrieval, and files

Agents can still limit their tools with the older database, retrieval, and files settings, which are deprecated. connic enforces them as the access rules below. connic lint warns about them and prints the matching access block to paste in their place, and deploys warn in the log. An agent can't have both access and the older settings.

Older settingAccess rules
database, files, or retrieval left out next to the othersan entry "*" with every operation
no collections, folders, or namespaces listedthe settings go into an entry "*"
collections: [orders]orders: read, insert, update, delete
prevent_write: truedatabase without insert and update, files without write and update, retrieval without store
prevent_delete: truewithout delete
session_only: trueread, update, delete: file.session_id == session.id; write: session.id != null

A setting on an entry wins over the top-level one, and nested namespaces follow the most specific entry.

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.

apps/notes.yaml
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.id

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

apps/orders.yaml
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"

An agent working for a visitor

The orders app above calls a support agent. When a visitor starts the run, the agent reads and changes only that customer's orders. Runs from cron or a webhook, without a visitor, reach every order.

agents/order-support.yaml
tools:
  - db_find
  - db_update
lookups:
  member:
    collection: members
    where: doc.email == user.email
access:
  database:
    orders:
      read: user == null or doc.customer_id == member.customer_id
      update: user == null or doc.customer_id == member.customer_id

Files per session

Each customer has their own session, and the agent sees only the uploads written in the current one. Templates are shared and read-only, and nothing can be deleted.

agents/support.yaml
session:
  key: context.customer_id
tools:
  - file_list
  - file_read
  - file_write
access:
  files:
    uploads:
      read: file.session_id == session.id
      write: true
      update: file.session_id == session.id
    templates:
      read: true

Published content on a public app

Anyone can read published articles. Drafts stay hidden, and nobody can write.

apps/help.yaml
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. With set: {uploaded_by: user.id} under uploads, connic writes the tag itself.

apps/portal.yaml
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 <= 10000000

Runs the visitor started

Visitors follow the runs they started through the app's connectors and cancel them while they wait or run.

apps/portal.yaml
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.

apps/finance.yaml
access:
  approvals:
    read: '"finance" in user.groups'
    decide: '"finance" in user.groups and approval.tool == "issue_refund" and approval.params.amount < 5000'

Anyone can search the articles in help and the namespaces below it through retrieval/search, and open their kept files through files/download. On an app that requires sign-in, a rule such as search: '"support" in user.groups' limits search to a group.

apps/help.yaml
auth:
  mode: public
access:
  retrieval:
    help:
      search: true
  files:
    retrieval/help:
      read: true