
IA Spaces — Coworking Platform
India Accelerator · Full Stack Lead, 2025 – Present · Visit
- Stage
- In production across the IA Spaces chain
The first real incident on the platform wasn’t a breach — it was a demo. A prospective operator’s occupancy chart included desks belonging to somebody else. Aggregates only, nothing you could call a leak with a straight face. Every table had a tenant_id, every query was supposed to filter on it, and the grouped aggregate behind that chart didn’t.
What the design had to survive
- Onboarding an operator cannot involve a deploy
- Not a fast deploy — none. It had to be a form somebody in ops fills out, not a ticket for me. That single requirement rules out per-database tenancy, because it turns customer acquisition into an infrastructure task, and it rules out any theming story that needs a build.
- Correctness cannot depend on remembering
- You write a few thousand queries over the life of a product. If isolation holds only when every one of them remembers a WHERE clause, it does not hold. The real decision was never which tenancy model — it was whether the database enforces isolation or the team promises each other.
- Connection pooling is in the path
- Under PgBouncer in transaction pooling mode, any per-session state outlives its transaction and goes back into the pool with the connection. Set the tenant the obvious way and the next request inherits somebody else’s. This is not an RLS gotcha; it is pooling doing exactly what it advertises.
- Some brands are light
- An operator sends a mid-yellow from their brand book and the primary button renders white text on it. A brand palette and a UI palette solve different problems, and treating the first as the second fails WCAG the moment somebody’s brand is pale.
- A centre in Dubai runs a different week
- Friday means something different there than in Gurugram. Locale, working days, cancellation windows, tax config and which modules are switched on all vary per operator — so all of it has to be data rather than code.
Two requirements shaped everything: isolation the database enforces rather than the team remembers, and an operator who can be onboarded without me. Each decision is summarised here and argued fully in a separate piece.
Isolation the database enforces
Tenancy can live in the application as a tenant_id plus discipline, in the
schema as one Postgres schema per tenant, or in the database as one cluster
each. Per-database is what you do when a contract or a regulator says
"dedicated infrastructure" — ours said the opposite, and it turns signing a
customer into an infrastructure task. Schema-per-tenant was genuinely tempting;
per-tenant restore becomes trivial, which sounds unimportant until an operator
deletes a workspace on Friday and wants it back on Monday. The cost lands on
migrations: thirty schemas is fine, three hundred is a twenty-minute deploy that
can fail halfway and leave two versions of the truth in one database.
So shared schema, with row-level security doing the enforcing. force row level security is the part people miss — without it the table owner, usually the
exact role your app connects as, bypasses its own policies and you get a green
test suite that proves nothing.
Every request sets the tenant inside its transaction, using set_config with
the local flag so the setting dies on commit. That detail is load-bearing under
PgBouncer: a plain SET outlives the transaction, returns to the pool with the
connection, and hands the next request somebody else's tenant.
We kept the application-level filter too. Two layers is unfashionable and I'd still argue for it — the ORM scope catches the mistake in review with a readable error, RLS catches it in production with an empty result set, and the two fail for unrelated reasons.
Retrofitting was the expensive part. Turning RLS on in week one costs an afternoon. Month five cost me a fortnight, spread thin enough that I didn't notice it going.
Where policies don't reach
Workers have no request, so they have no tenant. Every job payload carries a tenant id and every handler opens by entering the tenant scope. The rule we settled on: a job touching more than one tenant isn't one job, it's a fan-out that enqueues one per tenant. Retries got considerably less frightening after that.
Redis has never heard of your policies. Every key is namespaced, and the one that got us was a memoised list of amenity types cached without a prefix, on the reasoning that amenities are basically the same everywhere. They were not.
Object storage is the same story — paths prefixed by tenant, signed URLs generated only inside a tenant-scoped call. And if your support console is just the app with a flag flipped, you don't have isolation, you have a habit. Ours is a separate role with its own policy and an audit row on every cross-tenant read.
Branding is data, not a build
Everything that varies between operators is a row: theme tokens, locale, working
days, cancellation windows, tax config, which modules are on. There is no
if (tenant === 'ia-spaces') anywhere in the codebase — a rule I enforced in
review with more conviction than I could always justify in the moment.
The pressure to break it never stops. Somebody always has one customer with one
requirement that would be four lines as a special case, and those four lines are
how a product ends up with a customers.ts nobody can delete. Genuine one-offs
go behind a flag on the tenant record, so at least ops can see it and switch it
off.
The tenant's values are inlined into the document head at request time, not applied after hydration. Apply a theme client-side and every visitor sees a flash of your default brand first — on a mid-range Android phone on a hotel network that's most of a second, and the one person guaranteed to notice is the operator you just onboarded, on the demo call, in front of their own team.
Custom domains were the fiddliest part: resolving host to tenant puts a lookup on the hot path for every request. That cache, and its explicit invalidation when a tenant record changes, is the piece of infrastructure I've thought about most since.
Brand colours are not design tokens
The one I didn't see coming. The tenant record stores the brand colour, but the tokens the UI actually uses are derived from it at save time: foreground chosen per surface by contrast ratio rather than assumed white, hover and active states generated by adjusting lightness in a perceptual space instead of plain HSL, and a contrast check that runs as part of saving the theme.
Two colours out of the first dozen operators failed that check. Both times, showing them the adjusted version next to their original ended the discussion in about a minute, because the adjusted one obviously looked better.
What I still haven't solved
Favicons and the PWA manifest. Both are static file references that browsers cache with unusual enthusiasm, both need to vary per tenant, and the manifest needs icons at half a dozen sizes generated from whatever the operator uploaded.
We generate them on upload and serve them from tenant-scoped routes, which works, but caching behaviour differs enough between browsers that I don't fully trust it. Every so often somebody reports the wrong icon in a bookmark and I have no reliable way to reproduce it.
If you've solved that one properly, I'd genuinely like to hear about it.
The system today
- centres on the platform
- 30+
- cities, Dubai included
- 17+
- seats managed
- 7,000+
- sq ft
- 350k+