HTTP security and scoped access
Who may ask: a bearer token and CORS, JSON Web Tokens held to a policy file down to the rows, and the keys that verify them. The routes are in the HTTP reference.
Security
Protect the endpoint with a bearer token, and put a TLS terminator in front of it on an open network.
fenec-server --file data.fenec --http 127.0.0.1:8080 \
--http-cors 'http://localhost:8787' \
--http-token "$TOKEN" \
--http-read-only--http-token <value> | every request needs Authorization: Bearer <value>, compared in constant time |
--http-cors <origin> | sets Access-Control-Allow-Origin; without it no CORS header is sent at all. A browser’s preflight (OPTIONS) from that origin, or any with *, is answered 204 before a token is asked for -- the browser sends none with it -- on a single database and under /t/<tenant>/. Every answer exposes Fenec-Seq, X-Request-Id and Retry-After and lets Idempotency-Key in. Through fenec-shard, give the router the flag: it answers the preflight itself and puts its headers on its own answers and in place of the node's (Tenants and sharding) |
--http-read-only | write endpoints return 403; fenec-server's own path is unaffected |
| a replica | every write returns 403, whatever the flags: its writes come from its primary |
| non-loopback address | refuses to bind without a token, unless --insecure is given |
TLS, unfiltered writes, body limits and what a shape leaks
There is no TLS, the same rule as fenec-server: on an open network
it needs a TLS terminator in front. A PATCH or DELETE
without a filter is refused, and covering a whole collection needs a separate
path rather than a flag — a key like ?all=true would
collide with a field named all.
Limits are shared with fenec-server (--max-connections,
--idle-timeout: a connection silent that long between requests is
closed with nothing written, and one that went silent part way through a
request is answered 408); the body ceiling is 64 MiB and the header block
64 KiB. Transfer-Encoding: chunked is unsupported and refused with
411, which beats reading half a body. A field whose name matches a clause name
(select, order, limit,
offset, count, where,
group) cannot be filtered
over HTTP; the FenecQL and fenec-server paths are unaffected.
A shape alone is not a security boundary. Under the server's own token, which ids changed is visible to a subscriber even when the rows are not. Rows are hidden by a scoped token, below.
Scoped access
Give each user a JSON Web Token, and a policy file decides which collections and which rows that token can read or write. A browser can then talk to the database directly.
# collection access rows role
notes read,write where owner = $jwt.sub
posts read where published = true or author = $jwt.sub
posts write where author = $jwt.sub
messages read,insert where room in $jwt.rooms
journal read,insert where owner = $jwt.sub
journal append-only
accounts update(balance, held) for app
orders update(status, run_at) where customer = $jwt.sub and status = "ready" and new.status = "cancelled"
orders read(pickup, run_at) where run_at <= now() for courier
positions read where courier in (get orders select courier where customer = $jwt.sub)
* read for dashboard- Reads only see the rows the rule allows.
- Fields.
read(pickup, run_at)shows the rows it admits with those fields alone, andid: a row shows the fields of every read rule admitting it, the restnull. A courier sees a ready order's pickup, and the customer's door once the order is theirs. - Writes must stay inside the rule, or get 403.
writegrantsinsert,updateanddelete, and each can be granted alone;update(balance, held)grants an update of those fields and no other. - Transitions.
new.statusreads a row as the write leaves it, and a bare name as it stands: theordersrule above lets a customer cancel a ready order, and not take a cancelled one back. returninganswers rows the token may read as the write leaves them: a courier who reads the orders they carry claims a ready one and is answered it, claimed.append-onlycollections take inserts from scoped tokens, and refuse every update and delete with 403.- A list claim, such as
"rooms": ["r1", "r2"], goes afterin. - Another collection.
in (get …)reads a rule's list from rows: thepositionsrule above lets a customer read where the courier carrying their order is, and nothing of the others. - The server's own
--http-tokenstill has full access.
A whole product built on these rules is
examples/saas:
sign-up and sessions owned by the app, a tenant per organisation behind
fenec-shard, roles and teams as grants and list claims, live
boards and search under each person's token, with its threat model and the
attacks its tests try.
Every rule of a scoped token
--http-token is everything or nothing. With
--jwt-secret the endpoint also takes HS256 JSON Web Tokens, each
held to the rules of a --policy file, collection by collection and
down to the rows, so a browser can talk to the database directly with a token
per user:
fenec-server --file data.fenec --http 0.0.0.0:8080 --http-token "$ROOT" \
--jwt-secret-file jwt.secret --policy policy.txt
fenec-server --jwt-secret-file jwt.secret --mint-token '{"sub":"alice","exp":1893456000}'- Reads get the rule's filter ANDed in at every level: the
collection's
where, eachlookupbelow it, a subscription's shape, acount. A collection no rule grants a token anything on does not exist for it: 404, and absent from the list. - Writes get it twice, as PostgreSQL's
USINGandWITH CHECK: asetordeltouches only the rows the filter matches, and every document written — a put, or a row asetrewrote — has to match it afterwards, or the write is refused with 403. A put that leaves out a field the filter pins to a claim gets the claim's value, so{"title":"a"}is alice's. A scoped put names noid, which could overwrite a row the token cannot see, and a scoped token changes no schema and runs no maintenance. returninganswers what the write wrote, so the rows it answers are held to the read rules: aset's as it wrote them, adel's as they were. A row the read rules leave out by a field the write does not change is passed over, as agetpasses it over; asetthat would leave a row the token may not read is refused with 403 and writes nothing. PostgreSQL asks itsSELECTpolicies of the row before anUPDATE … RETURNINGas well as after, which is why a courier who reads the orders they carry, claiming a ready order, would take nothing there and be told nothing; here the terms over the fields thesetwrites are asked of the row it leaves, and the claim answers the order, claimed. Withoutreturninga write's rows are those its own rules reach, read rules or not.- Grants are
read,insert,updateanddelete, joined by commas, andwriteis the three writes. An insert is a put of new documents, held to the filters of the rules grantinginsert; an update is asetand a delete adel, of rows the token may read and the filters of the rules granting them, aset's rows checked again after.update(balance, held, status)names the fields an update may change: a write over a row — aset, a RESTPATCHorPUT, a line of a/batch— is judged by the fields it changes, and each of them must be one rule's whose filter admits the row before and after, or it is refused with 403 naming a field (a batch holding it is put back whole). A field written with the value it holds is not changed. Rules widen each other, each with its fields and its rows:notes update where owner = $jwt.subbesidenotes update(title)changes every field of the token's notes and the title of anyone's. A token may insert into a collection it cannot read, such as an event stream: its reads of it are refused with 403 and it is not listed.expiredgrants the rows past their@ttl, whichexpired()reads (the reaper): a statement asking it is held to the filters of the rules grantingexpiredbeside the ones its operation needs, and neitherwritenor*grants it — a lapsed hold is the reaper's, not a user's to see again. - Fields a read shows.
read(pickup, run_at)grants the rows its filter admits with those fields andid;readgrants every field. A row shows the fields of every read rule admitting it, and the rest answernull— in/query,/batch, REST, eachlookuplevel by its own collection's rules, a write'sreturning, a subscription's seed and changes, and the MCP tools. Besideorders read where courier = $jwt.sub for courier, the rule above shows a courier every ready order's pickup and the whole of the orders they carry. A field no read rule grants is not there:select *leaves it out,describe,/collectionsand/_schemado not list it, and naming it is refused (403) as a name the collection lacks is.A field withheld from a row must not show through what a statement does with it either. Aget orders where dropoff = $1 -- no ready order matches, whatever its door get orders where not (dropoff = $1) -- every ready order does: no door told from another get orders order dropoff -- 403: the order would tell the doors get orders where courier = $1 order dropoff -- $1 their sub: every order reached shows itwhereterm reading it holds only where the row shows it —dropoff = $1is asked as(courier = $jwt.sub and dropoff = $1), theorof the filters of the rules grantingdropoff— in aget, asetordel, alookuplevel, a facet's and an innerget's;andstops at the first false, so the term is never worked out over a value it may not see.order,group,having, afacet, an aggregate,match,near, a highlight, alookupkey and aset's values read a field only where every row they reach shows it, and are refused with 403 naming it otherwise: when every read rule of the collection grants it, or when one rule granting it has no filter or one whoseandterms are each a term of the statement's ownwhere, parameters bound — as the last line above. A computed column over such a field isnullwhere the row does not show it and never worked out there, and an innerget's column of one is listed from the rows showing it. A write reaches the rows it reached before; itsreturningis masked as a read's rows are. PostgreSQL's column privileges are a role's and the same on every row, and refuse a statement naming a column the role lacks,SELECT *included; a column shown on some rows is a view ofCASE WHEN … THEN col ENDthere, whoseWHEREreadsNULLon the rest where this reads the term as false. A token whose rules name no fields reads as before; one whose do loses the stored-bytes path of a plaingetwhere a column is masked, and pays each rule that could show a row more, per row: over HTTP on loopback a row by id took 28.0 µs against 26.0 under the same two rules naming no fields, a page of twenty 39.5 against 34.4, about 0.13 µs a row of it in process (make policy-bench). - Transitions. A rule's filter reads the row as it stands, and
new.<field>reads it as the write leaves it, asnew.names the document in an upsert'selse set. One filter asked of the row before and after cannot say which way a row may go:status = "ready" or status = "cancelled"lets a customer cancel an order and take a cancelled one back, andrun_atgranted lets them move it to the head of the queue. A rule namingnew.is a transition, asked of the two rows together:A write over a row is the token's when one rule granting# a customer cancels a ready order, out of the queue for good orders update(status, run_at) where customer = $jwt.sub and courier is null and status = "ready" and new.status = "cancelled" and new.run_at is null for customer # a courier claims an order whose lease is out, for themselves, counting the attempt orders update(status, run_at, courier, claim, attempts) where run_at <= now() and new.status = "assigned" and new.courier = $jwt.sub and new.attempts = attempts + 1 for courier # and delivers only what they carry orders update(status, delivered_at, run_at, claim) where courier = $jwt.sub and (status = "assigned" or status = "picked") and new.status = "delivered" and new.run_at is null for courierupdatenames every field it changes (or names none) and admits the change: a transition by being true of the row before and after together, any other rule by admitting each. Rules naming no fields and nonew.widen each other as before, the row before one rule's and the row after another's, as PostgreSQL's permissive policies do; a transition lends neither half. Asetpicks the rows a transition reaches by what the row before can answer of it —status = "ready"of the first rule above — and each row it writes is then asked the whole rule, a refusal 403 and the statement (or the batch) put back. The row after is held to what the rule says ofnew.and nothing more: the field list keeps the rest where it was, so a transition with none should pin what must not move (new.customer = customer). A rule namingnew.grantsupdatealone — a read, an insert or a delete has no row after, or none before — and one granting more stops the server at startup. A scopedsetof one row over HTTP on loopback took 5.0 µs more than the server token's 45 µs under a plain rule, 5.7–5.9 under a field list and 6.1–6.8 under a transition (make policy-bench). append-onlyis a line of its own,journal append-only, with no filter and no role. No scoped token updates or deletes there, by any route —/query, REST,/batch— whatever its rules grant:writeand*meaninsertthere, and a rule namingupdateordeletefor the collection stops the server at startup. It binds JSON Web Tokens and not the server's own token: corrections, erasure and@ttl's sweep stay with whoever holds that, and an app server that should only append is given a scoped token of its own, a role for instance.- List claims. A claim that is a JSON array goes after
in:room in $jwt.roomsmatches the rooms it lists,room in [$jwt.rooms, "lobby"]those and one more, and a text claim there is a list of one. It holds reads, writes (WITH CHECK: a post into a room the list does not hold is 403) and subscriptions alike. A list holding anull, a"", a list or an object is no claim, and the rule naming it does not apply;[]is a list of none. A token whose list claim holds more than 1 000 values is refused (401). Aroleclaim may be a list too, and a rulefora role applies when the list holds it. Membership kept in another collection is a rule'sin (get …)(below). - Rules that read another collection. A rule's filter takes
in (get …), its claims bound inside as anywhere else:The inner# a customer follows the courier carrying their order positions read where courier in (get orders select courier where customer = $jwt.sub and (status = "assigned" or status = "picked")) for customer # a member reads and posts in the rooms they belong to messages read,insert where room in (get members select room where user = $jwt.sub)getreads the rows of its collection the token's owngetwould, held to the token's rules there; one no rule lets the token read gives none of its rows, as PostgreSQL's row security gives a policy's subquery none of a table no policy grants. The fields it reads are the policy's own: its list is never shown, only what it admits. It is answered before each statement, under the lock that runs it, as theinlist it is: reads, alookuplevel, a facet, amatchand an agent's MCP tools see the list as it stands, and a write'sWITH CHECK— an insert's document, an update's row before and after, a transition's pair, an upsert's row written over,returning's rows — is tested by it where no query could run. A rule naming the fields it shows may read another collection too,positions read(courier, at, loc) where courier in (get orders …): each row is shown the fields of the rules admitting it as their lists stand, in a read, alookuplevel and a stream (Fields a read shows). A/batchanswers it again for each statement: a courier's claim, then a read of the customer's door the claim lets them read, is one round trip. The list is held to a list claim's bound, 1 000 values, and a statement whose list would hold more is refused (400) rather than answered with a list cut short; a value held by many rows counts once. Rules that read each other's collections —positionsreadingorderswhose rules readpositions, at any depth, whatever their roles — stop the server at startup, since none of them could be answered first; so do a rule on*that reads a collection, a claim anywhere in the innergetbut itswhere, and what an innergetmay not say anyway. A scoped subscription whose rules read another collection is bound again when that collection is written: the courier enters a customer's stream ofpositionsas the order is claimed and leaves it, a deletion of the row it was sent, as it is delivered, with no write topositionsbetween. It costs one innergeta statement — a@hashbucket's read, 1.8 to 2.1 µs in process and 4.3 to 4.7 µs over HTTP on loopback above a rule naming a claim — and a stream one at each write to the collection its rules read: beside 100 scoped streams a write took 1.89 ms of the server's CPU against 1.65 under a rule naming the courier as a claim, every scoped stream looking at every write either way. A token with no such rule runs as it did (make policy-bench,ARGS=streams). matchscores over the rows the token may read. BM25's statistics — how many documents, their mean length, how many hold each word — are taken over the rows the query's filter selects, the token's rules among them, not over the collection: there a user's own memo scored 9.87, then 4.79 once another user wrote 200 private memos holding the same word, which told the first how many of the second's held it. A score is what a collection holding the user's rows alone would give, and so is the order ofmatch,rerank's candidates andfuse's text side. It costs the token's query its rows' lengths and each word's count among them: over 100 000 documents, a filter keeping 2.5% took 0.16 ms and takes 0.24, one keeping half 5.4 and 6.2. The server's own token scores over the collection, as before.- Subscriptions are told only of rows they were sent: a scoped subscriber hears of a row leaving its shape only if the row was in it, so the ids other users write never reach it — rows leaving as a rule that reads another collection moves among them. Where a rule names the fields it shows, a row written again that shows what it showed is not sent: a write to a field the subscriber does not see would tell it when.
- Tokens are HS256, or RS256 with
--jwt-keys(below). The algorithm is the key's, not the token's, soalg: noneis refused. Every token is checked againstexpandnbf, and one with noexpis refused (401): it would be good for ever, and a token that leaked could only be outlived by rotating every key.--jwt-require-exp offtakes such tokens again, and--jwt-max-age <s>also refuses one whoseexplies further ahead than that.--mint-tokengives claims naming noexpone an hour away. A rule withforapplies to tokens whoseroleclaim names that role, or lists it; rules on one collection widen each other; a rule whose filter names a claim the token lacks does not apply, and grants nothing. Nor does one naming a claim the token holds asnull, as"", as an object, or as a list where the rule reads one value:"sub": nullis no subject, andowner = $jwt.subdoes not becomeowner = null, which every row with no owner matches. A number and a bool are values like any other. A policy names its parameters by claim alone: one writing$1or$3of its own stops the server at startup. A subscription is checked when it opens, and ended at its token'sexpwithevent: errorand{"error":"the token has expired","status":401}: the client takes it as a refused request, fetches a fresh token and opens the stream again (FenecHttp.live,subscribe, the sync layer, Go's*Errorand .NET'sEvent.Status). Without it a stream outlived its token, and a member taken off a team heard the team until the client went away. No other request is held open past its token, nor before it: a read, a subscription or a/_changespage sent withFenec-Afterwaits only once its token is taken — one the server refuses is answered 401 at once, never after the wait — and is checked again once its wait ends, a write held byFenec-Waitasks its token again each time it runs, and/_changestakes no scoped token. - Refusals tell a scoped token only what it may read: a
@uniqueclash (409) names the field, not the document holding the value nor the value, since that row is another user's. - Tenants. On a
--dirnode a token reaches/t/<t>/only when itstenantclaim names<t>(a text, or a list holding it), and is refused with 403 under every other tenant's prefix; one without the claim is refused there unless the node runs with--jwt-unbound-tenants, and one holding it asnull,""or anything else naming no tenant is refused even then (Tokens).
The server's own --http-token keeps full access alongside.
Keys, rotation and identity providers
To accept tokens from Auth0, Clerk, Firebase or another identity provider,
give --jwt-keys its published key file. Edit the file to rotate
keys; no restart is needed.
# the provider's public keys, as it publishes them
curl -s https://example.auth0.com/.well-known/jwks.json > jwks.json
fenec-server --file data.fenec --http 0.0.0.0:8080 --jwt-keys jwks.json --policy policy.txtHow keys, algorithms and token checks work, timed
--jwt-keys takes a JWKS file, {"keys": [...]}, in
place of a secret. An oct key verifies HS256 tokens and needs at
least 32 bytes. An RSA key of at least 2048 bits verifies RS256
tokens, which is what Auth0, Clerk, Firebase and most identity providers
sign with. A token issued by one of them is taken with its public keys
alone. Keys of any other kind (EC, OKP) are skipped. A key is only used for
its own algorithm: an RSA modulus is public, and taken as an HS256 secret it
would let anyone sign. A token that names a kid is checked
against that key only. A token without one is checked against each key of
its algorithm.
The server looks at the file at most once a second and reads it again when it has changed, so keys rotate without a restart. To rotate:
- Add the new key to the file.
- Start signing with it.
- Once the tokens signed with the old key have expired, remove the old key.
If the file can no longer be read, the server keeps the keys it read
last and logs why. --mint-token signs with the first
oct key and names its kid.
A token that has already been verified is kept by its text, so that later requests with it skip the check. The server keeps up to 4 096 tokens, and the kept tokens are dropped whenever the keys change. Checking a token from that cache takes 0.3 µs for HS256 and 0.5 µs for RS256. Checking a new one takes 2.9 µs for HS256 and 38 µs for RS256 over 2048 bits (Apple M1).