Sooner or later most companies replace the bills-and-budgets spreadsheet with a proper internal app: somewhere to enter and code supplier bills, route them for approval, track spend by department and pull numbers for the month-end pack. The screens are the easy part. The hard part is access: who can see which numbers, who can approve what amount, and how you prove it to an auditor afterwards.
This is the access model we’d use for a finance tool like that. Everything below is plain PostgreSQL, so the rules hold whether the data is reached through a Retool app, a custom front end or a reporting tool. We tested every snippet against PostgreSQL 16, then ran it behind a small demo app so you can see what each person actually gets.
How it fits together

| Role | Sees | Can approve |
|---|---|---|
| AP clerk | All bills | Nothing; enters and codes bills |
| Department approver | Their department’s bills | Up to 5,000, never their own |
| Controller | All bills | Up to 50,000; edits supplier bank details |
| CFO | All bills | Any amount |
| Auditor | All bills, read-only | Nothing; can export |
Step 1: Roles, permissions and who holds them
Role-based access control (RBAC) means permissions belong to roles and people are given roles. In finance, roles map onto real jobs. Permissions are actions, not pages: view, enter, approve, export, change supplier bank details.
create schema fin;
create table fin.departments (id int primary key, name text not null);
create table fin.users (
id uuid primary key,
email text unique not null
);
create table fin.roles (name text primary key);
insert into fin.roles values ('ap_clerk'), ('approver'), ('controller'), ('cfo'), ('auditor');
create table fin.permissions (name text primary key);
insert into fin.permissions values
('bill.view'), ('bill.enter'), ('bill.approve'), ('bill.export'), ('supplier.bank.edit');
create table fin.role_permissions (
role text references fin.roles,
permission text references fin.permissions,
primary key (role, permission)
);
insert into fin.role_permissions values
('ap_clerk', 'bill.view'), ('ap_clerk', 'bill.enter'),
('approver', 'bill.view'), ('approver', 'bill.approve'),
('controller', 'bill.view'), ('controller', 'bill.approve'), ('controller', 'bill.export'),
('controller', 'supplier.bank.edit'),
('cfo', 'bill.view'), ('cfo', 'bill.approve'), ('cfo', 'bill.export'),
('auditor', 'bill.view'), ('auditor', 'bill.export');
-- department_id null means "all departments"
create table fin.user_roles (
user_id uuid references fin.users,
role text references fin.roles,
department_id int references fin.departments,
unique (user_id, role, department_id)
);Step 2: Tell the database who is asking
When the app uses one database connection for everyone, it sets the current user at the start of each transaction with set_config, and the rules read it back. (On Supabase you’d use auth.uid() instead.) The third argument, true, makes the setting last only for that transaction, so it can’t leak to the next request on a pooled connection.
create function fin.current_user_id() returns uuid
language sql stable as $$
select nullif(current_setting('app.user_id', true), '')::uuid
$$;
create function fin.has_permission(p text, dept int default null) returns boolean
language sql stable security definer set search_path = fin as $$
select exists (
select 1
from fin.user_roles ur
join fin.role_permissions rp on rp.role = ur.role
where ur.user_id = fin.current_user_id()
and rp.permission = p
and (ur.department_id is null or dept is null or ur.department_id = dept)
)
$$;
-- in the app, per request: begin; select set_config('app.user_id', $1, true); ... commit;Step 3: Bills, with department-level row security
Roles decide what someone can do; row-level security decides which rows they can do it to. A department approver should see their department’s bills and nobody else’s, while the controller, CFO and auditor see everything.
create table fin.bills (
id uuid primary key default gen_random_uuid(),
supplier text not null,
department_id int not null references fin.departments,
amount numeric(12, 2) not null check (amount > 0),
status text not null default 'pending' check (status in ('pending', 'approved', 'rejected')),
entered_by uuid not null references fin.users,
approved_by uuid references fin.users,
approved_at timestamptz
);
alter table fin.bills enable row level security;
create policy bills_read on fin.bills for select
using (fin.has_permission('bill.view', department_id));
create policy bills_enter on fin.bills for insert
with check (fin.has_permission('bill.enter', department_id)
and entered_by = fin.current_user_id());Here is the same bills page for the controller and for the Operations approver. Same app, same query:


Step 4: Approval limits and segregation of duties
Approval rights depend on the amount as well as the role. Keep the thresholds in a table, then put every rule in one function: you can’t approve a bill you entered, the amount must be within your limit for that department, and the approval is written to the audit log in the same transaction.
create table fin.approval_limits (
role text references fin.roles,
department_id int references fin.departments, -- null = any department
max_amount numeric(12, 2) not null
);
insert into fin.approval_limits values
('approver', null, 5000), ('controller', null, 50000), ('cfo', null, 99999999);
create table fin.audit_log (
id bigint generated always as identity primary key,
actor uuid not null,
action text not null,
bill_id uuid not null,
amount numeric(12, 2),
at timestamptz not null default now()
);
create function fin.approve_bill(p_bill uuid) returns void
language plpgsql security definer set search_path = fin as $$
declare
b fin.bills;
me uuid := fin.current_user_id();
my_max numeric;
begin
select * into b from fin.bills where id = p_bill for update;
if not found then raise exception 'Bill not found'; end if;
if b.status <> 'pending' then raise exception 'Bill is already %', b.status; end if;
if b.entered_by = me then
raise exception 'You cannot approve a bill you entered';
end if;
select max(al.max_amount) into my_max
from fin.user_roles ur
join fin.role_permissions rp on rp.role = ur.role and rp.permission = 'bill.approve'
join fin.approval_limits al on al.role = ur.role
where ur.user_id = me
and (ur.department_id is null or ur.department_id = b.department_id)
and (al.department_id is null or al.department_id = b.department_id);
if my_max is null then raise exception 'You cannot approve bills for this department'; end if;
if b.amount > my_max then raise exception 'Amount % is above your approval limit of %', b.amount, my_max; end if;
update fin.bills set status = 'approved', approved_by = me, approved_at = now() where id = p_bill;
insert into fin.audit_log (actor, action, bill_id, amount) values (me, 'approve', p_bill, b.amount);
end $$;In the demo app the Approve button is shown to everyone, deliberately, to show that hiding a button isn’t the control. The clerk who entered the Brightline Logistics bill tries to approve it:

The Operations approver approves the 3,200 bill, then tries the 12,480 Atlas Warehousing bill:



Step 5: Hide sensitive columns
Some fields need tighter rules than the rest of the row. Supplier bank details are the classic case, because changing them is a common fraud route. PostgreSQL can grant access column by column, so the app’s general role reads supplier names but never the account number; only a separate, audited function can change bank details.
create table fin.suppliers (
id int primary key,
name text not null,
bank_account text,
bank_sort_code text
);
create role finance_app nologin;
grant usage on schema fin to finance_app;
grant select (id, name) on fin.suppliers to finance_app; -- no bank columns
grant select, insert on fin.bills to finance_app;
grant execute on function fin.approve_bill(uuid) to finance_app;Step 6: Test the rules
We set up a clerk, two department approvers and a controller, and tried the cases auditors ask about.
-- clerk enters a 3,200 bill for Operations (dept 1)
select set_config('app.user_id', :'clerk', true);
insert into fin.bills (supplier, department_id, amount, entered_by)
values ('Acme Supplies', 1, 3200, fin.current_user_id());
-- the clerk tries to approve it -> ERROR: You cannot approve a bill you entered
-- (the clerk also lacks bill.approve, but the duty check fires first)
-- Operations approver approves it -> ok, audit row written
-- Marketing approver tries a dept 1 bill -> ERROR: You cannot approve bills for this department
-- approver tries a 12,000 bill -> ERROR: Amount 12000.00 is above your approval limit of 5000.00
-- controller approves the 12,000 bill -> ok
-- finance_app selects bank_account -> ERROR: permission denied for table suppliers
Put these in an automated test suite and run it on every change to the schema. When someone adjusts a policy six months from now, the tests will tell you if they opened a gap.
Sign-in and access reviews
Connect the portal to your identity provider, such as Okta, Microsoft Entra ID or Google Workspace, and map groups to rows in user_roles, so joining finance, moving department or leaving the company changes access in one place. Then review role holders once a quarter. A query listing everyone with bill.approve, their department and their limit turns that review into a five-minute job.
For a small finance team, building these screens in Retool on top of this schema is usually the fastest route; for a larger or more specialized portal, a custom app works on the same database. We compare the two in Retool vs a custom-built internal tool.
What shapes the cost
| Factor | Why it matters |
|---|---|
| Number of approval steps | One approver per bill is simple; multi-level approval by amount adds states and tests. |
| Departments and entities | Several legal entities or currencies add another dimension to every rule. |
| Accounting integration | Pushing approved bills to Xero or another ledger adds a sync job and error handling. |
| Identity provider | Mapping SSO groups to roles is quick; custom provisioning flows take longer. |
| Retool or custom front end | Retool is faster for a small team; a custom app suits a larger or client-facing tool. |
Mistakes we’d avoid
Sources
Questions we often hear
What’s the difference between RBAC and row-level security?
RBAC decides what someone can do (view, enter, approve). Row-level security decides which records they can do it to, such as only their own department. A finance tool needs both.
Why put approval rules in the database rather than the app?
So they hold however the data is reached: the app, a reporting tool, an API or a script. Hiding an Approve button isn’t a control.
Can finance change approval limits themselves?
Yes. Limits live in a table, so an admin screen can update them, and every change can be logged like any other action.




