skip to content

When is a declarative authorization marker the right choice, and which rules must be an explicit call inside the operation?

level: seniorimportance: should knowfreq 56%

answer

  1. who runs the check, and when
  2. markers need no loaded data
  3. the interception boundary must be crossed
  4. internal calls, jobs, second routes, overrides
  5. object rules go inside the operation

basics

~20 s

Declare rules that need only the principal and the operation name, on entry points that always cross the interception boundary. Write an explicit call for any rule needing the loaded record, and for code reachable from a job, a consumer or another method.

solid answer

~50 s

A declarative marker - a rule attached to a method or route and evaluated by the framework's interception layer - is excellent for coarse, argument-free rules: this operation requires the `ticket:close` authority. It is cheap, greppable and visible in review. It has one structural weakness: it is enforced by something outside the method, so it protects the method only when the call goes through that something. Three cases where it silently does not: an internal call from another method that does not cross the interception boundary, an entry point that has no interceptor in its stack at all (a scheduled job, a message consumer), and a second route to the same handler that nobody marked. Any rule that needs the loaded ticket cannot be declared anyway, because the argument list does not contain the answer. Put those inside the operation, after the load, where every caller reaches them.

code

pseudocode · 24 lines
pseudocode
# declarative: evaluated by the interception layer, not by the method
@requires("ticket:close")
function close_ticket(principal, ticket_id, reason):
    ticket = tickets.load(ticket_id)
    require(may_close(principal, ticket))   # explicit: evaluated by the method itself
    ticket.close(reason, by = principal)

# path A - inbound request: the call crosses the interception boundary, marker runs
#          and the explicit call runs too

# path B - internal call from the same component
function bulk_close(principal, ids):
    for id in ids:
        close_ticket(principal, id, "bulk")  # may not cross the boundary: marker may not run
                                             # the explicit call still runs

# path C - nightly job, no interception layer in the stack at all
on schedule("02:00"):
    for t in breached_response_window():
        close_ticket(service_principal("escalation-job"), t.id, "auto-closed")
        # marker does not run; the explicit call does

# the object rule could not have been declared anyway:
# at declaration time the arguments hold an identifier, not the ticket's building

go deeper

for a junior

Know the difference: a marker is evaluated by something outside the method, an explicit call is run by the method itself. That difference is why one can be skipped and the other cannot.

for a middle

Explain which rules are expressible declaratively - those needing only the principal and the operation name - and why a rule about the loaded record is not, no matter how many arguments you pass.

for a senior

Name the bypasses you have actually seen and what they cost: the internal call that never crossed the boundary, the consumer with no pipeline in its stack, the alias route nobody marked. Then say how you would detect each one before production does.

for a principal

Decide the house rule and make it checkable: which class of rule is declared, which is called, and what in the build asserts that every path to the data carries one of them. Without that assertion the convention decays one new entry point at a time.

## Two shapes of the same check A rule can be attached to code in two ways. - **Declaratively**: a marker on the method or route - 'requires `ticket:close`' - evaluated by an interception layer before the body runs. - **Explicitly**: a call inside the body - `require(may_close(principal, ticket))` - executed by the method itself. They are not stylistic alternatives. They differ in *who* runs the check and *when*, and each failure mode follows from that. ## What the declarative form buys - **Visibility.** The rule sits at the top of the method, so review sees it without reading the body, and a missing marker is visible as an absence. - **Uniformity.** Every marked operation refuses the same way, with the same status and the same shape, because one component produces the refusal. - **Grep.** 'Which operations require `ticket:close`?' is a search, not an investigation. - **Ordering.** The refusal happens before the body allocates anything, which matters on expensive operations. ## What it cannot do **A declared rule cannot see data the method has not fetched.** 'Only the managing agent for this building' depends on the ticket row. At declaration time the arguments hold a ticket identifier and nothing else, so the rule is not expressible without the marker itself performing a load - which moves the data access into the interception layer, duplicates it, and runs it again inside the method. **A declared rule is enforced by something outside the method.** It therefore holds exactly when the call passes that something: 1. **The internal call.** Another method in the same component calls the marked one directly. Depending on how the interception layer is wired, that call may not cross the boundary where declarations are evaluated - and then the marker does not run. It is still in the source, still in review, still greppable. It just does not fire. 2. **The entry point with no interceptor.** The nightly escalation job and the e-mail consumer call the component directly. There is no request pipeline in the stack, so there is nothing to evaluate the declaration. 3. **The second route.** Somebody adds a bulk endpoint that reaches the same handler by another path, or an alias route, and marks neither. 4. **The override.** A subclass or an alternative implementation replaces the marked method and carries no marker of its own. Each of these produces the worst failure mode in this subject: the rule reads as enforced and is not. ## The allocation that actually works | Rule | Form | Why | |---|---|---| | Only authenticated callers reach this operation | Declarative | Inputs are on the request; uniform refusal is a virtue | | This operation requires the `ticket:close` authority | Declarative | Needs only the principal and the operation name | | Only the managing agent of this ticket's building | Explicit, after the load | The rule's inputs do not exist until the row is fetched | | Anything reachable from a job, consumer or import | Explicit | No interception layer in that stack | | Anything whose denial must record the inputs it saw | Explicit | The decision record wants the values the rule read | A useful discipline: the declarative marker is an **admission** control on the entry point, and the explicit call is the **authorization** of the work. Keeping both is not duplication of one rule; it is two rules of different classes. ## Making the gaps visible instead of hoping - **Refuse unmatched routes.** If the route table's default for an unmatched path is 'allow', every forgotten marker is a hole; if it is 'refuse', a forgotten marker is a broken feature, which someone reports in minutes. - **Assert coverage in the build.** Enumerate the operations that reach the ticket store and assert each either carries a marker or calls the explicit rule. This is a few lines and it catches the second route and the override. - **Put the rule where every caller must pass.** The strongest version of this whole argument: an explicit call inside the operation is enforced by the method itself, so none of the four bypasses apply to it. The declarative marker is then an early, cheap refusal on top - not the only thing standing there. - **Review new entry points, not just new methods.** A new consumer or job is a new path to every operation it can reach. ## The sentence to say in the interview 'Declare the coarse rule so it is visible and uniform; call the object rule explicitly so it is unavoidable. If the only copy of a rule is a marker, I need to be able to name every path that reaches the method and show that each one crosses the boundary where the marker is evaluated - and on a system with a scheduler and a message consumer, I usually cannot.'

  • Why can a declared rule not express 'only the managing agent for this ticket's building'?
    Because at the point the declaration is evaluated the arguments hold a ticket identifier, not the ticket. The rule's input - which building the stored ticket belongs to - does not exist yet. Making the marker load the ticket puts data access in the interception layer, duplicates the load the method is about to do, and leaves two places that can disagree about which row is the subject.
  • How would you detect an operation that lost its marker?
    Two cheap mechanisms. Make an unmatched route refuse rather than admit, so a forgotten entry-point rule is a visible outage instead of an invisible hole. And add a build-time assertion that enumerates the operations reaching the ticket store and requires each to carry a marker or call the explicit rule. Together they catch the second route, the alias and the override.
  • Is keeping both a marker and an explicit call duplication of one rule?
    Usually not: they are different rules. The marker is admission - may this principal attempt this operation at all - and the explicit call is authorization of the work against the record. Where they genuinely are the same rule, keep the explicit copy and treat the marker as a cheap early refusal, and say so in a comment so the next reader does not delete the wrong one.

A checklist taped to the till helps whoever is standing at the till. A till that will not open without the manager's key helps regardless of who walks up, including the person who came up the back stairs and never saw the checklist.

saying these in an interview costs you the question

  • A marker on the method protects it from every caller
  • Object-level rules can be declared if you pass enough arguments
  • An explicit check inside the method is a code smell
  • If the marker is in the source, the rule is enforced
  • A job calling the same method gets the same declared check
  • Unmatched routes can default to allowed; we mark everything anyway