Short session: three questions a guard asked, answered
The Starting Point
Short one. Last week's new security guards found three lookups crossing business units, and I'd deliberately left them open because each needed a decision rather than a fix — are these things set per tenant or per deployment?
Today was the decision, and then the work.
What Actually Happened
The answer for both: per unit, and a parent unit's settings do not reach its children.
Which sounds obvious written down, and the implementation turned up three distinct problems underneath it.
Measuring against the right unit, from contexts that have none. An SLA has to be measured against the case's own unit — whatever unit is acting, or none at all. The recompute command runs with no unit at all, and a parent unit legitimately sees its children's rows. So there's exactly one method that steps outside the scope to do this, and it's on the guard's allow-list with its reason written next to it.
That's the pattern I want: the escape exists, it's in one place, and the guard makes you justify it.
A unique index that stopped the second tenant from configuring anything.
The schema made the case type unique across the whole deployment. So once one business unit had set an SLA for a type, a second unit could not set one for the same type at all. Not "it would overwrite" — the insert simply fails.
New migration making it unique per unit. This is the kind of thing that is completely invisible until the day somebody adds a second tenant, which on this project is also roughly the day it goes live.
And a tag lookup that silently emptied a list.
A particular contact tag was being looked for without scoping. So another unit's tag tagged nobody here — and because the lookup joins on it, the result was an account with no recipients at all rather than an error.
That's the failure mode I like least: not a crash, not wrong data, just an empty list that looks like "nobody is configured" when the truth is "we looked in the wrong place".
The test covering all four cases fails with the fix and the migration removed, which is the only way I trust one now.
What Worked
Leaving the questions open last week. The guard went red on three lookups and I could have made it green in ten minutes by scoping everything. Scoping is probably right — it was right here — but "probably right" about tenancy semantics is how you ship something nobody can explain later.
Writing them down as three questions and coming back to them with an actual decision cost a week of them sitting on a list, and produced a migration I wouldn't have thought to write.
What I Learned
- "Takes the first matching row" is a perfectly good lookup until there are two tenants, and then it's a silent cross-tenant read.
- Check your unique indexes against tenancy. A constraint that's unique deployment-wide means the second tenant can never configure the thing the first one did.
- A lookup that returns nothing looks like "not configured". That's worse than an error, because nobody investigates a sensible-looking empty state.
- One deliberate scope escape, in one place, with its reason on an allow-list is much better than scoping everything and hoping no caller needed the wider view.
- A red guard asking a question is not the same as a red guard reporting a bug. Answer the question properly.
Tech Notes
- Stack: PHP 8.4, Laravel, MySQL
- One method steps outside the tenant scope deliberately (measuring an SLA against the case's own unit from a context with none); it's the single escape and it's on the scope guard's allow-list with its reason
- Migration changing a deployment-wide unique index to unique-per-unit
- Test covers all four cases and fails with both the fix and the migration reverted
Zero Focus Dev
Breaking things, learning fast, sharing it all.