BlogDocsProduct Log in Start free
Behind Hawzu

The Three Permissions That Aren't CRUD

View, create, edit, delete times every QA entity gets you most of a permissions model. Three responsibilities refused to fit that grid — and each one names a risk the grid can't see.

A grid of verb-by-entity permissions with three cells deliberately sitting outside it.

Here’s a requirement that sounds trivial: a tester should be able to run tests without being able to reshape the run.

Try expressing it as view, create, edit or delete. Recording a result is obviously an edit — it writes to the run. But so is adding twelve test cases to the run, removing four, and changing what it covers. Grant edit and you’ve granted both. Withhold it and your testers can’t record results.

The grid can’t say it. That’s not a flaw in this particular grid; it’s what happens when you model what an action touches instead of what an action means.

Verb times entity gets you most of the way

To be clear, the grid is most of the model and it’s fine. Every QA entity — test cases, suites, releases, executions, requirements, defects, boards, shared steps, parameters, custom fields, reports, tokens — carries view, create, edit and delete, at both workspace and project level.

Test casesSuitesReleasesExecutionsRequirementsDefects view create edit delete
  • Run tests Recording a result is not reshaping the run
  • Trigger automation Starts a real build on your own infrastructure
  • Manage boards Your own board is granted by ownership, not permission
Ninety percent of a permissions model is a grid. The exceptions are where the risk stops matching the data.

It’s predictable, it’s explainable to a customer, and a new entity slots in without a design discussion. Ninety percent of what anyone wants to express, it expresses.

The interesting part is the ten percent, because each exception marks a place where the risk isn’t shaped like the data.

Three that refused to fit

Running tests is not editing runs. So recording results has its own permission, gating around two dozen endpoints — final status, step status, evidence, execution notes, time spent, applying a refreshed test case. A pure tester role gets all of that and none of the ability to change which cases are in the run, what it covers, or when it completes. Two genuinely different jobs that happen to write to the same collection.

Triggering a build is not creating a run. Starting an automated run kicks off a real build on the customer’s own CI infrastructure. That’s not a data mutation with a bigger number attached — the blast radius leaves the product entirely. It can consume runner minutes, deploy to an environment, page someone. So triggering is its own permission, deliberately separated from creating the run that will hold the results. Choosing which pipeline a run uses is part of editing the run. Pulling the trigger is not.

Owning a board is not a permission at all. Defect boards have a manage permission, but editing your own board doesn’t need it — that’s granted row-level by who created it. The permission is for reaching into boards you don’t own. And board permissions deliberately don’t cover the cards: dragging a card still goes through the defect endpoints and needs defect edit rights. Hand someone a board without them and they can look but not drag.

Fail-closed, in a specific order

The permissions are only half of it. The order the middleware checks them in is the other half, and it encodes three arguments.

The superuser check runs first, before anything touches the membership store. Not for speed — so that recovery access never depends on the subsystem most likely to be broken when you need it.

Project reachability is checked above the owner shortcut. A workspace owner passes almost everything, but not this: if the project isn’t reachable, the request 404s no matter who’s asking. Putting the owner shortcut first would mean an owner can still reach a project they just deleted, which makes soft-deleting a project cosmetic rather than real.

An unmapped path is a 403. Every endpoint declares its permissions in one central table, and a route missing from that table is denied by default rather than allowed. This is the decision that matters most in practice: with fail-open, the failure mode of forgetting to add your new endpoint is that it ships wide open, and nothing tells you. With fail-closed, it’s that your endpoint 403s in development and you fix it in the same hour. A test polices the table so the denial never becomes permanent by accident.

There’s a smaller version of the same instinct in the permission lookup itself. It returns a pair: a flag meaning this caller passes everything, and the set of individually held permissions. When the flag is true, the set is returned empty — not filled in. It must never be consulted in that case, and leaving it empty means a caller who forgets to check the flag denies rather than allows.

The list that isn’t the contract

One honest wrinkle, because it’s the kind of thing that makes a permissions model quietly wrong.

There’s a canonical list of permission names in the codebase. It reads like the authoritative definition. It isn’t — nothing validates against it. The real contract is the endpoint-to-permission table the middleware actually consults, and the two have drifted: the run-tests permission is used by two dozen endpoint mappings and offered in the role builder, and it isn’t in the canonical list at all.

Nothing is broken. The permission works because the table is what’s enforced. But a list that looks canonical and enforces nothing is worse than no list, because the next person to read it will trust it.

Documentation that looks like a contract, and isn’t enforced as one, is a trap with a helpful tone of voice.

The lesson generalises past permissions: if two artefacts describe the same rule and only one is enforced, the other will drift, and it will drift silently. Either generate it from the enforced one or delete it.

Where this stops being abstract is a cycle run by people who don’t work in the tool every day — handing business testers a narrow role for UAT is the case that exercises all three of these at once.

Which is really the same principle as everything above. A permission model isn’t a taxonomy of your data — it’s a description of who is trusted with which consequences, and the consequences don’t always line up with the tables.

Model what an action means, not what it touches.

Continue learning