A tenant facade that fails closed
A customer session never calls an operator path directly. tenantAdapt rewrites the path it recognises and turns everything else into a blocked request that answers 403, and customer_safe in the middleware refuses any /api/* the session is not permitted to call. Both fail closed, so a new capability is unreachable for a tenant until somebody makes it reachable on purpose.
Two refusals, and why neither guesses#
The alternative design is a permissive rewrite: strip a prefix, add another, pass anything through. It is one line shorter and it fails in the worst available direction — a route added for the operator becomes immediately reachable by a tenant, with operator semantics, and nobody finds out until the semantics matter.
| Gate | Where it runs | What it does with something unrecognised |
|---|---|---|
tenantAdapt | webapp/src/services/tenant-adapt.js, in the browser | Rewrites it to a blocked path, which answers 403 — it does not attempt the operator path |
customer_safe | backend/app/main.py middleware | Refuses any /api/* a customer session is not on the allowlist for, whatever asked |
The browser half is a convenience: it keeps a customer's screens from issuing requests that were never going to succeed, and it makes the failure legible. The server half is the boundary. Neither depends on the other being correct.
The chore this creates is the design working#
Every new operator path needs either a rewrite in tenantAdapt or a written reason for not having one. That is a real recurring cost, paid per feature, and it buys the property that no capability leaks to the other audience by default.
It also produces a characteristic symptom that is worth recognising on sight: a feature that works perfectly for the operator and returns 403 for a customer. That is almost never the feature. It is the rewrite that was not added, or the allowlist entry that was not added, and both are a two-minute fix once the shape is recognised.
Enumerated rather than asserted#
A rule like "every capability is available to both audiences" is worth nothing as a sentence in a document. tools/test_audience_parity_v1.py reads the flattened route table out of the running application and compares every operator capability against every /api/customer/** one.
ALIASESrecords where the customer surface renames something, so a rename is not mistaken for a gap.KNOWN_GAPSlists the capabilities a tenant still cannot reach, each with its reason. The list may shrink and must never grow.- The suite fails on a new one-audience capability, which catches the forgotten rewrite at the moment it is introduced.
- It also fails on a gap that has been closed and not removed from the list, which is what stops the exceptions file becoming a graveyard nobody reads.
Three capabilities are correctly reachable only by the operator, and they are exceptions of a different kind: verifying customer payments, JBRH's own pricing, and website enquiries addressed to JBRH. Those are the platform being run rather than Connect being used, and connect_inquiries sits outside every workspace by design.
Reading a 403#
Establish which gate refused. A blocked path in the browser's network panel points at the rewrite; a refusal on the real path points at the allowlist.
Result The two have different fixes and there is no point guessing between them.
Check whether the capability is in
KNOWN_GAPS.Result If it is, the refusal is expected and the entry says why. If the reason no longer holds, closing the gap is the change — and the suite will then require the entry to be removed.
Check whether it is one of the operator-only three.
Result Payments, JBRH pricing and website enquiries are refused on purpose and are not a parity failure.
Otherwise, treat it as a missing rewrite or a missing allowlist entry and add the one that is missing.
Result The parity suite goes green, and the capability is reachable by both audiences over the same implementation rather than a second one.
Questions#
Why does something work for the operator and refuse for a customer?
Because the customer path is explicit. Either tenantAdapt has no rewrite for it, so the request becomes a blocked path, or customer_safe does not list it. Both are omissions in the mapping rather than faults in the feature, and the parity suite exists to catch them before a customer does.
Is a 403 for a customer always a bug?
No. Three capabilities are deliberately operator-only, and KNOWN_GAPS records the remaining ones with reasons. What would be a bug is a gap nobody wrote down — which is precisely what the enumerated comparison refuses to let through.
Could a customer bypass the rewrite by calling the operator path directly?
The rewrite is in a browser and is not what stops them. The allowlist in the middleware refuses the request whatever issued it, and beneath that the workspace kernel and the database's own policy still filter every row.