Authorization & Access
Authorization in Foir is a property of your data, not a step in your request. Who can read or write a record is decided in the database itself, on every query — so the rules apply the same way whether a read comes from your storefront, a server-side build script, a background job, or an operator in the admin app. There is no separate permissions service to call and no set of if checks you have to remember to add.
This page explains the access model you configure — tenancy isolation, owner planes, per-record visibility, grants, and roles — and how it reaches your app through the public API.
The problem it solves
If you have assembled a backend by hand, you have written authorization at least twice: once as row filters in the database (Postgres RLS, if you got that far) and again as checks in application code. Both only protect the paths that remember to ask. Miss one WHERE clause, add one new endpoint, run one ad-hoc query, and the rule quietly isn’t there. Multi-tenant leaks are almost always a forgotten check, not a wrong one.
Foir removes the “remember to ask” step. Your access rules are declared once and compiled into Postgres Row-Level Security, so the database is the floor that every query passes through. A query cannot return a row the rules forbid, because the filtering happens below your code rather than inside it.
The isolation model
Tenancy: tenant → project
Every project belongs to a tenant, and every record belongs to a project. A request is bound to a project by its API key: a project key (pk_ / sk_) can only ever reach its own project, and a tenant key (pk_t_ / sk_t_) can reach only the projects on its explicit allow-list — never another tenant, never a project you didn’t list. A stray or forged X-Project-Id header cannot steer a project key somewhere else. Project isolation is the outermost boundary and it is not something your application code enforces — it is enforced for you.
Owner planes
Within a project, each model declares which plane owns its records:
| Owner plane | Records belong to | Typical use |
|---|---|---|
project | the project as a whole (the default) | pages, catalog content, shared configuration |
customer | the authenticated customer who created them | profiles, orders, saved items, anything per-user |
app | an installed app | data an integration manages on your behalf |
You set this per model with the access.ownerPlane field (see Model Access). It is the broad layer of the access model: a customer-owned model means a signed-in customer can only ever read and write their own records — the myProfile / setMyProfile self-service queries never need a permission check because ownership is structural (see Customers).
Per-record visibility
Ownership decides whose a record is; visibility decides how far it reaches by default. Each model sets a defaultVisibility, and any record can be narrowed or widened individually from the editor’s Access tab:
| Visibility | Who can read it |
|---|---|
private | the owner and anyone with an explicit grant (the default) |
public | anyone, including unauthenticated public-key reads |
Published, public content is what a pk_ key returns to a browser. Nothing else is reachable with a public key.
Changing a model’s defaultVisibility re-applies to its existing records: making a model public reveals the records already under it, and making it private again hides them. A record you set individually from the Access tab keeps that setting and is not moved by a later change to the model default.
Grants
When one record needs to be shared beyond its owner, you issue a grant from the Access tab: a scoped, revocable, optionally expiring grant of access to a specific principal. Grants are additive and explicit — the record stays private, and the grant is a visible, auditable edge rather than a widening of the whole model.
Relationship-derived reads
By default a reference from one record to another is structural only — pointing at a record grants no access to it. This is deliberate: if references carried access, anyone who could create a record pointing at yours could read yours, which is a privilege-escalation path. When a record genuinely should be readable by whoever can read a record it points at (an order’s line items, an invoice’s fulfilment list), a reference field opts in with the association_read relationship kind — a read-only, one-hop, declared “borrow.” See Relationship-derived visibility.
Two layers of enforcement
Most access questions are about rows — can this principal read this record? — and those are answered by Row-Level Security, the floor described above. Some questions aren’t about rows at all — can this principal publish? — and those are answered by a verb gate at the request boundary, backed by the same rules. Publishing, for example, is gated by the records:publish scope on the key and the principal’s role, not by a row filter.
Both layers come from one source, so they cannot disagree. You never hand-write a policy on one side and a check on the other and hope they stay in sync.
How it reaches your app
The public API inherits the floor automatically. You do not configure RLS, write policies, or pass tenant IDs — you present a credential and the platform resolves the rest:
- An API key identifies the project and carries scopes that bound what the request may do.
- A customer JWT (short-lived, EdDSA, issuer
foir-customer) identifies the signed-in end user, socustomer-owned records andcurrentUserresolve to that person and no one else. See Customer JWT Authentication.
# With a customer's bearer token, this returns only that customer's orders —
# not because the query says so, but because the model is customer-owned and
# the database enforces it.
query MyOrders {
orders(first: 20) {
edges { node { _id total status } }
}
}A query that asks for something the credential can’t reach doesn’t silently return a trimmed result set with the sensitive rows quietly dropped and nothing to tell you — it returns exactly the rows the rules allow, and scope violations (a public key attempting a write, a preview: true read without drafts:read) fail loudly with an authorization error. See Errors.
Why you can’t misconfigure it
Every table Foir manages has Row-Level Security enabled and forced — there is no table where the isolation is quietly “off,” and no per-project setup step where you might forget to turn it on. Because the row rules, the verb gates, and the session claims are all produced from one definition, the database and the API can’t drift apart, and the rules are re-verified automatically so they don’t regress over time.
The practical effect for you: a forgotten WHERE clause in your query, a new query you add next month, or a report pulled by an operator cannot reach data the rules forbid. The isolation isn’t a convention your team has to uphold on every code path — it’s a property of the data underneath all of them.
Under the hood: Demesne
The engine that compiles these rules is Demesne, an open-source (Apache-2.0) authorization compiler — a Zanzibar-class relationship model that emits Postgres RLS rather than running as a separate Check service. It’s independently published and independently tested (its suite applies the generated SQL to a live database and asserts the policies match what it emitted), so the mechanism behind your data’s isolation isn’t a black box you have to take on faith — you can read it.
You never interact with Demesne directly — Foir compiles your access rules and runs the engine for you. It’s named here so that, if you or your security team want to, you can audit the mechanism enforcing your data’s isolation instead of taking it on trust.
See also
- Authentication — API key types, scopes, and customer JWTs
- API Keys — creating and scoping keys, tenant keys, rotation
- Model Access — declaring owner plane and default visibility in config
- Customers — customer-owned models end to end
- Team Members — operator roles in the admin app