analytics
Portafolio de Iván
Projects chevron_right Multi-tenant B2B platform

Production system · curated B2B

lock_person RLS in the database payments Money in cents verified_user 2FA science Vitest + Playwright

A multi-tenant B2B platform for travel experiences

This is not an open marketplace: only approved agencies get in, and each of them sees strictly their own data. Isolation is not trusted to application code — it is enforced by the database.

Key numbers

RLS

isolation enforced by PostgreSQL, not by the app

0

monetary amounts in floating point: everything in cents

1 role

allowed to bypass RLS, and only for authentication

200 → ∞

silent list truncation became cursor pagination

Tech stack

Next.js 15 (App Router)Strict TypeScriptTailwind 4shadcn/uiPrismaPostgreSQL 16 with RLSBetter Auth + 2FARedisResendVitest + Playwright
schema

Where each guarantee lives

Every rule sits in the deepest layer possible. The lower a guarantee lives, the fewer ways there are to bypass it by accident.

database Database

Row Level Security

PostgreSQL decides which rows each tenant can see. A badly written query cannot see more.

key Exception

A single privileged role

One role can bypass RLS, and it is restricted to the authentication adapter.

payments Domain

Money in cents

Never floating point: amounts are integers and do not accumulate rounding error.

policy Tests

The voucher never leaks the supplier

An automated test verifies the customer's document never shows supplier data.

list API

Cursor pagination required

Replaces the silent 200-row truncation of the first version.

store

Context

A private company runs a curated network of travel experiences: pre-approved travel agencies quote, book and pay for activities operated by third parties. Not anyone can sign up and not anyone sees everything; that filter is precisely the product.

That means tenants who must not see each other live in the same database, alongside suppliers whose commercial data must never reach the end customer even though it appears in the same booking flow.

The system also handles money: quotes, bookings and payments. Any rounding or visibility error is not a cosmetic bug, it is a commercial dispute.

report

The problem

In multi-tenant systems the question is not whether code will leak data between tenants, but when.

Trusting isolation to filters written into every query works until someone writes a new query at seven on a Friday evening. No bad faith is required: forgetting one tenant filter is enough, and at that moment one tenant sees another's business.

The same risk shows up in documents. A voucher is assembled from booking data, and the booking contains the supplier. If the document is generated «with whatever is there», the supplier's information reaches the end customer without anyone deciding it.

And there was a concrete debt from the first version: lists were silently truncated at 200 rows. Anyone querying a busier period got an answer that looked complete and was not, which is the worst way to be wrong.

rule

Key decisions

Each of these rules is written as non-negotiable inside the repository itself, so it outlives whoever wrote it.

01 Isolation is enforced in the database

PostgreSQL has Row Level Security enabled: the engine decides which rows each tenant can see, not the application layer.

psychology Because a rule living in code depends on every query remembering it, and one is enough to break it. If it lives in the database, the forgetful query simply gets no foreign data.

02 One exception, tightly scoped

There is a PostgreSQL role allowed to bypass RLS, and its use is restricted exclusively to the authentication adapter.

psychology Every real security rule needs a door for the process that validates identities. What matters is that the door is one, is named, and is good for nothing else; a fuzzy exception ends up being used «just this once» across half the app.

03 Money is an integer

All amounts are stored and computed in cents, never in floating point.

psychology Floating-point numbers do not represent monetary amounts exactly and accumulate differences when summed. In a system where the customer compares their total against the supplier's, that one-cent difference ends in a phone call.

04 The voucher does not leak, proven by a test

An automated test verifies that the document the customer receives never includes supplier data. Sensitive fields are also encrypted at field level.

psychology It is the same logic as the rest of the system: a rule that only exists in the team's head disappears with the team. Written as a test, any future change that breaks it is stopped before it reaches production.

05 Cursor pagination, mandatory

The second version removed the silent 200-row truncation and requires cursor pagination on listings.

psychology An incomplete list presented as complete makes someone take decisions on half the data without knowing it. I would rather force pagination — more work for the API consumer — than return a comfortable lie.

construction

The solution

The application is Next.js 15 with strict TypeScript over PostgreSQL, with its own authentication supporting a second factor, Redis caching and transactional email for confirmations. Booking documents are generated from the app itself.

The interesting part is not the framework, it is where each guarantee sits: isolation in the database engine, money as integers in the domain model, supplier confidentiality in an automated test, and list integrity in the API contract.

Tests run at two levels: unit tests for domain rules and end-to-end tests for the complete quote, booking and payment flows.

emoji_events

Results

lightbulb

What I learned

That multi-tenant security is won or lost in the deepest layer. Everything solved in the database stops depending on the discipline of whoever writes the next query.

That writing rules as «non-negotiable» inside the repository is not bureaucracy: it is what keeps them alive when the project changes hands.

And that an incomplete answer pretending to be complete is more dangerous than an error. An error is visible; a truncated list is believed.