skip to content

How do you build an authorization matrix as a test artefact, and what do its negative cells assert?

level: middleimportance: must knowfreq 57%

answer

  1. Rows and columns, not screens
  2. Count the cells nobody ever runs
  3. Deny cells outnumber allow cells
  4. Ownership is a third dimension
  5. An unfilled cell fails the build

basics

~20 s

Rows are actor classes, columns are operations, and each cell says allow or deny. A parameterized suite iterates the whole table, and every deny cell asserts a refusal, an unchanged state, no information leak and the required record of the attempt.

solid answer

~50 s

Draw rows as actor classes — every named role plus the unauthenticated caller, an actor from a different account or scope, and any service or support identity — and columns as operations at the service boundary rather than screens. Add a third dimension per cell for the actor's relationship to the target: their own record, a peer's, and one in another scope. Generate the cases from the table instead of hand-writing them, so a new row regenerates a whole column and an unfilled cell fails the build. The deny cells are where defects live, because a missing check never breaks the happy path. Each deny cell asserts four things: a refusal distinguishable from a crash or a wrong route, byte-identical state afterwards, a message that does not disclose whether the target exists, and the audit record the product's rules require.

code

pseudocode · 19 lines
pseudocode
matrix = [
  {actor: "planner",       operation: "publish_timetable", allowed: true},
  {actor: "teacher",       operation: "publish_timetable", allowed: false},
  {actor: "student",       operation: "publish_timetable", allowed: false},
  {actor: "other_planner", operation: "publish_timetable", allowed: false},
  {actor: "anonymous",     operation: "publish_timetable", allowed: false}
]

for cell in matrix:
    before = snapshot("timetable")
    session = sign_in_as(cell.actor)
    result = call(cell.operation, session)
    if cell.allowed:
        assert result.status == 200
    else:
        assert result.status == 403
        assert snapshot("timetable") == before
        assert not result.body.contains("other_school")
        assert audit_contains(cell.actor, cell.operation, "denied")

go deeper

for a junior

Know what the artefact is: actor classes down the side, operations across the top, allow or deny in every cell. Be able to say why the deny cells are the ones that find defects.

for a middle

Explain the mechanics an interviewer is probing: why columns are operations rather than screens, why the anonymous and other-account rows exist, and how cases are generated from the table so a new row regenerates a column.

for a senior

Demonstrate the assertions that make a deny cell real — unchanged state, a refusal that leaks nothing about existence, the audit record — and be ready to describe a defect where the status code was right and the system was still wrong.

for a principal

Own the economics. Be prepared to argue how big the matrix should get, which operations may share a column, where the cases run, and how you make an undecided cell block a merge without stalling delivery.

### The artefact An authorization matrix used as a test artefact is a table whose rows are **actor classes** and whose columns are **operations**, with each cell recording whether that actor may perform that operation. It is not a design document that lives in a wiki and rots; it is the input a parameterized suite iterates over, so that every cell is exercised on every run. The two axes have to be chosen carefully, and both are commonly chosen wrong. **Rows are actor classes, not just named roles.** A usable row list includes the unauthenticated caller, each named role, an authenticated actor from a *different* scope or account, and - where the product has them - a service or automation identity and a support or impersonating identity. Teams that list only the product's three named roles leave out the two rows that catch the most defects. **Columns are operations, not screens.** A screen may issue four calls; a background job may issue an operation no screen ever shows. If the columns are drawn from the interface, everything reachable only through the service boundary is invisible to the matrix, which is precisely where enforcement gaps hide. ### The negative cells are the test For a school timetable planner with four actor classes - planner, teacher, student, and a planner from another school - and nineteen operations, the matrix has seventy-six cells. Twenty-two of them are allow cells; fifty-four are deny cells. A suite that tests "each role can do their job" covers the twenty-two. The remaining fifty-four are the ones a defect actually lives in, because an enforcement gap does not make a permitted action fail - it makes a forbidden action succeed. Nothing about the happy path degrades when a check is missing, which is why these defects survive release after release. A deny cell asserts more than a status code. The minimum useful assertion set is: 1. **The outcome the caller sees** - a refusal distinguishable from a crash and from a "not found because the route is wrong". 2. **Side-effect absence** - the state the operation would have changed is byte-for-byte what it was before the attempt. This is the assertion most often missing, and the one that catches an **ordering assumption**: the timetable planner reserved the room slot and *then* checked whether the caller was allowed to book it, so a refused request still consumed a slot. The status code was a correct refusal; the system was still wrong. 3. **No information leak in the refusal** - the message does not disclose whether the target record exists, and does not echo data from the other actor's scope. 4. **The audit record** - if the product's rules say a denied attempt is recorded, the record exists, names the actor and the operation, and does not contain the credential itself. ### The third axis A two-dimensional role-by-operation matrix cannot express the most common enforcement gap, because "a teacher may view a timetable" and "a teacher may view *this* timetable" are different claims. The matrix needs a resource-relationship dimension on each cell: the actor's **own** record, a **peer's** record in the same scope, and a record in **another scope**. In practice this is done by making the row key a pair - actor class plus relationship to the target - which triples the row count and is still cheaper than discovering the gap in production. ### Keeping it alive The matrix earns its keep only if it is the single source both the implementation and the suite read. Three habits do that. Generate the cases from the table rather than hand-writing one test per cell, so adding a row regenerates the whole column. Make an **unfilled cell a failure**, not a skip - a new operation added without a decision for every actor class breaks the build, which forces the conversation at the moment the operation is written rather than at audit time. And review the table whenever a role is added, because a new role silently inherits whatever the default branch does, and the default branch is usually "allow". The cost is real and worth stating in an interview: a full cross-product grows multiplicatively, and a four-person team cannot maintain thousands of hand-written cells. The answer is not to shrink the matrix to the happy cells; it is to keep the table small by grouping operations that genuinely share one enforcement rule, generate the cases, and run the deny cells at the cheapest level that still exercises the real check - the service boundary rather than the interface.

  • Why is a two-dimensional role-by-operation matrix not enough?
    Because "a teacher may view a timetable" and "a teacher may view this particular timetable" are different claims, and only the second one catches an actor reaching another actor's record. Each cell needs a relationship dimension — the actor's own record, a peer's record in the same scope, and a record in another scope. Without it the matrix is fully green while cross-account reads succeed.
  • The full cross-product is thousands of cells and a small team cannot maintain it. How do you keep it tractable?
    Do not shrink it to the allow cells. Keep the table small by grouping operations that genuinely share one enforcement rule into a single column, generate the cases from the table rather than hand-writing them, and run them at the cheapest boundary that still exercises the real check. Growth then costs a row or a column, not a test file, and the expensive part stays the decision rather than the code.
  • How does the matrix stay current as the product changes?
    Make the table the source both the suite and the review read, and make an unfilled cell a failure rather than a skip. A new operation then cannot merge until someone decides what every actor class may do with it, which forces the conversation when the operation is written. Re-review the whole table whenever a role is added, because a new role inherits whatever the default branch does — and the default is usually allow.

saying these in an interview costs you the question

  • Drawing columns from screens rather than operations
  • Listing only named roles, omitting anonymous and other-account actors
  • Testing only the allow cells and calling the matrix covered
  • Asserting a status code and nothing about state
  • Treating an unfilled cell as a skip rather than a failure
  • Believing a missing check shows up as a broken happy path

context