Stop Permission Bugs With a Role Matrix
Permission Bugs Slip Through Green Test Suites
Permission bugs are uniquely frustrating. Every functional test passes. When you log in as the target role and click through the UI, everything behaves correctly. Then production reports come in: "a Viewer could still press the delete button," or "I could see another team's bug reports." These bugs are structurally hard to catch with ordinary functional testing, because those tests confirm a screen works as intended for its intended role — nobody checks what happens when the wrong role touches it.
Two Patterns Worth Naming
Vertical Privilege Escalation
A lower-privilege role performs an action it should never be able to do. A classic example: a "Member" role has the delete button hidden in the UI, but calling the API directly still deletes the project, because the check only exists on the frontend and the backend never verifies authorization.
Horizontal Privilege Escalation
A user stays within their own permission level but reaches a resource that belongs to someone else. For example, swapping the `:id` in `GET /api/v1/bug_reports/:id` for a record from another project returns data it shouldn't, because the endpoint checks the caller's role but never checks whether the resource actually belongs to their organization.
Building a Role × Action Matrix
Both patterns can be prevented structurally by building a role × action matrix during design, before implementation starts.
- List every action: CRUD on the resource, plus special actions like invite, export, or status changes
- List every role: not just your internal roles (Owner / Admin / Member / Viewer), but also rows for "logged out" and "user from another organization"
- Fill each cell: mark Allow / Deny / Conditional, and spell out the condition when it's conditional
- Review it as a team: conditional cells are exactly where implementers disagree, so get explicit agreement in a design review
In a bug-report SaaS like Bugoon, whether a Viewer can move a bug report to "Closed" is easy to leave ambiguous. Writing it into a matrix forces that decision to surface before anyone writes code.
Turning the Matrix Into Test Cases
The matrix isn't a document to file away — it becomes the input for your tests. In RSpec, parameterize role and resource combinations and assert that every "Deny" cell actually returns 403.
%w[owner admin member viewer].each do |role|
context "role: #{role}" do
let(:user) { create(:user, role: role) }
it "responds according to the permission matrix" do
expected = permission_matrix.lookup(role: role, action: "destroy_project")
delete api_v1_project_path(other_project), headers: auth_headers(user)
expect(response.status).to eq(expected == :allow ? 204 : 403)
end
end
end
Store the matrix as CSV or YAML and reference it from the test suite. When someone adds a new role and forgets to update the matrix, a failing test catches the gap instead of a production incident.
Questions for the Design Review
- Does the team know that adding a new role means revisiting every existing matrix?
- Is authorization enforced in one place (e.g. a controller filter), or scattered across the codebase?
- Does "is this my resource" checking compare organization membership, not just the ID in the URL?
- Have you checked the response body, not just the status code, for leaked cross-tenant data?
Putting This Into Practice With Bugoon
Even a well-tested permission matrix can miss a role combination nobody anticipated. When that happens in the wild, having testers report it through Bugoon's widget — with a screenshot and annotations — and turning it into a GitHub Issue tracked on the kanban board makes it easy to communicate whether it's a horizontal or vertical escalation. If the report also carried the reporter's own role, narrowing down whether a bug is permission-related might get a bit faster.
Streamline bug reporting for your team.
Bugoon is free to get started. Add one line of code to your site and transform how your team handles bugs.
Get Started