How to Add Row Level Security to Views in PostgreSQL
Posted on
Recently, I needed to store some customer-specific data in a PostgreSQL database and grant customers access to only their data in the shared tables. Fortunately, PostgreSQL has support for row level security in conjunction with its RBAC model which helps us do exactly that.
While row level security does exactly what we need it to for tables, I ran into a challenge when I needed to apply the same row level security to views built from the tables: row level security is only available on tables, not on views! Luckily, I was able to find a way to accomplish what I needed to and learned some more about Postgres along the way.
How to follow along in a Docker “lab” with our schema and dummy data:
# Run the docker container:
$ docker run --rm --detach --name rlslab benburwell/postgres-rls-lab
# Connect to the database in the container using psql:
$ docker exec -it rlslab psql -U postgres
# Remember to stop the container when you’re done!
$ docker stop rlslab
Back to the good stuff:
Let’s start off by creating some tables that we’ll store customer-specific data
in. To grant our customers access to only their data in these tables, we’ll be
creating a role for each customer, e.g. customer_a
, customer_b
, and so on,
and we’ll include a customer_user
column on each table that specifies the role
which should have access to that row:
CREATE TABLE milestones (
id serial primary key,
customer_user varchar,
name varchar
);
CREATE TABLE milestone_events (
milestone_id int,
customer_user varchar,
name varchar
);
Now, we’ll create the customer users. To simplify management, we can create a
generic customer
role that has the access we want each customer to have, and
then just grant that role to new customers as we onboard them.
CREATE ROLE customer;
GRANT SELECT ON milestones TO customer;
GRANT SELECT ON milestone_events TO customer;
Next, we’ll create our individual customer roles and grant them the privileges
from the generic customer
role we just created:
CREATE ROLE customer_a;
CREATE ROLE customer_b;
GRANT customer TO customer_a, customer_b;
Next, let’s populate our milestones
and milestone_events
tables with some
dummy data:
postgres=# SELECT * FROM milestones;
id | customer_user | name
----+---------------+---------------------------
1 | customer_a | A great milestone
2 | customer_a | Another milestone
3 | customer_b | Customer B milestone
4 | customer_c | Spooky invisible milestone
postgres=# SELECT * FROM milestone_events;
milestone_id | customer_user | name
--------------+---------------+----------------
1 | customer_a | First task
1 | customer_a | Second task
2 | customer_a | Another task
3 | customer_b | B event
4 | customer_c | Invisible task
Now, we’ll add the row-level security policies to these tables so that customer users only have access to the appropriate rows in these tables:
postgres=# ALTER TABLE milestones ENABLE ROW LEVEL SECURITY;
ALTER TABLE
postgres=# CREATE POLICY customer_access ON milestones
postgres-# FOR SELECT
postgres-# USING (customer_user = current_user);
CREATE POLICY
Let’s switch over to the customer_a
role and check out the results:
postgres=# set role customer_a;
postgres=> select * from milestones;
id | customer_user | name
----+---------------+-------------------
1 | customer_a | A great milestone
2 | customer_a | Another milestone
Nice! Because of our row-level security policy on the milestones
table, we
only see the rows where customer_user
matches our current user, customer_a
.
It would be really nice to create a view for these tables so that we can see all
the events with their related milestone names. Let’s jump back to the postgres
role and create the view:
postgres=# CREATE VIEW milestone_events_view AS
postgres-# SELECT milestone_id, m.name as milestone_name, e.name as event_name
postgres-# FROM milestone_events e
postgres-# JOIN milestones m ON e.milestone_id = m.id;
CREATE VIEW
postgres=# GRANT SELECT ON milestone_events_view TO customer;
GRANT
Let’s switch back over to our customer_a
role and take a look:
postgres=> SELECT * FROM milestone_events_view;
milestone_id | milestone_name | event_name
--------------+----------------------------+----------------
1 | A great milestone | First task
1 | A great milestone | Second task
2 | Another milestone | Another task
3 | Customer B milestone | B event
4 | Spooky invisible milestone | Invisible task
Whoa! We shouldn’t be able to see all these other customers’ data! That was the
whole point of the row level security policy we set up! As it turns out,
PostgreSQL views always adhere to the permissions of their owner (in this case
the postgres
superuser) rather than the current user.
How can we fix this? Changing the owner of the view wouldn’t help us because
then all the customer users would just see customer_a
’s data.
My solution was to create a function that does the selection. In Postgres,
functions can either be run with the privileges of the user who created them (by
specifying SECURITY DEFINER
), or as the user calling them (with SECURITY INVOKER
).
CREATE FUNCTION customer_milestone_events()
RETURNS TABLE (
milestone_id int,
milestone_name varchar,
event_name varchar
)
LANGUAGE sql
SECURITY INVOKER
AS $$
SELECT milestone_id, m.name AS milestone_name, e.name AS event_name
FROM milestone_events e
JOIN milestones m ON e.milestone_id = m.id
$$;
In order to make the results conveniently available as a view, we can create a view based on this function:
CREATE VIEW pub_milestone_events AS SELECT * FROM customer_milestone_events();
GRANT SELECT ON pub_milestone_events TO customer;
Now, when we switch over to our customer_a
role and query our new view, we
only see the rows we’re supposed to see:
postgres=> select * from pub_milestone_events ;
milestone_id | milestone_name | event_name
--------------+-------------------+--------------
1 | A great milestone | First task
1 | A great milestone | Second task
2 | Another milestone | Another task
And as customer_b
:
postgres=> select * from pub_milestone_events ;
milestone_id | milestone_name | event_name
--------------+----------------------+------------
3 | Customer B milestone | B event
Tada! Row level security on views in PostgreSQL.