A Timer You Can Stop from an Email
A work-from-home swipe modelled as a state machine, a stop-from-email deep link, the estate's one deliberate IDOR guard that compares instead of overwrites, and a DbContext constructed by hand inside the action.
Working from home has no badge gate. There is no door to swipe through, so the attendance platform gave remote employees a virtual one: a button that says I have started work, which opens a timer, and a matching button that closes it. That timer is a small state machine, and hanging off it is the single most security-conscious line of code in the whole estate — a deliberate authorisation check that does the opposite of what every other action does. After the between-requests state of part ten, this is a piece of durable state a user drives directly.
A swipe is a state machine
A virtual work-from-home session has exactly the lifecycle you would draw on a napkin:
start work
[ none ] ─────────────► [ open ] ──────────► [ closed ]
│ stop work
└── (still open at threshold) ── reminder email sent
The platform stored each session as a row and each transition as an event, with a “last node” marker recording the current state — a magic number, 8 for in and 7 for out, inherited from the physical badge system's node types, a coupling to the hardware that outlived the hardware's relevance. Starting work writes an in event and opens the session; stopping writes an out and closes it. Duration is the difference, and a background job (Series B's territory) watches for sessions left open too long.
Modelling a swipe as an explicit state machine was the right instinct. The states are real, the transitions are few, and an illegal transition — closing a session that was never opened, opening one already open — should be a rejected command, not a silent no-op. Where the implementation frayed was in trusting the last node marker as the whole state: a crash between writing the event and updating the marker could leave the two disagreeing, and nothing reconciled them. A status column derives from its events; a cached marker can drift from them. The companion series makes this exact case at length in ModifiedBy Is a State Machine.
Stop it from an email
The nice feature: if you forget to stop your timer and it runs long, the platform emails you a reminder — and the reminder contains a link that stops the timer. One click from your inbox closes the session. No login, no navigating to the app, no finding the button. For a genuinely helpful nudge — “you have been swiped in for eleven hours, did you forget to swipe out?” — a one-click resolution is exactly the right UX.
The security shape of that feature is where it gets interesting. A stop-from-email link is, by design, actionable without an interactive session — the whole point is that you click it from your mail client. So the link has to carry enough to identify which timer to stop, which means an employee id in the URL:
https://portal.example.com/Employee/StopTimerFromEmail?eid=20117&token=...
And that is the exact shape of an Insecure Direct Object Reference: an identifier in a URL naming a resource, where changing the number might act on someone else's resource. Change eid=20117 to eid=20118 and, if the endpoint trusts the parameter, you have just stopped a colleague's timer.
The one action that compares instead of overwrites
Here is the detail I find genuinely admirable, in an estate where I have spent ten parts cataloguing what went wrong. Recall the house pattern from part three: nearly every action receives an employee id from the query string and then immediately overwrites it with the id from the authenticated claim. Whatever the URL says, you act on yourself. That is IDOR-proofing by making the parameter decorative.
But StopTimerFromEmail cannot do that. The user clicking from their inbox may not have a current session cookie at all — that is the feature. So overwriting-from-claim is not available, and the endpoint does the one thing the rest of the codebase never needed to:
public IActionResult StopTimerFromEmail(int eid, string token)
{
var claimId = User.FindFirst(ClaimTypes.Sid)?.Value;
// the estate's one explicit IDOR guard: compare, don't overwrite
if (claimId != null && int.Parse(claimId) != eid)
return Forbid();
if (!_tokens.IsValidStopToken(eid, token))
return Forbid();
_timer.StopIfOpen(eid);
return View("TimerStopped");
}
It compares the URL's eid against the claim, and refuses if they disagree. Everywhere else in the app, the claim silently wins; here, a mismatch is an explicit Forbid. It is the same security property — you can only act on yourself — reached by the opposite mechanism, because this is the one endpoint where the identity might legitimately come from the link rather than the cookie. Whoever wrote this thought about the threat specifically, which is why it stands out: it is not the house style applied by reflex, it is a considered exception to the house style.
I would still harden it further. The signed token is the real authenticator for the no-cookie path — an employee clicking from email may have no claim at all, in which case the comparison is skipped and the token is the only guard. So that token has to be per-session, single-use, and expiring, or the “stop link” becomes a “stop link forever” that anyone who ever sees the URL can replay. But the instinct — “this URL parameter is dangerous, guard it explicitly” — is exactly the one the other 118 actions never had to have, and it is right.
The replay concern is not paranoia; it is the defining property of any actionable link sent by email. Email is not a confidential channel. The message sits in an inbox indefinitely, gets forwarded, gets synced to three devices, and is scanned by a mail provider that dutifully pre-fetches every URL in it to check for malware — which means a naive stop-link can be “clicked” by a security scanner seconds after the email is sent, closing the employee's timer before they have even read the reminder. This is a real and well-documented failure mode for one-click email actions, and it is why the action behind such a link should be idempotent and its token should encode the exact operation it authorises. A token that means “stop this session, once, before this expiry” is safe to pre-fetch: the first resolution closes an already-closable session, the second finds nothing to do, and neither leaks. A token that means “stop whatever is open for this employee” is a loaded gun with the recipient's name on it, and the mail scanner is holding it. The endpoint's comparison guard is the right first move; making the whole operation replay-safe is the second, and the two together are what turn a convenient link into a correct one.
A DbContext born in the action
One blemish on an otherwise thoughtful endpoint, and it ties back to part one's layering violations. The stop-from-email path did not use an injected context or repository. It built its own:
using var context = new AttendanceContext(BuildOptions()); // inside the action
context.CloseSession(eid);
context.SaveChanges();
A new AttendanceContext(...) constructed by hand, mid-action, bypassing the container entirely. Why here? Almost certainly because the email-triggered path ran outside the normal request scope in someone's mental model — “this comes from an email, it's special” — and hand-rolling a context felt safer than trusting the injected one. It is the same downward violation the composition root committed with its hand-built singleton, now committed inside a controller action, and it carries the same costs: a connection lifetime nobody manages centrally, no shared unit of work, and a second place that has to know how to build context options correctly. A stop-from-email request is just a request. It should have taken an injected ITimerData like everything else.
The ledger
- The state machine — right shape, but trust the events, not a cached last-node marker that can drift from them.
- Stop-from-email — genuinely good UX; the security burden is real and was, unusually, taken seriously.
- The comparison guard — the estate's one deliberate IDOR check, and correct in spirit: same “act only on yourself” property, reached by comparing because overwriting wasn't available. Harden the token to single-use-and-expiring and it is exemplary.
- The hand-built context — the one architectural slip on the endpoint; an email-triggered request is still a request, and deserved an injected dependency.
That reminder email the timer sends does not hold its own text — it is rendered from a template stored in the database, through a sixty-line mini-language of the platform's own invention. How email templates lived in tables, and rendered with reflection, is next.