TL;DR
- Two apps each had their own password-change/reset logic, plus config toggles to enable/disable backends. That combination quietly allowed partial syncs.
- Fix: one shared engine, one canonical order, backends mandatory (no config-disable), and every attempt written to an audit log.
- Lesson: for a write that spans several systems, "configurable steps" is a footgun. Make the flow fixed and make failure loud.
The setup
A user changes their password. Behind the scenes that single password has to land in several systems — a directory, an external database, and the app's own store. Two separate apps were doing this, each with slightly different code, and each with config flags like sync_oracle => true|false to turn backends on and off.
Sounds flexible. It's actually a trap.
Why configurable backends are a footgun
TL;DR: a password that updates 2 of 3 systems is worse than one that updates none — because now the systems disagree and nobody gets an error.
The moment a backend is optionally skippable, "skipped" and "failed" blur together. Someone flips a flag in one environment, forgets it in another, and now prod and staging run different flows. Debugging a login failure means first reverse-engineering which steps actually ran.
| Before | After |
|---|---|
| Each app had its own reset logic | One shared engine, both apps call it |
| Backends toggle via config | Backends are mandatory, always run |
| Order implicit / differed per app | One canonical order: New directory → external DB → local app |
| "Did it sync?" answered by guessing | Every attempt logged with per-service status |
The canonical order
The order isn't cosmetic. It runs most-authoritative-first, so if a downstream step fails you haven't already told the user their new password works.
change/reset request
|
v
[ New Directory ] --ok--> [ External DB ] --ok--> [ Local App store ]
| | |
fail fail fail
| | |
+----> stop, record per-service status, surface the failure
Same engine, same order, both apps. A change API and a reset flow are just two entry points into it.
Driver-per-backend
Each target system sits behind its own data-source class with the same shape, so the engine doesn't care what's underneath:
interface PasswordTarget
{
public function update(UserIdentifier $user, string $secret): SyncResult;
}
// Each backend is a driver: DirectoryTarget, ExternalDbTarget, LocalAppTarget.
// The engine walks them in the canonical order and short-circuits on failure.
One subtle bug the convergence surfaced: an external backend that's unreachable must report skipped, not disabled. "Disabled" is a config state that no longer exists; "skipped" is a runtime fact about this attempt. Conflating them hides real outages.
Make it observable
Every attempt now writes an audit record — which services were hit, each one's status, when. That turned "did the sync work?" from tribal knowledge into a query, and made a small admin log page trivial to build on top.
test('external backend down is recorded as skipped, not disabled', function () {
$result = app(PasswordEngine::class)->run($user, 'new-secret');
expect($result->status('external_db'))->toBe('skipped')
->and($result->status('directory'))->toBe('ok');
});
Takeaway
If a single logical write fans out to multiple systems, resist the urge to make each hop configurable. Pin the order, make every hop mandatory, and record the outcome of each one. Flexibility you don't need becomes ambiguity you can't debug — and for credentials, ambiguity is a security problem, not just an annoyance.
Top comments (0)