Finance

Role-based access for an internal finance portal: approval limits and segregation of duties in PostgreSQL

Role-based access for an internal finance portal: approval limits and segregation of duties in PostgreSQL

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

Architecture: staff sign in through the identity provider; the finance app sets the user on each transaction and connects as a restricted role; PostgreSQL holds roles, row security, the approval function and the audit log
The app connects as one restricted role and tells the database who is asking. The database applies the rules.
RoleSeesCan approve
AP clerkAll billsNothing; enters and codes bills
Department approverTheir department’s billsUp to 5,000, never their own
ControllerAll billsUp to 50,000; edits supplier bank details
CFOAll billsAny amount
AuditorAll bills, read-onlyNothing; can export
The roles in the demo. Your limits will differ, which is why they live in a table.

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.

sql
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.

sql
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.

sql
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());
No update policy, on purpose. Approvals go through a function that checks the rules first.

Here is the same bills page for the controller and for the Operations approver. Same app, same query:

Finance Hub signed in as Arjun Mehta, Controller: six bills across Operations, Marketing and Engineering
The controller sees all six bills across three departments.
Finance Hub signed in as Daniel Brooks, Approver for Operations: only the three Operations bills
The Operations approver sees only the three Operations bills. Marketing and Engineering never leave the database.

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.

sql
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 $$;
All approval rules in one place, enforced by the database and audited in the same transaction.

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:

Finance Hub signed in as Priya Shah, AP clerk, with the error: You cannot approve a bill you entered
Segregation of duties, enforced by approve_bill(). The message is the database’s own.

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

Finance Hub signed in as Daniel Brooks with the error: Amount 12480.00 is above your approval limit of 5000.00; Brightline Logistics shows approved
The 3,200 bill is approved; the 12,480 bill is above the 5,000 limit and is refused.
Finance Hub signed in as the controller with the message: Bill approved and recorded in the audit log
The controller’s limit is 50,000, so the same bill goes through.
Audit log with two approvals: Daniel Brooks approved Brightline Logistics 3,200 and Arjun Mehta approved Atlas Warehousing 12,480
Both approvals in the audit log, written in the same transaction as the change.

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.

sql
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.

sql
-- 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
Terminal output of the approval rules test: errors for self-approval, wrong department, over limit and bank details; approvals for the valid cases
The real output of the test file. Every refusal comes from the database, and both approvals appear in the audit log.

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

FactorWhy it matters
Number of approval stepsOne approver per bill is simple; multi-level approval by amount adds states and tests.
Departments and entitiesSeveral legal entities or currencies add another dimension to every rule.
Accounting integrationPushing approved bills to Xero or another ledger adds a sync job and error handling.
Identity providerMapping SSO groups to roles is quick; custom provisioning flows take longer.
Retool or custom front endRetool is faster for a small team; a custom app suits a larger or client-facing tool.

Mistakes we’d avoid

Sources

Spotted something out of date? Tell us

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.

Replacing a finance spreadsheet with a proper tool?

Tell us who needs to see and approve what. On a free 30-minute call we’ll sketch the roles, limits and approval flow we’d build.