all builds >> N. GUGLIELMI
>> nicola@guglielmi ~ / labs / ebadge

eBadge

Workplace-safety records for Italian construction and industrial firms, and for the external safety consultant who carries several of them at once. A worker gets an NFC and QR badge; an inspector standing on a site can check whether that worker is cleared to be there without an account and without learning who they are. Underneath it is the least glamorous and most consequential kind of software: multi-tenant data that must never leak sideways.

Django + DRF React 19 Postgres · Cloud SQL Multi-tenant Web NFC Cloud Run
>> THE_PROBLEM

Two hard constraints pull against each other. The first is tenancy: a safety consultant legitimately sees several client companies, some of which compete with each other, so "logged in" says nothing at all about what a request may touch. Every read and every write has to be scoped, including the ones that create a record and the ones that could quietly move a record from one company to another.

The second is that the whole point of the badge is to be readable by a stranger. An inspector arriving on site has no account and never will, but needs to know whether this person is cleared to be here. That answer depends on things like medical fitness expiry, which is exactly the category of data you least want on an unauthenticated page.

>> WHO_MAY_SEE_WHAT
authenticated Company userConsultantSite foremanthree different scopes One scoping layerreads AND writesshared by views + serialisersout of scope returns empty Tenant dataworkers, sites, badgesqualifications, equipmenttwo audit trails unauthenticated, on site Badge scanNFC tap or QRnon-guessable id Traffic light onlycleared / not clearedno name, no company PIN unlocks detailprinted on the badgethis session only The unauthenticated path never reaches a name. It answers one question, is this person cleared to be on this site, and that answer is derived from expiry dates the system holds without ever holding a medical reason for them.

> the marketing site is the same compiled bundle as the app, deployed a second time with the landing at the root. cheaper than a separate stack, and it cannot fall out of sync.

>> IN_ACTION
The eBadge public site: a headline about digitalised workplace safety, a description of the compliance platform with NFC and QR badges, and a worker badge card showing a green cleared-for-duty state with an NFC tap area and a QR code
> the badge as the product's centre of gravity: a worker card that resolves to cleared or not cleared. that green state is computed, not stored, from whether any qualification marked critical has passed its expiry date.
>> DECISIONS_THAT_MATTERED
[ 01 ]

Isolation was enforced on reads and only assumed on writes

Reads went through one scoping layer and were correct. Writes went through form validation that narrowed the allowed foreign keys by looking at the user's own company, which works for a company user and silently does nothing for a consultant or a foreman, because neither has one. The result was a cross-tenant write: a consultant could attach a record to another client's worker. The fix made read scope and write scope the same computed thing, shared by both layers, so the two can no longer disagree.

[ 02 ]

The public badge answers a question without identifying a person

Scanning the badge as a stranger returns a traffic light and nothing else: cleared or not cleared, no name, no company, no photograph, no qualifications. The detail exists behind a PIN printed on the physical badge, so possessing the card is what unlocks the record, and only for that browser session. An inspector gets what they came for, and a lost badge on a train platform reveals that somebody, somewhere, is up to date on their training.

[ 03 ]

Health data handled by never having any

Fitness for duty is medical information, and the usual answers are encryption or a separate store. This took the cheaper and stronger route: a medical examination is stored as a date it happened, a date it expires, and whether it is blocking. There is no diagnosis field, no clinical note, no judgement text anywhere in the schema, because none was ever added. You cannot leak what the model has no room to hold, and the product loses nothing, since the only question it asks is whether the date has passed.

[ 04 ]

The deploy pipeline used to wipe the database

The build ran migrations and then a seed step, and the seed step began by deleting the contents of every table. Fine on a laptop, catastrophic once the same pipeline pointed at something real. Two changes: the seeding script now refuses to run against a production environment unless explicitly overridden, and the pipeline was split so shipping code no longer drags a reseed along with it. A guard first, because it stops the bleeding on every path, then the structural fix.

[ 05 ]

Every badge PIN was in every badge listing

The badge endpoint serialised all fields, which is convenient exactly until you notice the unlock PIN is one of them, returned on every read to anyone inside the tenant. It became write-only, with a separate flag saying whether a PIN is set, and new ones got longer. The compromise worth noting is that shorter legacy PINs stay valid and the verification form still accepts them, because the alternative was invalidating physical cards already printed and in people's wallets.

[ 06 ]

Registers were being truncated without saying so

The API paginated at twenty rows and the client had no way to ask for more, so attendance registers and dashboards showed the first page and presented it as the whole answer. Not an error, no warning, just a quietly wrong number in a compliance tool. The page size became a parameter with a ceiling, the default stayed put for compatibility, and the client learned to follow pages to the end. Several round trips instead of one, in exchange for totals that are actually totals.

>> WHY_THIS_MATTERS_TO_YOU

Multi-tenancy is where B2B products quietly go wrong, and the failure is rarely a missing check. It is a check that is correct in one layer and merely implied in another, or a scoping rule written for the common role that degrades to nothing for the unusual one. The habit that helps is making the scope a single computed thing that every layer has to ask for. If you are building something where one customer must never see another's data, that is a review I enjoy doing before it ships rather than after. Let's talk.