Skip to main content
connic
Apps

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.

Last updated

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

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
ResourceKeysOperations
databasecollection nameread, insert, update, delete, set
filesfolderread, write, update, delete
runsread, cancel
sessionsread, delete
approvalsread, decide
retrievalnamespacesearch

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.

NameAvailable inFields
userevery ruleid, email, name, groups, claims, signed_in
<lookup>every rule except a lookup's wherethe document's fields plus id, created_at, updated_at; null when nothing matched
docdatabase read, update, deletethe stored fields plus id, created_at, updated_at
newdatabase insert, update; files write, updateinsert: 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
filefiles read, update, deletepath, folder, name, size, mime_type, metadata, created_at, updated_at
runruns read, cancelid, agent, status, context, input, output, queued_at, ended_at, duration_ms
sessionsessions read, deleteid, agent, state, created_at, updated_at
approvalapprovals read, decideid, 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 bare doc.done is 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, 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.

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, 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.
  • insert and files write check new before the write. Every document of an insert must pass, or nothing is inserted.
  • Database update changes only the documents the doc part allows, and the new part checks the update. A rule on new.status fails when the update doesn't set status.
  • 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.
  • A rule can still make a filter that is too large for one visitor, for example doc.team in user.groups for someone in more than 1,000 groups. The request then fails with 500 This 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: 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 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:

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

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"

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.

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