Auth, roles and permissions
Who a request is, what it may do, and which records it may touch. Those are three separate questions in Bronit, and keeping them separate is the whole design — see Three questions, three answers.
Sessions
Login is email + password against the Admin collection
(POST /api/auth/login). There is no external identity provider.
On success a signed session cookie is set — a JWT signed with
SESSION_SECRET via jose. The code lives in src/shared/auth/session.ts.
| Cookie | Lifetime | Why |
|---|---|---|
session | 7 days | The dashboard. SameSite=Lax, which is real CSRF protection |
tma_session | 24 hours | The Telegram Mini App. Must be SameSite=None to exist inside Telegram’s webview at all, so it is a separate cookie rather than loosening the one above |
The payload:
{
userId, email, name,
role, // see Roles
companyId, companySlug, // the tenant; null for Bronit's own staff
hotelId, hotelSlug, // an admin's anchor hotel
hotelIds, serviceIds, // the scope — see Scoping
permissions, // per-account extras — see Permissions
impersonatorId, impersonatorEmail,
expiresAt,
}Sessions outlive deploys, and that constrains changes
A cookie minted before a release is presented to the code that comes after it,
for up to seven days. So any new session field must be optional and must
degrade to today’s behaviour when absent. permissions is the current
example: undefined means “no extras”, and the role’s own set still applies.
Reading an absent field as “denied” would lock out every signed-in user for a
week.
Roles
Six, in two groups. The split is in src/shared/auth/roles.ts, and it is
enforced — npm run check:session-roles fails the build if a role is added to
the union and classified in neither group.
Bronit’s own people — INTERNAL_ROLES
Reach the superadmin tree — hotel.bronit.uz/<locale>/secure/superadmin/**.
Never reach a customer’s dashboard.
It used to be a subdomain,
super.bronit.uz, and the hostname was the gate. That record was deleted in the hostname split; the tree is now reached by role on the one hostname, and the guards were never hostname-based to begin with — every one of them reads the signed session. (super.bronit.uzexists again as bronit-super, a separate application with its own operator accounts.)
| Role | Who |
|---|---|
superadmin | The platform owner. Everything |
staff:finance | Pricing, trials, orders. Cannot delete a business |
staff:support | Reads a customer’s setup, fixes their televisions |
staff:sales | The demo queue, trials, creating a business |
What each may actually do is INTERNAL_ROLE_PERMISSIONS in
src/shared/auth/permissions.ts — a table, changeable without touching any
guard.
Customers — TENANT_ROLES
| Role | Who |
|---|---|
owner | The business owner. Every hotel in their company, plus Settings |
admin | Their staff. Scoped to granted hotels, kept out of Settings |
owner and admin are still the two session roles, and they still decide
which side of the platform somebody is on. What they no longer decide is what a
person may do — that is a Role, defined by the business.
Permissions
module.resource.action — core.pricing.write, tv.devices.read. Strings, not
an enum, because a module ships its own through the contributions registry.
The catalogue is PERMISSION_CATALOGUE, and every entry carries a description
that is rendered to humans rather than kept for developers.
if (!hasPermission(session, 'core.pricing.write')) return 403
// or, as a route guard:
const session = await requirePermission('core.pricing.write')
if (session instanceof Response) return sessionhasPermission is synchronous and reads no database: a permission check on the
request path that costs a round trip is one somebody eventually skips.
Two catalogues, and they must not be crossed
| Catalogue | Bundled by | Guard | |
|---|---|---|---|
| Bronit staff | PERMISSION_CATALOGUE | code — INTERNAL_ROLE_PERMISSIONS | requirePermission(p) |
| A customer’s staff | TENANT_PERMISSION_CATALOGUE | the business owner, in Settings → Roles | requireTenantPermission(p) |
Using the internal helper on a tenant route refuses every owner and admin
alive; npm run check:permissions fails the build if one does.
Roles, for a customer’s staff
A Role is { companyId, name, permissions[], isSystem, key }. Every company
has two built-ins seeded on creation — Owner and Staff — which hold the
complete tenant catalogue, because that is what both could do before roles
existed. An owner clones one and unticks something to make a receptionist who
cannot see revenue, or a waiter who can only take orders.
Built-ins cannot be deleted or have their permissions changed: a company whose Owner role held nothing would have nobody able to open the screen that could put them back. A role still assigned to somebody cannot be deleted either — moving its holders onto Staff would silently widen their access.
How a permission is resolved
The cookie carries roleId, not a permission list, and the list is read
server-side and memoised for the request. The alternative — codes in the JWT —
was rejected twice over: the cookie already carries hotelIds and serviceIds
against a ~4KB ceiling and the browser silently drops one that overflows, and a
list baked into a 7-day cookie means a revoked permission takes a week to bite.
A cookie minted before roles existed carries no roleId and falls back to the
same constants the built-ins are seeded from, so it keeps working with exactly
the access it had.
Every permission on offer is enforced
A catalogue that describes the product and enforces half of it is an editor that
lies. check:permissions fails the build if a tenant permission is offered that
no route reads, so growing the catalogue means guarding a route in the same
commit.
Impersonation
A superadmin’s own password authenticates into any non-superadmin account. From that moment the session is indistinguishable from the customer’s own except in the audit trail, which records the impersonator rather than the impersonated.
It is core.impersonate, and it is granted per account, never by role —
Admin.permissions, ticked on the staff screen. Every other permission answers
“may somebody doing this job do this thing”; this one answers “do we trust this
named human to become a customer”, which is revocable without demoting them.
Existing superadmins were granted it by migration 0017, so nothing an operator
could do before this shipped stopped working.
Scoping
Orthogonal to permissions, and unchanged by any of the above. A permission grants a verb; the scope decides which records. Both must pass.
| Field | Meaning |
|---|---|
companyId | The tenant. Every tenant query is filtered by it |
hotelIds | Every hotel this admin may work in. A security boundary, not a UI convenience |
serviceIds | Bookable services. Empty means all, narrowing within hotelIds and never beyond it |
The helpers are in session.ts: hotelScope(session) builds the filter,
writeHotelId(session, id) validates a hotel named in a request body,
idScope(session, id) scopes a single record.
An owner is unrestricted within their company; an admin is confined to their grants, whatever any screen shows them.
The guards
Each returns the session, or a Response the route returns immediately:
const session = await requireDashboard()
if (session instanceof Response) return session| Guard | Admits |
|---|---|
requireAuth() | Anyone signed in; redirects to /login (pages, not APIs) |
requireDashboard() | owner and admin, with a companyId |
requireOwner() | owner |
requireAdmin() | admin, with a hotel |
requireSuperadmin() | superadmin |
requirePermission(p) | Bronit staff holding p |
requireTenantPermission(p) | A customer’s staff whose role holds p |
requireWritable(session) | Call after the role guard on every mutating route — refuses a lapsed plan and the public demo tenant |
They are allowlists, and that is load-bearing
requireDashboard used to be a denylist — it rejected superadmin and passed
everything else. Around 30 route groups sit behind it, so a role added to the
union would have silently inherited every customer’s dashboard, with nothing to
notice: no error, no failing test, just a route working for the wrong person.
npm run check:session-roles now fails the build if any guard is written as
“not this role” rather than “this role”.
Three questions, three answers
| Question | Answered by | Example |
|---|---|---|
| Who are you? | role | staff:support |
| What may you do? | permissions | tv.devices.write |
| To which records? | companyId, hotelIds, serviceIds | hotels 3 and 7 |
Collapsing any two of these is the mistake the model exists to avoid. A job title is not a permission set — a hotel’s “manager” and a restaurant’s “manager” want different things — and a permission is not a scope, or every new hotel would need a new role.
Checks
npm run check:session-roles roles classified, guards allowlist
npm run check:permissions bundles reference real permissions,
impersonation stays per-account,
every tenant permission is enforced,
built-ins hold the whole catalogue
npm run check:demo-readonly every mutating route refuses the demo tenant