carlos@cesaints: ~/projects/multi-company-onboarding.md — zsh

Command: cat projects/multi-company-onboarding.md

projects/multi-company-onboarding.md · 1.3 KB

A multi-company onboarding platform with isolation in the database

Two companies of a business group use the same platform to onboard new hires, in the same database, without either one seeing a single row of the other's data. Each person gets a guided trail by phase, assembled for their department, role and contract, and the admin panel follows progress in real time without sending any personal data over the real-time channel.

open the demoa simulation of the system with fictional data · 6 screens

Role
Sole engineer, from product to operations
Period
Jul 2026 – Sep 2026
What I did

Me: Product, architecture, data model, access rules in the database, every screen of the app and the panel, tests, deployment and operations.

Private project, described without identifying the client, product or company.

Diagram

Companies in one database, kept apart by the database itself

The employee app, with its trail by phase, and the people-team panel, which the platform operator also uses, go through Server Actions. The data lives in a single Postgres, and the boundary between the companies belongs to the database: RLS on every table, composite keys with the company and triggers against links across companies. Files live in Storage, checked by their first bytes and served through signed links that expire. Realtime only publishes a notice that a scope changed, and the screen queries again under the same access rules.

Context

A group with two companies needed to onboard new hires without spreadsheets and hand-offs. Each company has its own identity, departments, roles and contracts, and one company’s data must never show up for the other.

What I built

  • The employee app. A home page that shows the next step and readiness, the trail by phase (pre-boarding, first day, first week, 30, 60 and 90 days), each step with video, document and checklist, the document library, the career ladder and the team.
  • The people team’s panel. Company structure, versioned documents, a career framework, a trail editor with a preview identical to the employee’s view, profiles and access, an overview with time-to-productivity and bottlenecks by step, an audit trail and exports.
  • The platform operator. A cross-company account that switches companies from a selector. The active company lives on the server, and every database rule starts serving the other company.
  • Each company’s color with computed contrast. The server picks light or dark text by WCAG contrast and warns when neither reaches 4.5:1.

What I would do differently

From day one I would separate an account’s home company from the company it is managing. Mixing the two locked the owner out of the product in an incident, and the fix meant moving identity into immutable columns enforced by the database.

Constraints

  • Two companies in the same database, with neither able to see the other's data, not even through a query bug.
  • Personal data only reaches the browser of someone who needs it, real time included.
  • A single technical person to build and run it.
  • Content assembled by the people team, without needing a developer for every change.

Decisions

Isolation in the database, not only in the application

Context
With two companies in the same database, one forgotten filter in a query would be enough to show one company's employees to the other.
Choice
Row-Level Security on every table, composite foreign keys that include the company, and triggers that refuse any link between different companies.
Gains
  • A bug in the application does not cross the boundary between companies.
  • Isolation is tested against the real migrations, not a mock.
Costs
  • Rules evaluated row by row hurt performance and had to be rewritten.

Real time without personal data

Context
Default row replication would send every employee's name, e-mail and manager to the browser of every administrator.
Choice
The database only publishes a notice that something changed in that scope; the screen queries again under the usual access rules.
Gains
  • No personal data travels over the real-time channel.
  • When the provider's mechanism failed, the transport could be swapped without changing the decision.
Costs
  • Every notice costs a new query, which called for a debounce and a minimum interval per screen.

Trails by composition, with additive assignment

Context
Each person needs the right sum of content for their department, role and contract type.
Choice
Trails with layered scope, added up automatically from the profile, plus assigning someone by name. Removing a person from a trail does not erase what they already finished.
Gains
  • The people team builds the onboarding without asking for code.
  • Putting someone back restores the steps they had already finished.
Costs
  • More rules to explain in the panel, which called for a preview of what the employee sees.

Stack and why

Next.js 15 and React 19
The employee app and the panel in one project, with Server Actions.
Strict TypeScript
End-to-end types with checked index access.
Supabase (Postgres, Auth, Storage, Realtime)
Per-company isolation in the database itself.
Vitest with PGlite
Isolation tests that run the real migrations on an in-process Postgres.
Playwright
Full flows with a test company created and removed on every run.
Vercel
Functions pinned to the database's region.

Results

  • Row-Level Security enabled on all 25 tables, with isolation and privilege-escalation suites running against the real migrations.

    audited private repositoryThe repository's migrations and tests(Audit of the private repository, Sep 2026)
  • 26 architecture decisions recorded as ADRs, including corrections of earlier decisions.

    audited private repositoryThe project's decision log(Audit of the private repository, Sep 2026)
  • Access rules down from 130 ms to 4.4 ms when reading 223 steps, after rewriting them to evaluate once per query, with proof that nobody gained or lost visibility.

    self-reportedThe project's performance log

Security angle

Attack surface

  • An admin panel with employees' personal data.
  • The real-time channel between the database and the browsers.
  • Document uploads to each company's library.
  • Step content written in markdown in the panel.

Controls in place

  • Row-Level Security, composite keys and triggers against cross-company links.
  • Immutable identity columns, enforced by a database trigger.
  • Real-time notices that carry only the scope, never the data.
  • Files validated by the real signature of their first bytes, and signed links that expire in one hour.
  • Markdown restricted to a safe subset, with no HTML and validated link schemes.
  • CSV export protected against formula injection, and an insert-only audit trail.

What I would test today

  • The platform operator switching companies and trying to read the previous company's data.
  • Subscribing to another company's real-time channels.
  • Uploading a file with a forged extension.

Evidence

  • audited private repositoryAudited private repository(Audit of the private repository, Sep 2026)