Let's talk
security

Your permission model probably stops at the ORM and the CSV export walks straight past it

A reporting module ran parameterised queries and returned rows, either to a dashboard or to a CSV file. It ran under row-level security, so a caller could only ever see their own organisation’s data. Tenant isolation held.

What it did not receive was the caller’s identity. The report functions took a report key and some parameters, and produced rows. There was no principal, so there was no way to apply the caller’s data scope.

Which meant a user restricted to a single branch could export the entire organisation’s customers, orders, stock levels, receivables and payments as a CSV file and keep it.

Every screen in the product respected that user’s branch restriction. The lists were narrowed, the detail views refused records outside their branch, the dashboards showed their own numbers. The report module was the one path where scope simply was not a parameter, and it was the path that produced a downloadable file.

Why this happens structurally

Permission models are usually built inward from the screens. Someone defines roles, attaches permissions, and wires the checks into the request handlers as they build each feature. That produces good coverage of the paths that existed when the model was designed.

Reporting is almost always built separately, later, by someone thinking about aggregation rather than authorisation. It has its own query layer because the ordinary service layer returns typed objects and a report needs arbitrary column sets. And the moment it has its own query layer, it has its own relationship with the permission model — which is usually none.

The same is true of anything that generates a file, feeds a webhook, backs a scheduled email, or serves an integration. Each is a data egress path that did not go through the request handler where the checks live.

The question worth asking of any system: list every path by which data leaves it, and for each one, name the check. Not “is there authorisation” — name the specific check on that specific path. The paths people miss are the ones where the output is not a screen.

Reading and exporting are different permissions

The fix took the principal into the report functions, and with it something less obvious: the permission key the endpoint was gated on.

The reason is that data scope is not a property of a role. It is a property of a (role, permission) pair. A branch manager might legitimately read organisation-wide figures on a dashboard — a glanceable total, on screen, in context — and legitimately be refused an export of the underlying rows. Those are different risks. A number on a screen is a number on a screen. A CSV is a file that leaves the building, gets emailed, sits on a laptop, and survives the person leaving the company.

So the report path checks read scope when serving a dashboard and export scope when producing a file, and they can be configured to different values for the same role.

This is a generalisable point about permission design. Granular permissions are not about enumerating actions for their own sake — they are about being able to say yes and no to two things that a coarse role would have to answer identically. If your model cannot express “can see the total, cannot download the rows”, it will be configured to whichever of those two answers is less annoying, which is always the more permissive one.

Choosing the narrowing column is a modelling decision

Applying a branch scope to a report is not one change; it is one change per report, and each requires a judgement about what “belongs to this branch” means for that data.

Orders, stock movements and payments narrow on their own branch column — straightforward, the record happened at a branch.

Customers narrow on their home branch, because a customer is registered to a branch rather than occurring at one.

Outstanding balances needed a new view built specifically for the purpose, and the reasoning is the interesting part. The existing view was customer-grained. Narrowing it by the customer’s home branch would have shown that customer’s invoices from every branch — the right customers, the wrong money. Re-deriving the branch-level figures in application code was rejected too, because that would create a second implementation of the same financial calculation, and two implementations of one number is a defect with a delivery date.

When a scope filter does not fit the existing data shape, add the shape. Do not approximate it in application code, and do not accept a filter that narrows the wrong dimension. A report that shows approximately the right rows is worse than one that refuses, because nobody can tell by looking.

There was one scope value the reports refused to support at all. A scope meaning “only records this user personally created” is expressible on a list screen and not meaningful across an aggregated report. Rather than approximating it — filtering by creator and calling it close enough — the module returns an explicit error saying that scope is unsupported for reports. Refusing is the honest answer, and it is far better than producing a file whose contents nobody can characterise.

Proving the fix rather than asserting it

The part of this I would repeat on any security fix: after the change, the fixed code was stashed and the test suite run against the original code.

Fourteen tests failed, including three that specifically demonstrated the CSV leak.

That inversion is worth doing every time. A passing test suite after a fix proves the tests agree with the current code. It does not prove the tests would have caught the original bug — and a test written after the fix, by the person who wrote the fix, very often does not. Running it against the broken version is the only way to know whether you have written a regression test or a tautology.

It takes a couple of minutes and it converts “I believe this is fixed” into “this specific behaviour is now pinned”. On a security fix, that difference is the whole point of writing the test.

The checklist

From this and the sweep that followed it:

  • Enumerate every data egress path, including exports, integrations, scheduled jobs, webhooks and anything that emails a file. For each, name the check.
  • Separate read from export as distinct permissions, with independently configurable scope.
  • Pass the principal and the permission being exercised into shared query layers, not just the principal — the scope depends on both.
  • When a scope cannot be applied correctly to a given output, refuse it rather than approximating it.
  • Prove the regression test fails against the unfixed code.

The underlying rule is short: authorisation belongs to the data, not to the screen. Anywhere a query layer can be called from more than one place, the check has to live in the query layer, because one of those places will eventually forget.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch