Return to notes
Systems engineering· 4 min read

Design tenant boundaries before automation writes data

What DayBoard taught me about identity-derived scope, defence in depth, and why multi-tenant safety has to precede booking automation.

#Multi-tenancy#ASP.NET Core#PostgreSQL#Security

DayBoard began as a WhatsApp booking product for salons. The visible workflow looked straightforward: receive a message, collect a service and time, then create an appointment. The difficult question appeared underneath that flow: which business is allowed to read or change each record?

That question cannot be postponed until the product has more customers. Once automation starts creating contacts, conversations, bookings, and appointments, a missing tenant boundary can turn a normal feature bug into a privacy incident.

A tenant ID is not an authorization decision

The earliest tempting design was to let the dashboard send a business identifier with each request. It is convenient, but it puts too much trust in browser-controlled input. A user can change a header, query parameter, or request body.

DayBoard instead derives the active business from authenticated server-side membership:

secure session cookie
  -> authenticated owner
  -> owner membership
  -> active business scope
  -> tenant-filtered operation

The client can express what it wants to do, but it does not get to declare which tenant it owns. ASP.NET Core Identity handles the user account and session, while the API resolves business membership before executing a protected workflow.

This distinction helped me separate two related questions:

  1. Authentication: who made this request?
  2. Authorization and scope: what can that identity do, and inside which business?

Treating those as separate checks makes the design easier to review.

One guardrail is not enough

Tenant isolation becomes fragile when it depends on every developer remembering one more Where clause. The safer approach is to repeat the boundary at independent layers.

BoundaryResponsibility
Capability policyReject users who cannot perform the operation
Membership resolverDerive the business from the authenticated owner
EF Core query filterConstrain normal reads and writes to the active tenant
Scoped uniquenessPrevent values from colliding inside a business
Provider routingMap inbound WhatsApp traffic to one receiving business
Database policyProvide a final isolation layer for production

The current project implements application-level membership resolution, query filters, scoped indexes, and provider routing. PostgreSQL Row Level Security and composite tenant foreign keys remain production-hardening work. Naming that gap matters: defence in depth is a direction, not a label the project earns from a single middleware check.

Inbound automation has a different trust boundary

Dashboard requests start with a signed-in person. A Meta webhook starts with an external provider delivery. There is no browser session from which to infer the tenant.

For those events, DayBoard uses the receiving WhatsApp Phone Number ID as the routing key. Signature verification happens before the payload is trusted, and batched deliveries are separated by that provider identifier before business workflows run.

Meta webhook
  -> enforce body limit
  -> verify signature
  -> persist an idempotent receipt
  -> group by receiving Phone Number ID
  -> resolve one business
  -> process inside that tenant scope

This prevents a batch containing messages for different businesses from being handled inside one accidentally reused scope. It also shows why multi-tenancy is not only a database concern; routing, background workers, caches, logs, and provider credentials all need an explicit tenant owner.

Durable state makes retries safer

Webhook providers can retry, reorder, batch, or delay events. An in-memory flag cannot protect a workflow after a process restart, and acknowledging a delivery before it is stored risks losing it.

The booking pipeline therefore records provider identifiers and processing state durably. A repeated delivery can be recognized, a failed attempt can be retried, and successful work does not need to be replayed blindly.

This led to a useful design rule:

Derive scope before loading tenant data, and persist identity before starting side effects.

The same rule applies beyond WhatsApp. Email ingestion, payment webhooks, scheduled reminders, and imports all become safer when the system knows both who owns the work and whether that work has already happened.

Test the negative space

Happy-path tests prove that one owner can access one business. Isolation tests need to prove the opposite:

  • An unauthenticated request is rejected.
  • An owner cannot request another business by changing client input.
  • A tenant-filtered query cannot return another tenant's record.
  • An inbound provider ID cannot route into the wrong business.
  • A repeated provider event does not duplicate the booking.
  • Database policies reject cross-tenant access independently of the application.

That last unchecked item is deliberate. A trustworthy engineering note should distinguish implemented controls from planned controls.

What I would do at the start of the next SaaS build

I would write the tenancy invariant before the first feature story:

Every tenant-owned operation obtains its scope from a trusted identity or verified provider mapping, and every persisted relationship preserves that scope.

Then I would make the invariant visible in schema constraints, request handling, background-job envelopes, tests, and observability. That work feels slower than building the first dashboard, but it prevents the most expensive kind of rewrite: adding ownership after the system already contains customer data.

Continue reading

Real-time software changes when physics joins the system