skip to content

Which class of authorization rule belongs at the edge, which in the service, and which where the ticket is loaded?

level: middleimportance: must knowfreq 74%

answer

  1. sort rules by the inputs they need
  2. the edge knows the caller, not the ticket
  3. the operation names what is attempted
  4. object rules need the row loaded
  5. one resource-action vocabulary everywhere

basics

~20 s

Coarse route admission belongs at the edge, which knows the caller but not the ticket. Operation-level authority belongs in the service operation. A rule that depends on the ticket's own building can only be evaluated where that ticket is loaded.

solid answer

~50 s

Sort rules by what data they need. A rule that needs only the caller - is this request authenticated, may this caller reach this route at all - can be decided at the edge before any application code runs. A rule that needs the operation - may this principal perform `ticket:close` - belongs in the service operation, because one route often fans out to several operations. A rule that needs the object - is this the agent managing the building this ticket was raised against - cannot be answered until the ticket is loaded, so it lives where the data is selected. Keep one resource-action vocabulary across all three, so the same rule is not spelled three different ways and reviewed as three different rules. Enforcing one rule at two layers is sometimes right, but it is a budgeted choice: the two copies drift.

code

pseudocode · 14 lines
pseudocode
# 1. edge - knows the caller and the route, not the ticket
edge_rule("/buildings/*/tickets/**", require = authenticated)   # refuse with 401 + WWW-Authenticate

# 2. service operation - first place that knows WHICH operation is attempted
function close_ticket(principal, ticket_id, reason):
    require(principal.may("ticket:close"))                      # same vocabulary the edge would name

# 3. data selection - first place that knows WHICH ticket
    ticket = tickets.load(ticket_id)
    if ticket is null:
        return not_found()
    if not manages(principal, ticket.building_id):              # needs the loaded row
        return denied()                                          # 403: identity known, answer is no
    ticket.close(reason, by = principal)

go deeper

for a junior

Learn the sorting question: what does this rule need to know? If it needs the record, it cannot be decided before the record is loaded. Be able to give one example of each of the three classes.

for a middle

Design it for a stated case: take one endpoint, write its three rules as sentences, and place each at the earliest layer that holds its inputs. Say why the route-level rule cannot express the object rule.

for a senior

Name the cost of the duplication you keep, and the failure it produces: a widened service rule that the edge still blocks, a denial reported twice in two vocabularies, a rule complete only on the path that goes through the edge.

for a principal

Own the vocabulary. One resource-action naming scheme across edge, service and data access is what makes 'where is this rule enforced?' a grep instead of an archaeology project, and it is nearly impossible to retrofit once three teams have named things three ways.

## Sort rules by the data they need, not by taste Every authorization rule needs inputs. The placement question answers itself once you ask which inputs a given rule requires and at what point in the request those inputs exist. In a multi-landlord maintenance service, one endpoint - close a ticket - carries three genuinely different rules: 1. **Is this caller authenticated at all?** Needs only the credential on the request. 2. **May this principal close tickets?** Needs the caller's authorities and the name of the operation. 3. **May this principal close *this* ticket?** Needs the ticket row, because the answer depends on which building it belongs to and who manages that building today. ## The three layers and what each can actually decide | Layer | What it has | Rules it can decide | Rules it cannot | |---|---|---|---| | Edge (API gateway or reverse proxy) | The credential, method and path | Reject an unauthenticated caller; admit or refuse whole route families | Anything needing the ticket, or needing to know which operation a route performs | | Service operation | The principal and the named operation | `ticket:close` requires the managing-agent authority | Anything needing the row before it is loaded | | Where the data is selected | The principal and the loaded ticket | This ticket belongs to a building this principal manages | Nothing about routes it never sees | The edge is the cheapest place to say no and the least informed one. It cannot decide rule 3 without fetching the ticket itself, at which point it has become the service wearing a different hat - with its own copy of the data model and its own staleness. That is why coarse admission is its job and object rules are not. The service operation is the first place that knows *what is being attempted*. This matters because a single route frequently fans out: one update endpoint may close, reassign or re-price a ticket depending on the body, and a route-level rule cannot tell those apart. The data-selection layer is the first place that knows *what is being attempted it against*. Rule 3 has no earlier home. ## One vocabulary, or you will review the same rule three times If the edge speaks of `/buildings/*/tickets`, the service speaks of `closeTicket`, and the data layer speaks of `agent_buildings`, then the rule 'a managing agent may close tickets on their own buildings' exists as three unrelated statements. Nobody can answer 'where is this rule enforced?' without reading three files, and a change to one is not obviously a change to the others. Pick one resource-action vocabulary - `ticket:close`, `ticket:reassign`, `building:read` - and use the same strings at every layer that needs to name the rule. It costs a naming convention and it buys a grep. ## What duplication costs, and when to pay it Enforcing the same rule in two layers is not forbidden; it is a purchase. The price: - **Drift.** Someone widens the rule in the service and not at the edge, or the reverse. Now the effective rule is the intersection, and nobody wrote it down. - **Two denials, two shapes.** The edge refuses with one status and message, the service with another. Callers - and your own support staff - learn two different failure vocabularies for one refusal. - **A rule that is true only on one path.** Traffic that bypasses the edge (an internal caller, a job) meets only the service copy. If the rule was complete only at the edge, that traffic is under a different, weaker rule. So duplicate deliberately and say why in writing. The usual justified case is coarse: refusing unauthenticated traffic at the edge so that unauthenticated load never reaches the service, while the service still refuses it too because the edge is not the only way in. ## Getting the refusal right The two refusals are not the same refusal. A `401` means *I do not know who you are* and, per RFC 9110 Section 15.5.2, must carry a `WWW-Authenticate` challenge telling the caller how to try again. A `403`, Section 15.5.4, means *I know who you are and the answer is no* - retrying with the same credential is pointless. The edge typically owns the first; rules 2 and 3 produce the second. Emitting `403` for a missing credential tells a caller to give up when they should authenticate; emitting `401` for a denied object invites an endless retry loop. ## A working rule of thumb Write each rule as a sentence and underline its nouns. If the nouns are only about the caller, it can go at the edge. If they include the operation, it goes in the service. If they include the record, it goes where the record is loaded - and no amount of routing cleverness moves it earlier.

  • The edge refuses a caller who sent no credential. Which status code, and what must accompany it?
    A 401, carrying a WWW-Authenticate header that names how to authenticate - RFC 9110 Section 15.5.2 requires the challenge. Reserve 403 for the case where the principal is known and the rule still says no; a 403 tells the caller that retrying with the same credential is pointless, which is exactly wrong when they simply have not authenticated yet.
  • One update endpoint can close, reassign or re-price a ticket depending on the request body. What does that do to route-level rules?
    It defeats them. A route-level rule can only say 'may this caller touch this endpoint', which must then be the union of the three operations' rules - the weakest one wins for all of them. Either split the route so each operation is separately addressable, or enforce the operation-level rule inside the service where the body has been interpreted.
  • Is it ever right to enforce the same rule at two layers?
    Yes, deliberately and for coarse rules. Refusing unauthenticated traffic at the edge keeps that load off the service, while the service refuses it too because the edge is not the only way in. Budget the cost: two copies drift, and two refusals speak different vocabularies. Write down which rule is duplicated and why, so the next person does not delete one as redundant.

A managed estate checks three things in three places: the gate confirms you are a resident at all, the office confirms you are asking for something residents may ask for, and the file clerk confirms the file you named is yours. Moving the clerk's check to the gate would mean the gatehouse keeping a copy of every file.

saying these in an interview costs you the question

  • Put every authorization rule at the edge; services should stay simple
  • A route-level rule can express which record the caller may touch
  • Duplicating a rule costs nothing, so enforce it everywhere
  • Return 403 whenever the request has no credential
  • One endpoint needs one rule, regardless of what the body asks for
  • Each layer can name the rule however its own code reads best