Skip to content

Roles in the app

2 min read · main part only; folded sections are optional

After this you can answer: How does the API decide that only an admin may create an event?

flowchart TB
R["events.create"] --> A{"session?"}
A -->|"no"| E401["401 UNAUTHORIZED"]
A -->|"yes"| B{"role lookup in Postgres"}
B -->|"account missing"| E401
B -->|"user"| E403["403 FORBIDDEN"]
B -->|"admin"| OK["create the event"]
B -->|"lookup failed"| E500["500 INTERNAL_SERVER_ERROR"]
  1. Three kinds of procedure. publicProcedure lets anyone in. protectedProcedure adds the session check. adminProcedure adds the role check on top.
  2. 401 first. isAuthed throws UNAUTHORIZED when there is no session: “we do not know who you are”.
  3. Then the role. isAdmin looks up the role by user id. A missing user gives 401. A user role gives 403 FORBIDDEN: “we know who you are, and you may not do this”.
  4. Fail closed. If the role lookup fails (Postgres down), the request fails. We never let someone through when we cannot tell.

The middleware lives in apps/api/src/trpc/init.ts → adminProcedure. events.create is an admin route; browsing events is public; booking is protected.

The role column has a default of user and a database CHECK that allows only user or admin, so even a typo from a script is refused (apps/api/src/db/schema/users.ts).

There is no screen for roles, on purpose. The make-admin script changes the role in Postgres and revokes the user’s sessions, so they must log in again (apps/api/src/scripts/make-admin.ts).

Hiding the admin link in the web app is only for looks; the API check is the real one.

See the API app on the map →

Roles live in Postgres, not in AWS IAM. The API log shows 401 and 403 answers with their correlation ids.

SymptomCauseWhere to look
A new admin still gets 403 after logging in againThe role change went to the wrong emailThe users row
An admin page loads, but saving gives 403The UI shows the page; the API checks the roleWorking as designed
Admin routes return 500 while the cached events list worksPostgres is down; the role check fails closed/ready, the API log
Why not store the role in the session?

Without revocation or another refresh mechanism, a copy could stay old until the session expires after 7 days. Reading Postgres avoids that stale role. The trade-off is one primary-key lookup per admin check and a dependency on database availability.

Why not look up the role in createContext for every request?

Then every route, even the public events list, would depend on Postgres for something only admin routes need. With Postgres down, a logged-in visitor could not browse cached events.

What is the difference between 401 and 403?

401: not authenticated, logging in may help. 403: authenticated but not allowed, logging in again will not help.

Advanced: Why make-admin revokes sessions

The service revokes sessions before updating Postgres and again afterward. The second revocation removes sessions created by concurrent logins before that point. This is not one atomic change across Postgres and Valkey: a login can save a session after the second revocation, and a request already authorized can finish. Reading the role on each new check still prevents a stale session role from preserving admin access.

Answer these without scrolling up. If one feels hard, reread that section.

  1. Which three procedure kinds exist, and how do they build on each other?
  2. When does the API answer 401, and when 403?
  3. Why is the role read from Postgres on each admin request?
  4. What protects the role column from a typo?
  5. Why is hiding the admin link not security?

Redraw it from memory: the decision: session? then role = admin? with the 401, 403 and OK outcomes

Try this: on the local stack, call events.create logged out and then as a normal user, and compare the two error codes

Read this book online: https://book.theversehub.in/