Files
zeekay cdf0c4c152 A graph's schema is the caller's, and new accounts buy a visitor nothing
The schema a graph of a Base renders named every collection to whoever
asked for it.  It is now as far as the caller reaches: a member sees
the Base whole, a visitor only the collections that admit visitors, and
anyone else only those whose list rule is "".  apis.ReachOf answers the
reach by putting the caller's credential through loadAuthToken, the
door every request passes, so the graph decides nothing of its own.
Each reach keeps its own cached schema, and a root beyond the caller's
reach is no field at all rather than a refusal that says it exists.

A visitor's writes were limited per account, which an account made for
the purpose resets.  They are now also held to fifteen a minute from
one client address and 120 a minute from all visitors in a Base, on
the same limiter.  A write that names no client address is refused.
2026-10-20 20:41:44 -07:00

7.2 KiB

Access rules and identity

A collection has five rules: listRule, viewRule, createRule, updateRule and deleteRule. They are all of Base's authorization. Base checks them in Go on every /v1 request. A list rule becomes part of the query, so a row the caller may not see is neither returned nor counted.

Rule values

Value Who passes A caller who fails gets
null, the default superusers 403 Only superusers can perform this action.
"" everyone, signed in or not
an expression callers for whom it is true list: a page with no items; view, update, delete: 404; create: 400 Failed to create record.

An expression is a filter over the record and the request:

@request.auth.id != ""
owner = @request.auth.id
status = "published" || owner = @request.auth.id
@request.body.role:isset = false
Part Forms
Compare = != > >= < <= ~ (contains) !~
Compare any of several values ?= ?!= ?> ?>= ?< ?<= ?~ ?!~
Combine && || and parentheses
The request @request.auth.* @request.body.* @request.query.* @request.headers.* @request.method @request.context
Another collection @collection.<name>.<field>
Modifiers :isset :changed :length :each :lower

?filter= on list requests uses the same syntax.

Rules also decide what ?expand= returns and what the /v1/rest/{table} reads return. A route you add in a hook applies no rule on its own. Call $app.canAccessRecord(record, e.requestInfo(), collection.listRule) for each record, as the hybrid search example does.

Where identity comes from

Base signs nobody in. It has no passwords, no sign-up and no sessions of its own. A caller is whoever a Hanzo IAM access token says, sent as Authorization: Bearer <token>.

Point Base at IAM to turn this on:

IAM_ENDPOINT=https://hanzo.id ./base serve --http 127.0.0.1:8090

With IAM_ENDPOINT set, Base:

  • verifies every token against IAM's keys at $IAM_ENDPOINT/v1/iam/.well-known/jwks, and treats a token that fails as no token;
  • ignores tokens signed any other way, including those an older Base issued;
  • passes /v1/iam/* through to IAM;
  • lists IAM as the one sign-in provider at GET /v1/collections/users/auth-methods, with IAM's authorize URL and a PKCE challenge;
  • deletes X-Org-Id, X-User-Id and X-User-Email from every request as it arrives. X-Org-Id is read first, as the org the caller asks for.

For the length of one request, the token becomes an auth record that is never saved:

Field From the token
id sub (else preferred_username): kept when it is 15 characters of a-z, 0-9 and _, padded with _ when shorter, otherwise the first 24 hex characters of its SHA-256
email email
name name, else displayName
org_id the org the request acts in, when the auth collection has an org_id field
admin whether the caller administers that org (its IAM role there is owner or admin)
role the caller's role in that org: owner, admin or member, or visitor (below)

Rules read these as @request.auth.id, @request.auth.email and so on, so "only the org's admins delete" is @request.auth.admin = true.

Superusers are the members of IAM's admin org, and nobody else. Their record is mirrored into _superusers; everyone else's into users. A superuser passes every rule, manages collections and settings, and can act in any org. An admin role inside an ordinary org is a different thing and grants none of this.

An org's admins change its schema. In an org's Base, an org admin creates, changes and deletes the org's ordinary base collections through /v1/collections (never auth, view or system collections, nor Base's own _ tables) and installs modules (POST /v1/modules/crm). Every member reads the schema.

Each org has its own database. Base takes the org from the token's memberships and opens <data dir>/orgs/<org>/data.db for the request. Two orgs never share a file, so a query in one cannot return another's rows. A token that belongs to several orgs picks one with X-Org-Id: <org>. Naming an org the token does not carry gets 403, and so does a token with no org.

Visitors. A Base its host opened for one org (a site's) refuses a token that acts in another org, with 403, everywhere except where a collection admits visitors. A base collection does with "visitors": true, which a superuser or the org's admin sets through /v1/collections. On such a collection a visitor may list, view, create, update and delete records and read their files, as its rules allow. Its auth record has role visitor, admin false, org_id its own org, and id and name as above, so a comment that is its writer's alone reads:

createRule: @request.auth.id != "" && author = @request.auth.id
updateRule: author = @request.auth.id
deleteRule: author = @request.auth.id

A visitor reaches nothing else: no other collection, not the schema, settings, batch, realtime, aggregates, history or a file token, and naming the Base's org in X-Org-Id is still 403. Where a visitors' collection relates to another, that collection's rules read the visitor as a guest, so an expand or a filter through the relation shows only what a guest would see. Visitors' writes are held, in each Base, to ten a minute from one account, fifteen from one client address, and 120 from all visitors together. A Base's GraphQL schema is the caller's too: a member sees every collection, a visitor only those that admit visitors, and anyone else only those whose list rule is "". A superuser is never a visitor; it names the org.

Keys stand in for tokens when no person is signing in. IAM issues them. sk- keys are for servers. pk- keys are for web pages and reach only routes marked public, such as record lists and views.

Without IAM

Leave IAM_ENDPOINT unset and the base binary serves one Base with no orgs. There is no sign-in route, so a caller without a token cannot get one: every such caller is anonymous and none is a superuser. That is enough to build against collections whose rules admit anonymous callers. Define the collections in migrations, because /v1/collections needs a superuser.

A users token Base signed before sign-in moved to IAM still counts. A superuser token does not; versions.md says why.

Such a Base prints this when it starts:

(!) No superuser exists yet. Create the first one with:
    ./base superuser upsert EMAIL PASS

That command was removed. Superusers come from IAM.

Who can add users rows

The users collection's createRule is null, so only superusers add rows. Before v1.5.98 a new Base set it to "", which let anyone add them. The upgrade sets null where all five users rules are still the ones Base created, and leaves any other rule set alone. To close one that kept "", run a migration:

migrate((app) => {
  const users = app.findCollectionByNameOrId("users")
  users.createRule = null
  app.save(users)
})

After it runs, an anonymous POST /v1/collections/users/records gets 403.

Rate limits and CORS have defaults to change too. See threat-model.md.