Introducing Crunchy Data Warehouse: A next-generation Postgres-native data warehouse. Crunchy Data Warehouse Learn more
Tutorial Instructions
At the basic level, Row Level Security (RLS) prevents a query from returning rows the user shouldn’t have access to within that a specific context. For instance, if RLS is enabled on a table and configured to append foo = 'bar'
to your query, then if you run:
SELECT * FROM contacts;
When enabled, RLS will always append that conditional, and Postgres will transform it to run:
SELECT * FROM contacts WHERE foo = 'bar';
If RLS the configured is to append foo = 'bar' and baz = 'qux'
to ALL queries, then if you run:
INSERT INTO
contacts (name, email)
VALUES ('Bruce Wayne', 'bruce@wayneenterprises.com');
It will always append that condition, and Postgres will transform it to run:
INSERT INTO
contacts (name, email, foo, baz)
VALUES ('Bruce Wayne', 'bruce@wayneenterprises.com', 'bar', 'quz')
In this way RLS can restrict SELECT
, UPDATE
, DELETE
, and INSERT
. We’ll walk through each in the examples below. All following queries work, so use the Postgres terminal to the right to test them out.
For this example, we will use a dataset that you might find in a CRM. The tables will be:
In this scenario with a CRM, we’ll show a few different scenarios. The first scenario will be as if each “user” gets their own Postgres login. The next scenario would be if you are running an application that has access to teams.
Without RLS, if a user has access to run SELECT against a table, then a user has access to the entire table. For instance, let’s create a new user called appuser
:
CREATE ROLE appuser LOGIN;
\x
Since we are running as a superuser within the playground Postgres, we can now start behaving as the user with this command. We use this command multiple times in this tutorial to switch roles:
SET ROLE appuser; -- this is possible in Postgres as the superuser, or on any granted roles
Now, let’s try to query the accounts
table, and you’ll get a permissions error:
SELECT * FROM contacts;
After which, you should see permission denied for table accounts
. So, let’s fix the permissions issue by acting as the root user, assigning permissions, and come back to the appuser
user:
SET ROLE postgres;
GRANT SELECT ON TABLE contacts TO appuser;
SET ROLE appuser;
Now, if you run the SELECT
statement above, you’ll see that no rows are returned:
SELECT * FROM contacts;
Great, so let’s give the appuser
access to the rest of the databases:
SET ROLE postgres;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO appuser;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO appuser;
SET ROLE appuser;
Great, so now the appuser
user can do all things on all tables, like so:
SELECT
*
FROM contacts;
If you look at the response, you’ll see that the user called appuser
has access to all records on the contacts
table for all queries. Postgres has a solution for this.
To table row, level security on the contacts
table, run the following:
SET ROLE postgres;
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
SET ROLE appuser;
Now, because we have enabled ROW LEVEL SECURITY
and because we are not a Postgres superuser, if you run the following you see that it returns zero rows:
SELECT * FROM contacts;
Let’s grant permission to a certain value of rows for the appuser
:
SET ROLE postgres;
CREATE POLICY appuser_contacts_policy ON contacts FOR ALL
TO appuser
USING (account_id = 1);
SET ROLE appuser;
Now, if you run the following, you’ll see that you access all the contacts where account_id = 1
:
SELECT * FROM contacts;
But, if you try to join from the accounts
table with a different value for account_id
, you’ll see that it returns the account, but does not return the contacts associated with the account:
SELECT
*
FROM accounts
LEFT JOIN contacts ON accounts.id = contacts.account_id
WHERE accounts.id = 2;
If you do SET ROLE postgres;
and run the above query, you’ll see a list of all rows.
When creating policies with explicit values, the functionality is limited. Postgres does have the capability to create roles for each user and grant them to other roles. Below, we create a new role called account_2
, with it we create a RLS policy to access where account_id = 2
, and we give appuser
the ability to change their role to user_2
. Run and examine each of the commands below:
SET ROLE postgres;
DROP POLICY appuser_contacts_policy ON contacts;
CREATE ROLE account_2;
CREATE POLICY appuser_contacts_policy_account_2 ON contacts FOR ALL
TO account_2
USING (account_id = 2);
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO account_2;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO account_2;
GRANT account_2 TO appuser;
SET ROLE appuser;
To use this new functionality, we can do the following:
SET ROLE account_2;
SELECT
*
FROM accounts
LEFT JOIN contacts ON accounts.id = contacts.account_id
WHERE accounts.id = 2;
SET ROLE appuser;
By doing it this way, the appuser
does not have privilege by default, but gains restricted privilege as it changes roles. Good news, there is a better way without having to manage multiple policies and privileges.
Instead of using a fixed account, we can use the value of a variable. Then, we can dynamically limit query breadth to a single value. Let’s drop and create a new policy that uses a variable:
SET ROLE postgres;
DROP POLICY appuser_contacts_policy ON contacts;
DROP POLICY appuser_contacts_policy_account_2 ON contacts;
CREATE POLICY appuser_contacts_policy ON contacts FOR ALL
TO appuser
USING (account_id = NULLIF(current_setting('rls.account_id', false), '')::integer);
SET ROLE appuser;
This looks more complicated, but it’s not as complicated as it looks. Basically, it extracts a value from rls.account_id
and converts it to an integer to be compared to account_id
. Now, to run a query as the appuser
user, we do can do the following:
SET rls.account_id = 1;
SELECT * FROM contacts;
And, if we want to query from the second account_id
, then we run:
SET rls.account_id = 2;
SELECT * FROM contacts;
If you compare the two queries above, you’ll see that the query is limited to the the value for rls.account_id
. If you are programming this into an application, when switching between accounts, change the rls.account_id
value.
So far, we have been using CREATE POLICY policy_name FOR ALL
. The FOR ALL
clause applies the policy to UPDATE
, SELECT
, INSERT
, and DELETE
. Instead of ALL
, you can also apply the policy on specific actions. When using these policies, actions that are omitted are not permitted.
Using the FOR ALL
policy, if we run an INSERT
, you’ll see that it fails if you try to insert a mismatched value between rls.account_id
and the record’s account_id
:
SET rls.account_id = 2;
INSERT INTO contacts (name, email, account_id, created_at, updated_at)
VALUES ('Bruce Wayne', 'bruce@wayneenerprises.com', 2, now(), now())
RETURNING id, account_id;
The above is successful, but the following will fail with new row violates row-level security policy for table "contacts”
:
SET rls.account_id = 2;
INSERT INTO contacts (name, email, account_id, created_at, updated_at)
VALUES ('Joker', 'joker@laughfactory.com', 1, now(), now())
RETURNING id, account_id;
An update will throw the same error if you try to change an account_id
:
SET rls.account_id = 2;
UPDATE contacts
SET account_id = 1
WHERE account_id = 2;
You can selectively create policies on any of SELECT
, UPDATE
, INSERT
, or DELETE
. Below, we create individual policies for each command, except DELETE
. Then we run the appuser
user will not have permission to delete any rows, even those available:
SET ROLE postgres;
DROP POLICY appuser_contacts_policy ON contacts;
CREATE POLICY appuser_contacts_select_policy ON contacts FOR SELECT
TO appuser
USING (account_id = NULLIF(current_setting('rls.account_id', false), '')::integer);
CREATE POLICY appuser_contacts_update_policy ON contacts FOR UPDATE
TO appuser
USING (account_id = NULLIF(current_setting('rls.account_id', false), '')::integer);
CREATE POLICY appuser_contacts_insert_policy ON contacts FOR INSERT
TO appuser
USING (account_id = NULLIF(current_setting('rls.account_id', false), '')::integer);
SET ROLE appuser;
Now, if we run a delete command, it will return DELETE 0
:
DELETE FROM contacts;
Row Level Security is a powerful feature, and when using variables, it is a flexible. Yes, you do have to start managing permissions, but some use cases call for this level of protection. We often see people move this direction in a multi-tenant world.
Loading terminal...
Loading terminal...