# Who can see which rows A Backend table is private until a rule says otherwise. Write rules as plain sentences, open a table to visitors only by typing its name, and check them with the policy tester. URL: https://docs.qaldy.com/guides/backend-access/ "Your database is public if you forget to turn the rules on" is one failure behind several well-known leaks: a table reachable by people who should not reach it, and nobody said so on purpose. In a Qaldy [Backend](https://docs.qaldy.com/guides/backend/) a table is **private until a rule says otherwise**, opening one to the public takes a person typing its name, and a rule is a sentence anyone on the team can read. :::preview Preview Row rules and sign-in tokens are built and covered by tests against the real API and a real runtime host. They have not had outside review. Not built: fetching the public keys of your identity provider by address, Telegram sign-in, a per-person block list. ::: ![The Access tab of a backend: rules written as sentences for the orders table, and a card showing what visitors, members and admins get.](https://docs.qaldy.com/screens/access.webp "The Access tab of a backend: rules as sentences, and who gets what. Sample data.") ## Safe by default | Default | What happens | |---|---| | **No browser origin** | A new backend allows no website. A page from an unlisted origin is refused with `origin_not_allowed`. Add your site in **Settings**. A project made before this release keeps what it had, and Settings flags a `*`. | | **Tables start private** | A new table has no rules: only servers (secret keys) and the workspace can use it. | | **Typed confirm to open a table** | Any change that gives **visitors** (people who are not signed in) something they did not have, such as a new action, one more column, or more rows, needs the table's name typed back. A rule that is the same, shows fewer columns, or narrows the rows asks nothing. | | **A person decides, never an agent** | A secret key or a personal token over MCP cannot make a table public (`public_needs_person`), and rules, identity and settings are console-only. | | **`qaldy_` is reserved** | Table names, new column names, app manifest tables and the runtime host's SQL access refuse `qaldy_`, `sqlite_` and `pragma_`. | | **Least-privilege keys** | A publishable key reads. A secret key reads and writes rows but cannot change the schema unless it was given `schema:write`. A key can be limited to some tables. | | **Limits** | 600 requests a minute per key; 120 a minute per address for a publishable key; bodies up to 256 KB; inserts up to 500 rows; failed keys and tokens 30 a minute per address. | ## Who is asking | Caller | How they arrive | Do row rules apply? | |---|---|---| | **A server** | A secret key (`qk_sec_`) | No. Limited by the key's scopes and its table list | | **A workspace member** | A console session, or a personal token or MCP | No. Limited by the member's role and the token's scope. This is what exports use | | **A visitor** | A publishable key and no token | Yes | | **A signed-in person** | A publishable key plus `Authorization: Bearer ` | Yes, as that person | A token that fails verification is `401 token_invalid`, never a quiet downgrade to "visitor". ## Rules are sentences Open the backend, then **Access**. A rule reads like this: ``` members can see rows where owner = me viewers can read only status and total visitors can add only email, name and source admins can see, add, change and delete all rows members can change only status on rows where owner = me and archived is false signed-in people can see rows where team = my team ``` The shape is ` [can] [only ] [on|of|from] [all rows | rows [where ]]`, in English for now. - **Who:** `anyone`, `visitors`, `signed-in people`, a role in the plural (`members`, `viewers`) or `people with role `. - **Verbs:** `see` or `read`, `add`, `change`, `delete`, and `manage` for all four. `only ` limits what is shown (read) or written (add, change). - **Condition:** comparisons joined by `and`, or groups joined by `or` (no brackets): `=`, `!=`, `>`, `>=`, `<`, `<=`, `is one of a, b`, `is empty`, `is not empty`, `is true`. A value is a word, a number, `"quoted text"`, `me` (the person's id) or `my `. At most 8 comparisons per rule and 16 rules per table. Nobody writes SQL. The sentence is parsed into a closed, typed rule and stored as that rule, and the sentence you read is rebuilt from it, so **what you read is what is enforced**. ### What the rules mean - **A person can do something only if a rule says so.** No matching rule is `403 policy_denied`, with the reason and an example sentence. Rules for the same people **add up**: the rows are the union. - **Reads are masked per row.** "Members see rows where owner = me" plus "members read only status and total" gives everyone's status and total and only their own other columns; the rest read `null`. - **Changing and deleting need seeing.** A person can change or delete only rows they can see, and the changed row must **still match** the rule, so `owner = me` cannot be turned into someone else's row (`403 policy_violation`, nothing is written). - **Adding** checks the new row against the rule; `owner = me` fills `owner` in when it is left out. - **Servers and workspace members** are not an audience: rules do not apply to them. ### No oracle through the query A column the caller cannot read at all is **"no such column"**, in `select`, filters and `order`, the same as a column that does not exist. A column visible only on some rows cannot be filtered or sorted by (`column_not_filterable`), so `?email=ilike.*bob*` cannot answer questions about a value the person may not see. `Prefer: count=exact` counts only rows in the caller's scope, and a row outside it is `404`, the same as a row that does not exist. The API has no joins, embeds, `or=`, aggregates or full-text search; if they are added they inherit the rules. ## People who sign in with your own system Qaldy does not issue sign-ins and keeps no signing key of yours. You paste the **public** keys that your own server or identity provider signs with (**Access**, **People who sign in**), and the data API verifies each token with them: - The algorithm must be RS256, ES256 or EdDSA and the one the key's type allows. `none`, HS256 and anything else is refused, so a public key cannot be used as a shared secret. A pasted private key or shared secret is refused by name and never stored. - A `kid` naming one of your keys (at most 8) is required; RSA keys under 2048 bits are refused. - The issuer must equal the one you pinned and the audience must include yours. `exp` is required, not past and at most 24 hours ahead. `sub` is who "me" means. - The role comes from the claim you choose; no claim means `member`. Other simple claims (at most 24) feed "my team". - Nothing from a token is logged or echoed in an error. To cut a person off, stop signing tokens for them (they last at most 24 hours) or rotate the key. ## Try the rules before you trust them **Try the rules** on the Access tab: pick a visitor, a signed-in person (id, role, details) or one of the backend's keys, an action and an optional query. A read runs the real data path and returns the rows that caller gets, with the hidden cells marked; a bypass attempt returns the data API's own refusal. A write is a rehearsal: nothing is written. ## Moving an existing backend A table that was public-read becomes the rule "anyone can see all rows" (marked "From the old public switch"); every other table has no rules. Existing secret keys keep full access, publishable keys stay read-only, and existing projects keep their origins. Nothing changes behaviour until you change it. ## Known gaps - Rules and warnings are written in English; the console's Russian and Kazakh translate the screens, not the sentences. - Hiding by *row* is exact. Hiding by *column* uses `null`, so a masked cell and a real null look alike. - A rule change reaches other API processes within 10 seconds (at once on the one that saved it). - Limits and caches are per API process. The pilot runs one. - The sample backend in the demo ships with rules so the tab is not empty; a real backend starts with none.