Why does an authorization check placed only in the HTTP handler miss the nightly job and e-mail intake that also close tickets?
answer
- the guard sits on one road only
- not every caller arrives by HTTP
- jobs, consumers, imports, admin tooling
- what principal does the job run as
- guard the operation, not the handler
basics
~20 sA check in the HTTP handler only runs when a request reaches that handler. A scheduled job, a message consumer or an import script call the ticket-closing code directly, so the guarded path is simply not on their route.
solid answer
~40 sAuthorization is enforced by code that actually executes, so a rule written in a request handler protects exactly the callers that arrive through it. A nightly escalation job, an e-mail intake consumer, a monthly bulk import and an operator's command-line tool all reach the same domain operation without passing that handler, and none of them carries a request to be checked. The fix is to move the rule into the operation every caller must go through - `close_ticket(principal, ticket)` - and to make each non-request entry point supply a principal explicitly. That forces the two questions those entry points usually dodge: what principal does this run as, and who decided that principal may close a ticket? A job with no answer to either is not exempt from authorization; it is unauthorized and running anyway.
code
pseudocode · 27 lines# one guarded operation, four callers
function close_ticket(principal, ticket_id, reason):
ticket = tickets.load(ticket_id)
require(may_close(principal, ticket)) # the only copy of the rule
ticket.close(reason, by = principal)
# 1. request handler - supplies the authenticated caller
on POST /tickets/{id}/close (request):
close_ticket(request.principal, id, request.body.reason)
# 2. nightly escalation job - no request, so the principal is explicit and narrow
on schedule("02:00"):
for ticket in breached_response_window():
close_ticket(service_principal("escalation-job"), ticket.id, "auto-closed: no response")
# 3. e-mail intake - the sender address is a claim, so verify or refuse
on mail(message):
principal = agents.resolve_verified(message.from)
if principal is null:
reject(message, "unrecognised sender")
else:
close_ticket(principal, parse_ticket_id(message), "closed by e-mail reply")
# 4. monthly import - runs as the operator who started it, not as "the system"
function import_file(operator_principal, rows):
for row in rows:
close_ticket(operator_principal, row.ticket_id, "closed by import")go deeper
Remember that a check only protects the code path that runs it. Name at least two ways into a system that are not HTTP requests - a scheduled job and a message consumer - and say that each needs a principal of its own.
Explain how moving the rule into the domain operation, with the principal as an explicit argument, makes every entry point evaluate the same code. Show what the adapter at each entry point is then responsible for.
Show the production consequence: an unscoped job identity turns a bug in a selection query into a cross-landlord incident, and the record shows only 'the system'. Say what you would scope the job principal to and how you would alert when one is missing.
Decide which non-request entry points are inside the application's authorization boundary at all. Direct data-store access by operator tooling is a deliberate exception with its own controls, and pretending otherwise leaves an unowned path into every rule you wrote.
## The rule and the road it sits on In a multi-landlord maintenance service the rule reads: **only the managing agent for a building may close a ticket raised against it**. The obvious home for it is the request handler that serves the close operation, because that is where the caller's identity is most obviously to hand. Written there the rule is real, it is tested, and it holds - for every caller who arrives through that handler. But a handler is a road, not a door. Enforcement happens only on paths that actually execute the checking code, and a system of any age has several paths into the same domain operation that never touch it. Nothing reports this. The code compiles, the tests pass, and the rule quietly applies to a subset of the traffic. ## The entry points that are not request handlers - **Scheduled jobs.** A nightly escalation sweep closes or reassigns tickets that breached their response window. It is started by a clock. There is no request, no caller and no credential unless somebody deliberately arranged one. - **Message consumers.** An e-mail intake reads a shared mailbox and creates tickets or closes them from a reply. The sender address is a claim made by a mail system, not an authenticated principal. - **Bulk import.** A monthly file from a landlord's own system is parsed and written straight into the ticket store, usually by code that was written once and never reviewed again. - **Administrative tooling.** A command-line tool or a maintenance script run against production, holding whatever service credentials happen to be on the machine. - **Another service calling in.** How a caller's identity survives that hop is its own subject; what matters here is that the hop does not pass your handler. ## Two questions every entry point must answer 1. **What principal does this run as?** Not *a user*, necessarily - a job can run as a named service principal - but something the rule can be evaluated against. If the answer is 'none', the rule cannot be evaluated, and 'cannot be evaluated' must mean refuse, not proceed. 2. **Who decided that principal may do this?** A principal that exists only so the job starts, and that was granted every authority in the system because that was quickest, has replaced authorization with a wish. | Entry point | Where the identity comes from | What usually goes wrong | |---|---|---| | Request handler | The authenticated caller on the request | Nothing - this is the path everyone remembers | | Scheduled job | A configured service principal | Runs with unrestricted authority because nobody scoped it | | Message consumer | Resolved from the message, then verified | The sender's address is trusted as if it were proof | | Bulk import | An operator or a service principal | The file's own building identifier is believed | | Admin command-line tool | The operator running it | Connects with credentials that bypass the application entirely | ## Moving the rule to the operation The cheap, durable fix is to write the rule once, inside the operation that every caller must pass, and to make the principal an explicit argument of that operation rather than something fished out of ambient request state. The handler then becomes a thin adapter that supplies the request's principal; the job supplies its service principal; the intake resolves and verifies one, or refuses the message. This costs almost nothing and removes a whole class of bug: there is now exactly one copy of the rule, and adding a fifth entry point next year cannot forget it, because the operation will not run without a principal. ## What goes wrong when it is not done - The escalation job runs with an identity that may do anything, so a bug in its selection query closes tickets for landlords it should never have touched, and the audit shows only 'the system' did it. - The import believes the building identifier in the supplied file, so one landlord's file writes tickets onto another landlord's buildings. - The intake trusts a sender address, which is trivially forged, so a reply from outside closes a ticket. - The operator's script talks to the data store directly, so no application rule applies to it at all - which is a decision somebody should be making on purpose, not by omission. ## What good looks like One function that answers 'may this principal close this ticket', called from inside the operation; every entry point supplying a principal and refusing to start without one; and the job's authority written down as narrowly as its work - close tickets that breached a window, nothing else. When someone asks 'what can the nightly job do?', the answer should be a short list, not a shrug.
- What principal should the nightly escalation job run as?A named service principal of its own, granted only the authorities its work needs - close a ticket that breached its response window - and nothing else. Reusing an operator's identity hides who acted, and giving it unrestricted authority means a bug in its selection query is unconstrained. The record of the action should show that principal, so 'the system did it' is never the whole answer.
- The e-mail intake receives a reply that looks like it came from a managing agent. What may it do with that?Treat the sender address as an unverified claim. Resolve it against known agents, and if it resolves, evaluate the same rule the handler would; if it does not resolve, refuse the message rather than acting as a privileged system principal. A mail address is trivially forged, so a reply is evidence of intent at best, never proof of identity.
- If the operation refuses because no principal was supplied, what should the job do?Fail loudly and stop. A missing principal is a configuration fault, and the tempting alternative - fall back to an unrestricted identity so the batch completes - converts a fault into a silent, system-wide bypass. Alert on it and leave the work undone; unfinished escalations are recoverable, wrongly closed tickets across landlords are not.
The building has a staffed front desk and three service entrances. Everyone who walks through the lobby is checked; the cleaners, the deliveries and the contractors come in round the back and nobody ever sees them. The answer is a lock on the flat door, not a second receptionist.
saying these in an interview costs you the question
- Internal jobs are trusted, so they need no authorization check
- A background job has no user, therefore authorization does not apply to it
- The request handler is the only way into the domain code
- Running batch work with unrestricted authority is fine because operators are trusted
- Checking in the job would duplicate the handler's check, so skip it
- A message's sender address identifies the principal that sent it