Skip to main content
Read rules are in beta.
A read rule decides every read of a table: a get by ID, an index range, a search, a count. You declare it on the table with .access and write it as an ordinary query that answers the reads Bijection asks it about.
bijection/schema.ts
The overview shows the matching taskRead rule. The rest of this page describes what a rule receives, what it returns, and what callers see when it refuses.

Declaring a read rule

.access takes an object with two required fields:
  • read: a reference to the rule, a query exported from a module of the same component. It can be public or internal; an internal query keeps clients from calling it directly.
  • reads: the tables the rule may read, its policy inputs. List every table the rule queries, including the protected table itself if the rule looks up the row it’s deciding.
.access can be declared on a table or on a view. A table has at most one read rule; several tables can share the same rule.
Referencing the rule with makeFunctionReference<"query">("module:export") keeps your schema from importing ./_generated/api, whose types depend on the schema itself.
When you push your code, Bijection checks each rule. A deployment is refused when the rule isn’t an exported query, when its args aren’t readAccessArgs, or when its returns isn’t readAccessResults.

What the rule receives

A rule receives a batch of requests: every read of the protected table that the calling function made and that has to be decided together.
  • table is the table being read.
  • target is what is being read:
    • object is one document, named by its ID, as ctx.db.get reads it.
    • query is a range or scan. equalities lists the fields the query’s index range pins with .eq(...). They are guaranteed index bounds, not a guess from .filter(). An equality without a value means the field is missing; null is an explicit value. A query with no equalities is a read of the whole table.
  • properties is the list of properties the read uses, including those that decide membership, filtering and order, and _id and _creationTime. null means the whole document.
Every row a query returns also arrives as its own object request, so a rule can decide a range and still look at each row.

What the rule returns

A rule returns one result per request, in the same order: To refuse, throw. A throw refuses the whole batch. Bijection refuses the batch when a result is missing, when there are too many, or when one isn’t a permission. A rule that returns early or falls off its end therefore refuses instead of permitting reads it never looked at. decideEach(requests, decide) calls decide for each request in order and collects the results, which is the easiest way to get this right. A rule that decides the whole table at once, for example “administrators read everything”, still answers each request:
bijection/access.ts

Time-limited permissions

A rule that reads the clock must say how long its answer holds. Return { validUntil } for each permitted request; a rule that observes the time and returns null is refused. validUntil must be in the future and at most 366 days ahead.

Reasons

A reason is a string of at most 256 bytes. It never reaches the caller: it is written to the audit log when the table is audited. To give the reason for a refusal, throw a BijectionError:

Policy inputs

While the rule runs, it can read the tables listed in reads and nothing else. Reading any other table fails with Policy input is outside its admitted scope. The list holds at most 32 tables, and each must be an ordinary table of your schema, not a view. Policy inputs are private to the rule. The caller doesn’t need permission to read them, and nothing the rule computes from them reaches the caller: a refused read reports Read access was refused, whatever the rule threw, and the rule’s console output is not shown to the caller.
Listing a table in reads makes it readable by the rule, not private everywhere. A membership table without rules of its own can still be read and written by any of your functions. Give it its own read and write rules, or only touch it from internal functions.

What callers see

A read rule never changes the data it permits. Refused reads behave as follows:
  • An index range in a query leaves out the rows the rule refuses and returns the rest, as if the refused rows didn’t exist. The rule still has to permit the range itself: if it refuses the range’s query request, the whole query is refused.
  • Every other refused read refuses the whole function with Read access was refused. That includes ctx.db.get, reads of a whole table, searches and counts, and every read inside a mutation.
  • A refusal never redacts. If a rule refuses a property, the read of the document that includes it is refused; Bijection doesn’t return the document with the property removed.
A rule that refuses an ID it can’t find, as taskRead does, answers a hidden, a deleted and a never-existing document the same way, so a refusal doesn’t reveal whether an ID exists.

Reading several documents by ID

getMany from bijection/server reads up to 100 documents of one table and answers each ID on its own. In a query, a refused document answers restricted while the others are returned:
bijection/tasks.ts
absent is only reported to a caller the rule permits to read the whole table; for anyone else a missing ID is restricted. In a mutation, getMany behaves like a series of ctx.db.get calls, and one refusal refuses the whole mutation.

Reading fewer properties

When a caller may read only some properties, ask for only those with .select. The rule then receives just the selected properties plus the ones the query uses to find and order rows:
bijection/customers.ts

Reactivity

The reads a rule makes are dependencies of the query it decided. When a membership row changes, Bijection reruns the subscriptions whose decision read it: a live query that was refused becomes permitted after a grant, and a permitted one is refused after a revocation. A refusal carries no expiry of its own. If access should begin at a later time, make that a committed change, for example with a scheduled function that inserts the membership.
Scheduled functions and cron jobs run without a user identity, so inside a rule ctx.auth.getUserIdentity() returns null for their reads.

Large ranges

Each function can carry at most 128 decided reads of protected tables, and every returned row counts as one. A function that reads more rows than that from protected tables fails with ReadAccessBounds. When your rule decides a whole range the same way for every row in it, declare that with rowsFollowRange. When deciding the range’s rows one by one would exceed the bound, Bijection keeps one decision for the range instead:
bijection/schema.ts
Each entry names an index of the table whose first field is field. The range must pin field with an equality. The rule then receives a query request with covers: true, and its answer decides every row the range covers. A text or vector search whose filter pins the same field searches that range’s rows only. If rows in the range carry their own labels that the rule checks, name that field as markings. Bijection then keeps one decision per distinct value of that field within the range. rowsFollowRange is a promise you make about your rule. In tests and development builds Bijection checks it: when the rule permits a covering request, every row it covers is decided too, and a row the rule would refuse fails the function with RowsFollowRangeViolated. A release build trusts the declaration unless BIJECTION_CHECK_DECLARED_RANGES is set.

Auditing decisions

Add audit: true to record every decision of the table’s rules in the deployment’s audit log: each read its read rule decides, each release its disclosure rule decides and each change its write rule decides.
bijection/schema.ts
A line names the caller, the rule, what it decided, whether it permitted and the reason the rule gave. A refused decision’s line names no document ID and no value. Bijection writes the lines itself, so the function can’t skip them.

Options