Guides

Row-level and field-level permissions in internal tools, explained

Row-level permissions decide which records a user sees; field-level ones decide which columns. How to design both, enforce them on the server and avoid leaks.

Founder, Emberblocks

Published 9 min read

On this page
  1. What are view, row and field permissions?
  2. A worked example: a support desk
  3. A second example: salary in an HR app
  4. Where must permissions be enforced?
  5. Postgres RLS or app-level permissions?
  6. Common mistakes that leak hidden fields
  7. How do you test permissions as each role?
  8. How Emberblocks handles row and field permissions

Row-level permissions decide which records a person can see or change: a support agent sees only the tickets assigned to them. Field-level permissions, called column-level in database terms, decide which fields inside a record they can read or edit: everyone sees an employee's name, only HR sees the salary. Both only count when the server enforces them before data leaves it. A field hidden in the interface but still present in the API response is readable by anyone who opens the browser's developer tools.

This guide defines the layers, works through two examples role by role, explains where enforcement has to live and when to use Postgres row-level security, and lists the mistakes that leak hidden data most often.

What are view, row and field permissions?

Access control in an internal tool usually has several layers. Each answers a different question, and a tool can be strong on one and weak on another.

LayerQuestion it answersExample
View accessWhich screens can this person open?Only managers see the Reports page
Table accessCan they create, read, update or delete this kind of record?Agents update tickets but never delete them
Row-levelWhich records of that kind?Agents see tickets where they are the assignee
Field-level readWhich fields inside a record can they see?Clients never see Internal notes
Field-level editWhich of the visible fields can they change?Agents see Priority, only managers change it

View access is about navigation, and on its own it is the weakest layer: hiding a page does not stop anyone from requesting the data behind it. Table, row and field rules are the ones that protect data, so they are the ones that must run on the server.

A worked example: a support desk

A helpdesk has three roles. Agents work their own queue, managers see everything, and clients (people outside the company who raised a ticket) see their own tickets but never the team's internal notes.

RoleTickets they seeInternal notesCan edit
AgentTickets assigned to themRead and write, on their ticketsStatus, priority, replies
ManagerAll ticketsRead and writeEverything, including the assignee
ClientTickets they raisedNeverNew tickets and replies only
Support desk: what each role can see and edit

Written as rules: the agent's row rule is "assignee is the current user", the client's is "requester email is the current user's email", and the manager has no row rule. Internal notes is a field-level read rule, and it has to hold everywhere the client could meet it: lists, record pages, exports, search, notifications. The client's edit rights are also narrower than their read rights: they can see Status but not change it.

The helpdesk template is a working starting point for this shape of app.

A second example: salary in an HR app

HR data mixes public and private fields in one record. A company directory needs name, department and work email for everyone; salary belongs to HR alone.

RoleDirectory fieldsHome addressSalary
EmployeeEveryone: readOwn record only: read and editHidden
ManagerEveryone: readHiddenHidden
HREveryone: read and editEveryone: read and editEveryone: read and edit
HR app: what each role can see and edit

Look at the Employee row: the home address rule is row-level and field-level at once (this field, but only on your own record). Rules like that are where single-table designs get tangled. The cleanest fix, in most tools and in Postgres itself, is to move sensitive fields into their own table: Employees for directory fields, Compensation for salary, linked by employee id. "Only HR reads salary" then becomes a table permission, the simplest rule to enforce and to audit. The HR onboarding template covers the directory side of such an app.

Where must permissions be enforced?

On the server, every time. OWASP's Top 10 for 2025 keeps broken access control at number one and reports that 100% of the applications tested had some form of it. Its prevention advice starts with the point that matters here: access control is only effective in trusted server-side code, where the attacker cannot modify the check.

The classic leak in internal tools is an API that returns the full row and lets the interface decide what to show. The table on screen hides Salary, but the JSON in the browser's network tab still contains it. OWASP's API Security Top 10 (2023) calls this broken object property level authorization, and its advice is to return only the specific properties a user may see instead of serializing the whole object.

Writes need the same treatment. If the server accepts any field in an update request, a client who cannot see the Assignee field can still set it by sending it. Field-level edit rules are only real when the server drops or rejects the fields a role may not write.

Postgres RLS or app-level permissions?

Postgres has both layers built in. Row-level security (RLS) attaches policies to a table; once it is enabled, every normal read and write must be allowed by a policy, and a table with no policy denies everything. Column privileges grant SELECT or UPDATE on named columns only.

-- Row level: agents see only their own tickets
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;

CREATE POLICY agent_own_tickets ON tickets
  FOR SELECT TO support_agent
  USING (assignee_email = current_setting('app.user_email', true));

-- Field level: staff read every employee column except salary
REVOKE SELECT ON employees FROM staff;
GRANT SELECT (id, full_name, department, work_email) ON employees TO staff;
With column grants in place, SELECT * from employees fails with permission denied for table employees, so queries must name their columns.

The catch is identity. RLS can only filter per person if the database knows who the person is: either everyone has their own database role, or the application sets a variable such as app.user_email at the start of each transaction, as the policy above expects. Supabase does this with the signed-in user's access token. An internal tool that connects with one shared role looks like a single user to Postgres, so per-person rules have to live in the tool.

Also check which role a tool connects as. Superusers and roles with the BYPASSRLS attribute skip RLS entirely, and table owners skip it unless the table is set to FORCE ROW LEVEL SECURITY.

Postgres RLS and column grantsApp-level permissions
Lives inThe databaseThe tool's server
Knows the end userOnly if each request carries their identityYes, from the sign-in
CoversEvery client, including SQL consoles and scriptsOnly traffic through the tool
Best forA hard outer limit; apps whose users sign in to the databasePer-person rules in a tool that connects as one role
Watch out forOwners and superusers bypass it; SELECT * breaks with column grantsMust be enforced in the API, not the interface
Database rules versus app rules

Most teams end up with both: a least-privilege database role sets the outer limit (the tool cannot read a table it was never granted), and the tool's roles narrow it per person. The SQL for that role is in the Postgres front-end guide.

Common mistakes that leak hidden fields

  • Hiding in the interface only. The column is invisible in the table but present in the API response, the export or the page source.
  • Formulas that repeat a hidden value. A computed Salary band, or a lookup that copies a value out of a hidden column, shows a version of the secret to anyone who can see the result.
  • Exports and reports. Exports often run through a separate code path. Check that CSV downloads, generated PDFs and scheduled emails apply the same row and field rules, or switch export off for roles that do not need it.
  • Filtering on hidden fields. If a user can filter by a field they cannot see, they can learn its value one guess at a time: filter on salary above 100,000 and count the rows.
  • Public share links. A link that shows a view to people without accounts must serve only the columns that view displays, not the whole row.
  • New tables with open defaults. Some tools let every role read a new table until someone restricts it. Check the default each time you add a table.
  • Notifications and AI assistants. Email bodies, push messages and chat assistants that read your data are new places for a hidden field to surface.
  • Never testing as each role. Permissions get checked from the builder's chair, where everything is visible.

How do you test permissions as each role?

  1. Write the role table first, like the two above, and keep it next to the app.
  2. Sign in as, or impersonate, one user of each role. Count the rows each one sees in every list and compare with the table.
  3. Paste the URL of a record that user should not see. You should get an error, not a page.
  4. Open the browser's developer tools, reload, and read the JSON responses. Hidden fields must not be in them.
  5. As the restricted role, try an export, a search and a filter that touches a hidden field.
  6. Open every public link in a private window and check which fields the responses contain.
  7. Repeat after every change to roles, formulas or shared views.

How Emberblocks handles row and field permissions

In Emberblocks, roles grant create, read, update and delete per table, plus separate CSV export and import rights. A per-table row filter narrows the rows: [Assignee] = USEREMAIL() gives agents their own queue, [Requester email] = USEREMAIL() gives clients theirs, and a manager role simply has no filter. The rules are enforced on the server, so the builder preview, the published app and public links all follow the same rules. Views, blocks and actions can each be restricted to specific roles as well.

Fields can be hidden app-wide, made read-only, or shown conditionally with a per-field Visible if expression such as USERROLE() = "Manager". A field hidden app-wide is left out of what the server sends and refused on writes, except that a formula referencing it still needs its value, so the value travels with the row whenever such a formula is on screen; review formulas that touch hidden fields. Visible if is a display rule: it decides what each role sees, while the value itself still reaches the browser. For a value one role must never receive, such as salary or internal notes, use the table design from the examples: give it its own table and grant read only to the roles that need it. One detail matters here: a table a role has no entry for defaults to read-only for that role, so add an explicit entry with read switched off.

Users can come from a table you already keep, such as Employees, with up to 20 rules that assign roles from your data ([Department] = "Support" becomes Agent) and a fallback role; deleting a person's row ends their access. Public links serve only the columns the shared view actually uses, strip actions, inline editing and export in read mode, and can stamp a moderation field on anonymous form submissions that the public cannot override.

To check all of it, the builder can preview the app as a specific user, with that user's role, row filters and visibility rules applied, which turns step 2 of the test plan into a dropdown. When Ask Ember for app users is switched on, it reads through the same role: row filters apply and hidden fields never reach it.

Where Emberblocks is not the fit: it does not offer SSO, so if access must flow from your identity provider, look elsewhere. And its per-role rules are set per table and per row, which is why a field that one role may read and another must never receive belongs in its own table.

Questions people ask

What is the difference between row-level and column-level security?
Row-level security limits which records a user can see or change, for example only the tickets assigned to them. Column-level security limits which fields inside those records they can see or change, for example everything except salary. Most real apps need both.
Is hiding a column in the interface enough?
No. If the server still sends the value, anyone can read it in the browser's developer tools, an export or the API. The server has to leave the field out of the response and refuse writes to it.
Should I use Postgres RLS or my tool's permissions?
Use the database for a hard outer limit with a least-privilege role, and RLS where each request carries the end user's identity. Use the tool's permissions for per-person rules when the tool connects to the database as a single role.
How do I hide a column from some users?
In Postgres, revoke table-level SELECT from their role and grant SELECT on the other columns only. In an app builder, check whether field rules are enforced on the server; if they only control display, move the column into a separate table those users cannot read.

Sources

  1. OWASP Top 10:2025, A01 Broken Access Control
  2. OWASP API Security Top 10 (2023), API3 Broken Object Property Level Authorization
  3. PostgreSQL docs: row security policies
  4. PostgreSQL docs: privileges (column-level grants)
  5. EDB: column and row level security in PostgreSQL
  6. Supabase docs: Row Level Security

Written by

Apostolos builds Emberblocks, a visual app builder for internal tools on your own data, from Thessaloniki, Greece. Spotted something wrong or out of date? Write to hello@emberblocks.com.

Keep reading

Ready to build on your own data?

Connect Postgres, Airtable or a spreadsheet export and publish a working app your team can use today. Builders are always free.