Two years ago, in a quarterly review, the HR director of a manufacturing customer asked me a question and I answered it too fast. "Everyone on the plant floor can open a personnel record," she said. "Only three people should ever be able to see the salary field. Can your platform do that?"
I said yes. It took us eighteen months to find out what I had actually promised.
The demo was easy. We added a setting on the field: hidden for these roles. The form rendered, the field disappeared, everyone nodded. That demo worked perfectly, and it kept working perfectly, right up until the customer's finance team asked for an export.
Rows have owners. Columns have meanings.
Row-level security is the part of the problem that behaves. A row has an owner — a department, a region, a creator, a customer. There is usually a natural key you can attach a rule to, and that rule can be pushed into the query as a predicate. Filtering rows is a database problem, and databases are good at database problems.
A column has no owner. A column has a meaning. And the meaning of a column is a decision somebody made eighteen months ago, in a meeting nobody recorded, by a person who has since left the company.
That asymmetry is why almost every low-code platform ships row-level security first and field-level security later, and why the second one leaks quietly for years before anyone notices.
The problem is not the form. It is every other reader.
Field-level security fails because it gets implemented as a property of a screen instead of a property of the model. A screen is just one reader. A successful platform has many, and you add more every quarter.
Here is what we already had.
The list view. We hid the field on the detail form. The grid still rendered thirty columns, and the salary was one of them.
The export. Export never touches the form. It runs against the query layer with its own serializer, written by a different person in a different year with a different idea of what "hidden" meant. The field was masked on screen and raw in the CSV. The HR director found out the way customers always find out: someone emailed a spreadsheet.
The filter panel. This is the one I am still embarrassed by. A user who could not see the field could still filter and sort by it. So the hidden field became an oracle. You cannot read the salary column, but you can sort by it and see who is at the top. You can filter for values above a threshold and bisect the range until you know what your colleague earns. We had not merely failed to hide the data. We had published a query interface to it.
The API. The field was gone from the form, and fully present in the API response, because our serializers were designed to be complete. Completeness is a virtue in a serializer and a vulnerability in a permission model. Anyone with a token and a browser console could read what the form politely declined to show.
The search index. The index is built by a background job running as a system account with no user context at all. Query-time permission checks made the search results look honest. But the index itself held the raw value, and the highlighting feature — which existed only to be helpful — would happily show a sentence fragment containing the number.
The dashboard. You cannot see individual salaries, but you can see the sum for a region. Subtract the values you already know, and three people's salaries remain. Aggregation does not hide data. It turns it into a puzzle with a known answer.
The notification. Message templates are usually built in a drag-and-drop designer by somebody in operations, not by an engineer. A template renders server-side, before anyone asks who the recipient is. The notification is a second renderer, built by a non-engineer, without any of the guardrails of the first.
The audit log. Our audit trail was, and still is, excellent. It records every field, before and after, immutably. Then we asked who could read the audit log. It was a considerably larger group than the group allowed to see the field. The history of a value is the value.
The AI agent. This is the one that finally forced the redesign. An agent queries as itself, not as you. We gave it a service account, and service accounts carry no field restrictions, because service accounts are system actors. So the agent reads the salary column and then, being helpful, summarizes it.
Nine readers. We had hidden the field in one of them.
Masking is not encryption, and neither one is a policy
Whenever this came up, someone suggested encrypting the field. Encryption at rest protects the disk. It does not protect the column. An encrypted value that the platform decrypts in order to show it to an authorized user is, from the platform's point of view, simply a value — and every reader listed above will receive the decrypted version.
Masking is a presentation decision. Rendering a phone number as its last four digits is a formatting choice. It removes nothing. It changes what a cell looks like and nothing else about what the system knows.
Neither one is a policy. A policy is a single answer to a single question — who may read this field — and it has to be the same answer in every reader. If five readers each implement the rule, you do not have a rule. You have five habits, and four of them will drift.
What we changed
The fix was structural, and it moved work backwards into the metadata layer.
Field sensitivity became a property of the field in the model, not a setting on a form. The policy is resolved into the query, so a hidden field is absent from the result set rather than removed from the render. Filters, sorts, grouping and tree views consult the same policy, because they all pass through the same resolver. Exports go through it too, and producing an export now writes its own audit event — a different event from viewing a record, because it is a different act.
The search index no longer contains fields the index is not allowed to contain. It is less useful than it was. It is also no longer a leak.
The AI agent runs with the asking user's scope, not its own. That single change altered more behavior than anything else we did.
And we stopped masking silently. If you cannot see a field, the field says so. A blank cell is a lie when it looks like "no data."
The uncomfortable conclusion
The row is the unit your users argue about. The column is the unit your auditors argue about. We built field-level security as a checkbox on a form because that is where the customer asked the question — and it was the right feature in the wrong layer.
Here is the part I keep coming back to. The number of readers of a field grows with the platform's success. Every capability you ship — export, search, dashboards, notifications, integrations, an AI agent — is another reader, and each one is built by a different team in a different year.
So field-level security is not a feature you finish. It is an invariant you maintain, and the cost of maintaining it scales with the number of ways your platform can read a single value.
A permission system is judged by what it denies. A field security model is judged by what it forgets to deny.
Ours looked complete. Then we shipped an AI agent and found out it wasn't.
Top comments (0)