Users
An open store, the moment it closes, three roles, and the door that does not exist.
A store with no user is open, and anything may run against it. That is a decision and not an oversight: requiring a sign-in against a store with no accounts locks everybody out of it with no way in to fix that.
Declaring the first user is what closes it.
DEFINE NAMESPACE prod;
USE NAMESPACE prod;
DEFINE DATABASE orders;
USE DATABASE orders;
DEFINE COLLECTION notes;
CREATE notes = { body: 'before there were users' };
DEFINE USER ada ON prod.orders ROLE editor PASSWORD 'correct horse';
Everything before that statement ran anonymously. Everything after it does not:
SELECT * FROM notes;
Refused — NotSignedIn, and note that it is a read. A closed store has no
anonymous access at all, not merely no anonymous writes. An application serving
public pages out of one therefore needs an account to read with; there is no
"public" level below viewer.
Closing it without a person there
That order works at a terminal and not in a container: nothing types the first
DEFINE USER into a node that comes up unattended. So a node reads two
variables on the way up, and declares that user as a store-wide owner when
the store has no users at all:
TESSARIDB_INITIAL_USER=root
TESSARIDB_INITIAL_PASSWORD=a long enough one
In a compose file that is the whole of it:
services:
database:
image: tessaridb
command: ["/data", "--serve", "0.0.0.0:7654"]
environment:
TESSARIDB_INITIAL_USER: root
TESSARIDB_INITIAL_PASSWORD: ${DB_PASSWORD:?set this}
Four things are worth knowing before you rely on it.
Both or neither. Setting one without the other stops the node rather than starting it. The alternative is a node that came up open, on a network, because a variable name was misspelled — and looking exactly like one that came up correctly.
It runs once. A store that already has users ignores both variables and says so in its log. That is what makes them safe to leave in a compose file across every restart, and it also means they are not a way to reset a password: once the store is closed they do nothing. A lost owner password is still a restore from backup.
The user owns the store, not a database. Anything narrower could not declare the users that come after it, and could not take a backup.
A variable is not a secret store. It is readable by anything that can inspect the container or the process. That is a smaller circle than a command line and it is not nothing, so treat it the way you would treat any other credential in a deployment file.
There is no back door
DROP USER ada;
NotSignedIn. An anonymous session cannot re-open a store by removing its last
account, and no flag, environment variable or single-user mode does it either.
The consequence is worth knowing before you need it: a lost owner password is a restore from backup, not a recovery. Nobody can let you back in, because the design is that nobody can.
The three roles
These are declared by a signed-in owner, which is why they are shown here rather than run against the anonymous session above:
DEFINE USER reader ON prod.orders ROLE viewer PASSWORD 'a long enough one';
DEFINE USER writer ON prod.orders ROLE editor PASSWORD 'another long one';
DEFINE USER root ROLE owner PASSWORD 'the longest one of all';
| role | may |
|---|---|
viewer | read what the tenancy holds, and nothing else |
editor | read and write records, and define structure |
owner | everything, including declaring users |
Only an owner may declare a user. That is what forces the order at deployment time: the owner is declared first, by the anonymous session that still can; the owner then signs in and declares everyone else. A start-up script that declares the editor first finds it cannot declare the owner afterwards.
Scope
ON prod.orders puts the user in one database. Left off, the user belongs to
the store:
| written | reaches |
|---|---|
DEFINE USER ada ON prod.orders ROLE editor … | that one database |
DEFINE USER root ROLE owner … | the whole store |
A store-scoped owner is what a backup needs, because
BACKUP is the one statement whose subject is
the store rather than the selected namespace — there is no table for a grant to
name.
Authority is a role and a reach
The role says what you may do. The scope says where. Neither is the whole answer on its own, and the second one is what bounds you when the thing you are acting on is another user.
An owner may administer users — but only those in the tenancy they administer, and containment is asymmetric:
| you are | you may administer |
|---|---|
| an owner with no scope | everyone |
an owner of prod.orders | users of prod.orders |
an owner of prod.orders | not a user of prod.billing, and not a store-scoped owner |
There are exactly two reaches, and it is worth saying so plainly: the whole
store, or one database. ON takes a table reference, so ON prod names the
database prod in the selected namespace rather than the namespace — there is
no syntax for a user who owns a namespace and everything under it.
Nothing contains a sibling. Two owners at the same depth are invisible to each other, which is the case that would otherwise leak quietly.
This bounds five statements, not one:
| statement | refused when |
|---|---|
INFO FOR USER <name> | the subject is outside your tenancy |
INFO FOR USERS | you administer nothing — it refuses rather than narrowing |
ALTER USER … | the subject is outside your tenancy |
DROP USER … | the subject is outside your tenancy |
GRANT / REVOKE … TO <name> | the user is outside your tenancy |
DEFINE USER … | the reach being asked for is wider than your own |
The last two are the ones worth reading twice.
A grant narrows. A user with even one grant is reduced to exactly what they
were granted — so aiming a grant upwards takes authority away rather than
giving it, and the first thing to go is the store owner's BACKUP. It is
refused for that reason.
DEFINE USER is bounded by the reach it names, not by the user it creates.
An owner of one database declaring an owner with no scope would be minting a
node administrator and then signing in as them, which is the way round every
other rule on this page. The refusal names it:
DEFINE USER mallory ROLE owner PASSWORD 'a long enough one';
Run by an owner of prod.orders, that is refused — "mallory would reach
further than you do; name a tenancy inside your own".
The one exemption is the moment the store closes: an anonymous session on an open store may declare a store-wide owner, because otherwise no store could ever be closed at all.
Some statements are about the store, not a tenancy
Most statements name a namespace, and naming one you do not hold is refused. A few name nothing — their subject is the whole store, or the machine serving it — so there is no name for that check to look at. Those need an owner with no tenancy of their own:
| statement | needs | why |
|---|---|---|
BACKUP | owner, store-wide | every record in every namespace, past every grant |
INFO FOR NODE · SELECT … FROM $node | owner, store-wide | this machine's roles, endpoints and peers |
DEFINE NODE · DEFINE REPLICA | owner, store-wide | what this machine is, and which others hold the data |
| a peer collecting the log | replicate, store-wide only | the log carries users and their credential hashes, so a namespace owner must not be able to admit a follower |
DEFINE NAMESPACE | editor, store-wide | a namespace is a sibling, and nothing contains a sibling |
Role and reach are two separate questions, and that last row is where the difference shows. A namespace is less than the databases and tables an editor can already define, so declaring one does not need a raised role — it needs a store-wide reach. A store-wide editor may; an editor of one database may not, because for them a new namespace is a sibling of the one they are in.
An owner of prod.orders is refused all four — "owns a part of this store, and
this needs an owner of the whole store". That refusal is deliberately not the
one a viewer gets: being told you are not an owner when you are one sends you
to ask for the wrong thing.
The familiar exemption still holds: an anonymous session on an open store may declare the first namespace, because otherwise no store could be set up at all.
Changing your own password
Everything above is about acting on somebody else. Your own password is different, and any role may change it:
$ curl -s -u ada -X POST --data-binary 'a different long one' \
http://localhost:8000/password
{"changed":true,"tokens_ended":true}
The current password is asked for, and checked. Being signed in is not proof of it: a token can be copied off a plaintext connection or read out of a log, and if holding one were enough to set a new password, a stolen token would be a permanent takeover rather than a temporary one. For the same reason the route takes Basic and refuses a bearer token.
It changes the caller and nobody else — there is no name to give it — which is
why it needs no ownership at all. A viewer whose password may have leaked
should not have to ask an owner to choose them a new one.
Every token that user holds stops working, this session included.
Changing a user afterwards
DEFINE USER refuses a name that is already taken — a rotation spelled as a
re-declaration fails rather than half-succeeding. ALTER USER is the statement
that changes one:
ALTER USER reader SET PASSWORD 'a different long one';
ALTER USER reader SET ROLE editor;
One field per statement, so a password rotation cannot silently reset a role
by restating it wrongly, and a role correction cannot invalidate a password.
There is no SET ON: a reach is fixed when the user is declared, for the same
reason DEFINE USER is bounded by it.
Removing one is DROP USER, and it takes the user's grants with it. A new user
of the same name is a different user and inherits none of them.
Passwords
A password is never an argument on a command line. It arrives in
TESSARIDB_PASSWORD, because an argument is in the process table where any user
on the machine can read it, and a password that has been in a process table has
been published.
$ TESSARIDB_PASSWORD='correct horse' tessaridb --at 127.0.0.1:7654 --user ada
In a statement it is written out, and it prints back as <redacted> — the value
does not reach a log through a debug rendering, because the type that holds it
has no rendering that shows it.
A user can also be declared from the hash the store keeps, which is how a state script brings users back without anybody knowing their passwords:
DEFINE USER ada ON prod.orders ROLE editor PASSHASH '$argon2id$v=19$m=19456,t=2,p=1$…';
The hash is taken only if this store could have made it — Argon2id, at parameters no weaker than its own — so it is not a way to plant a credential cheaper to guess than any the store would write.
Guessing is made slow, not impossible
Repeated failures against one identity are throttled rather than locked out. The numbers are fixed and not configurable:
| 3 consecutive failures are free | someone mistyping a password twice is ordinary; a third consecutive miss is where a person stops guessing and a program starts |
| then 250 ms, doubling each further failure | a fixed delay is a fixed guess rate, and a fixed guess rate is a budget an attacker can plan against |
| up to 30 s between attempts | doubling without a ceiling reaches days, and a real user who mistyped six times should not be locked out for one |
A delay rather than a lockout, deliberately: a lockout on a name anyone can
guess is a way to deny service to the person who owns it. The refusal is
SignInThrottled, which is distinct from SignInRefused — one says not now
and the other says not this password.
On a cluster the budget belongs to the cluster, not to each node (from
0.21.0-beta): a clustered node asks the leader whether a name may try a password
and reports how the try went, so three nodes are not three allowances. A node
that cannot reach the leader in time falls back to its own count.
Separately, the process runs at most 24 password verifications at once. A password hash is expensive on purpose, so that bound is what keeps a burst of sign-ins from becoming the node's whole CPU budget; it is not the connection ceiling, which is larger and counts something else.
Signing in once
That bound is also why a password should be spent once rather than on every
request. Over HTTP, POST /session exchanges one for a token:
$ curl -s -u ada -X POST http://localhost:8000/session
{"token":"5f3a…","expires_in":43200}
Every request afterwards carries the token instead:
$ curl -s -H 'Authorization: Bearer 5f3a…' -d 'SELECT * FROM orders;' \
http://localhost:8000/script
The difference is not small. A password costs an Argon2 hash — nineteen mebibytes and tens of milliseconds, deliberately — and a token costs a lookup. Sending the password every time also means at most 24 of those requests are in flight at once, whatever the connection ceiling says.
DELETE /session hands a token back. A token also expires on its own after
twelve hours, and a node's tokens live only in that node's memory, so a restart
ends every session it had issued.
A token stops standing for anybody the moment the account behind it changes. Rotating a password, correcting a role, or removing the user kills every token that user held, on the next request:
| what you run | what happens to their tokens |
|---|---|
ALTER USER ada SET PASSWORD '…' | all dead |
ALTER USER ada SET ROLE viewer | all dead |
DROP USER ada | all dead |
That is what makes a rotation worth doing. A token that outlived the password it came from would make changing a password a note in the catalog rather than a change in what somebody may do.
Grants are the exception, and deliberately: a GRANT or REVOKE narrows a
token-carrying session on its very next statement rather than ending it. Taking
authority away should take effect sooner than giving it back, not later.
A token is a bearer credential — whoever holds the text is that user until it dies. It travels in a header exactly as a password does, over a connection this node does not encrypt. What it is better at is that it can be thrown away and it expires; a password does neither.
The wire protocol needs none of this: a connection is a session there, so the sign-in happens once when it opens.
Narrowing further
A role says which verbs a user may use. A grant says which tables they may use them on, and a user with even one grant is reduced to exactly what they were granted.