Who is asking decides what an answer contains. This is the one rule the control plane is built on, and it is enforced in the service layer rather than route by route, so that adding a route cannot be a way of forgetting it.
The provider surfaces are not part of this. An integration authenticates with its application's own credential; nothing below applies to it.
#Two credentials, and they are not the same thing
| The operator token | A session token | |
|---|---|---|
| Where from | ADMIN_TOKEN |
POST /auth/login |
| Acts as | admin |
the person's own role |
| Is a person | No | Yes |
| For | Bootstrap, CI, the seed, the console's server before anyone exists | Everything a human does |
The operator token acts as an administrator because it is how the first
administrator comes to exist — but it is nobody, and anything that has to
belong to a person refuses it. Registering an application or opening a bank
account with it fails with owner_required unless it names whose it is.
There is no default operator token. The API refuses to start without one of at least sixteen characters, because an emulator shipping a well-known password is an emulator with no password.
The first administrator is created through POST /auth/bootstrap, which the
operator token opens. Somebody has to exist before anybody can sign in to create
anybody. npm run setup makes that call as its last step, and
npm run admin:create makes it on its own; both go through the HTTP surface
rather than the database, so neither can create an account the API would have
refused.
#The three roles
| Role | Is | Sees |
|---|---|---|
admin |
The operator of this installation | Everything |
merchant |
A business | Its own applications, its own money, its own accounts |
customer |
A buyer | Its own wallet and accounts — in the checkout, not in the console |
A person carries two optional keys that tie them to the ledger: walletKey
(where their money comes from when they pay with balance) and merchantKey
(under what name they are paid). A key that does not match the role is refused,
because it is a key nothing would ever read.
#How a route is guarded
RolesGuard resolves the credential, then compares the caller's role against the
route's @Roles(...). Absent means administrators only: the control plane's
default has to be the closed one.
Method-level @Roles overrides the controller's, which is how the banks
catalogue is readable by everyone and writable only by an administrator —
choosing where your account is means seeing the list.
#How rows are scoped
Guarding a route says who may call it. It says nothing about which rows come back. That is a single expression per module, written once:
visible(caller): FilterQuery<Entity> {
if (caller.role === 'admin') return {}
if (!isPerson(caller)) return {}
return { owner: caller.user.id }
}
Every listing and every single-row read intersects it. Two consequences worth stating out loud:
- An id the caller may not see answers
404, never403. A403on a real id confirms the id is real, which turns an error code into a way of enumerating other people's records. - A filter can narrow the scope and never widen it. The endpoint's own scope is intersected with the caller's filters, not merged.
#Sessions
A session is a row with a hashed token and an expiry. Presenting an expired one deletes it and answers as if it never existed, which also keeps the table from growing a tail of dead rows nobody sweeps. Changing a password ends every session opened with the old one.
The console never holds a session token in the browser: it is sealed into a cookie by Nitro, and every proxied request is made as the person who signed in. The operator token is the fallback and only that. See the console.
#Related
- Control-plane authorization — where this sits in the architecture.
- The list contract — what a caller may ask of a listing.
- Fake bank and ledger — what
walletKeyandmerchantKeyname.