Row-level security for multiple roles for hierarchy in PostgreSQL - postgresql

I'm trying to implement row-level security in Postgres. In reality, I have many roles, but for the sake of this question, there are four roles: executive, director, manager, and junior. I have a table that looks like this:
SELECT * FROM ex_schema.residence; --as superuser
primary_key | residence | security_level
------------------+---------------+--------------
5 | Time-Share | executive
1 | Single-Family | junior
2 | Multi-Family | director
4 | Condominium | manager
6 | Modular | junior
3 | Townhouse | director
I've written a policy to enable row-level security that looks like this:
CREATE POLICY residence_policy
ON ex_schema.residence
FOR ALL
USING (security_level = CURRENT_USER)
WITH CHECK (primary_key IS NOT NULL AND security_level = CURRENT_USER);
As expected, when the executive connects to the database and selects the table, that role only sees rows that have executive in the security_level column. What I'd like to do is enable the row-level security so that higher security roles can see rows that match their security level as well as rows that have lower security privileges. The hierarchy would look like this:
ROW ACCESS PER ROLE
executive: executive, director, manager, junior
director: director, manager, junior
manager: manager, junior
junior: junior
I'm wondering how to implement this type of row-level policy so that a specific role can access multiple types of security levels. There's flexibility in changing the security_level column structure and data type.

One thing you can do is define an enum type for your levels:
CREATE TYPE sec_level AS ENUM
('junior', 'manager', 'director', 'executive');
Then you can use that type for the security_level column and write your policy as
CREATE POLICY residence_policy ON ex_schema.residence
FOR ALL
USING (security_level >= CURRENT_USER::sec_level);
There is no need to check if the primary key is NULL, that would generate an error anyway.
Use an enum type only if you know that these levels won't change, particularly that no level will ever be removed.
Alternatively, you could use a lookup table:
CREATE TABLE sec_level
name text PRIMARY KEY,
rank double precision UNIQUE NOT NULL
);
The column security_level would then be a foreign key to sec_level(rank), and you can compare the values in the policy like before. You will need an extra join with the lookup table, but you can remove levels.

Related

PostgreSQL RLS policy to specific user is not working as expected and applies to all users

I have 2 users in my DB, one is "strong" and one is "weak".
I want to apply RLS policy only for one of them, the weak user.
Meaning, when strong user queries the table, it should get all rows. But when weak user queries the table, the policy will be applied and it will return only allowed rows.
I have created a table, and applied the RLS policy only to the weak user.
But even when querying with the strong user, the policy is executed and prevents me from getting all rows.
I'm using PostgreSQL version 11.4.
Here is how I created the policy (I've created the policy with another 3rd user which is an admin and the owner of the table)
CREATE TABLE account_test
(
id bigserial not null,
description varchar(200),
tenant_id UUID not null
);
ALTER TABLE account_test ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_policy ON account_test TO weak_user
USING (tenant_id = current_setting('rls.tenant_id')::uuid);
account=# select * from pg_policies;
schemaname | tablename | policyname | permissive | roles | cmd | qual | with_check
------------+--------------+---------------+------------+---------------+-----+--------------------------------------------------------------+------------
account | account_test | tenant_policy | PERMISSIVE | **{weak_user}** | ALL | (tenant_id = (current_setting('rls.tenant_id'::text))::uuid) |
Now, inserting and selecting with admin user always works, because it is the owner:
insert into account_test (description, tenant_id) VALUES ('desc111', '11111111-c929-462e-ade4-074c81643191');
select * from account_test;
no problem here and all rows returned.
When trying to login with weak_user and select, I get no rows as expected:
select * from account_test;
-- returns 0 rows as expected (weak_user).
If I set the parameter, policy applies and I get the data as expected:
select set_config('rls.tenant_id', '11111111-c929-462e-ade4-074c81643191',true);
select * from account_test;
-- returns 1 row as expected
Now, when I login with strong_user and perform the select * from account_test query, I expect all rows to be returned because policy applies only for weak_user.
However, I get the same behavior as for weak_user and no rows return.
Also the query with set_config does not return anything.
What am I missing?
Is that the expected behavior?
Can someone explain?
The moment you enable RLS on a table with ALTER TABLE account_test ENABLE ROW LEVEL SECURITY, a default-deny all policy is used for all users.
When [RLS] is enabled on a table, all normal access to the table for selecting rows or modifying rows must be allowed by a row security policy. (However, the table's owner is typically not subject to row security policies.) If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified.
– https://www.postgresql.org/docs/current/ddl-rowsecurity.html
Each policy has a name and multiple policies can be defined for a table. As policies are table-specific, each policy for a table must have a unique name. Different tables may have policies with the same name.
In order for strong_user to have access, you will need to add another rule, e.g.
CREATE POLICY tenant_policy ON account_test TO strong_user USING (true);
When multiple policies apply to a given query, they are combined using either OR (for permissive policies, which are the default) or using AND (for restrictive policies). This is similar to the rule that a given role has the privileges of all roles that they are a member of. Permissive vs. restrictive policies are discussed further below.

Row Level Security Enabled update is not working while standard update does using the same logic

Working on postgres 10.4 (on RDS if it makes a difference) I am trying to enforce application user permissions using Row Level Security.
I have a permissions table which looks something like
user_group_id | entity1 | entity2 | entity3 | permission
==============|=========|=========|=========|============
1 |1 |null |null | write
1 |1 |1 |null | read
entity1(root)-entity2-entity3(leaf) is a hierarchy of items stored in different tables. entity1 contains entity2 items, entity2 items contains entity3 items.
permission are inherited meaning entity3 inherits the permissions from entity2, entity2 inherits the permissions from entity1, unless:
Rows with matching entity3 override rows with matching entity2 which override rows with matching entity1.
In the above example user group 1 has write on all entity2/entity3 under entity1=1 (1st row) except for entity2=1 and all entity3 under entity2=1 on which it has read (2nd row) etc.
I already wrote the logic (i think it's not relevant to the actual question hence not pasting it) as a single query using dense_rank - which is a window function.
When I use this logic with an UPDATE directly - it works flawlessly.
When I embed the same exact logic as the WITH CHECK of a row level security policy - update refuses to update the rows (I enabled ROW LEVEL SECURITY on the relevant tables - so that's not it).
I tried to embed my logic in a function instead of directly in the policy - but got the same result, meaning this works:
--not as table owner
update entity1
set col1=1
where entity1_id=10
and check_access(entity1_id); --updates 1 row
but this doesn't:
--as table owner
alter table entity1 enable row level security;
CREATE POLICY entity1_update
ON entity1
AS permissive
FOR UPDATE
TO some_role
WITH CHECK ( check_access(entity1_id) );
--not as table owner
update entity1
set col1=1
where entity1_id=10; --updates no rows
Reading the documentation:
The conditional expression cannot contain any aggregate or window functions - is that the reason? If so - I would have expected an error to be thrown when trying to create the policy or when actually running the update - but nothing happens.
I also tried re-writing my access check logic using a subquery with ORDER BY and wrapping it with a LIMIT - did not help.
Am I missing something? Any way around this?
I think I found the problem - I have another FOR SELECT policy on the same table and I assumed its USING clause would be sufficient (there's an AND between policies of different FOR types) - so I did not include a USING clause in the FOR UPDATE policy. Once I added a USING (true) on the FOR UPDATE policy things started working normally (for now at least - I need to do more experiments).
Seems like it has no relation to using a window function.
I assume postgres allowed me to create a FOR UPDATE policy without a USING clause since it allows multiple policies of the same type - but maybe the documentation should have a note that when having a single FOR UPDATE policy it MUST include both clauses?
UPDATE: I'm not the only one who's update failed for the same reason -
PostgreSQL, unable to update row ( with row level security )
This leads me to ask what does the The conditional expression cannot contain any aggregate or window functions restriction mean - if it seems to work with using dense_rank() over (order by...)... raised Aggregate/Window functions restriction in Postgres Row Level Security Policy conditions

In Postgresql, is there a way to restrict a column's values to be an enum?

In postgresql, I can create a table documenting which type of vehicle people have.
CREATE TABLE IF NOT EXISTS person_vehicle_type
( id SERIAL NOT NULL PRIMARY KEY
, name TEXT NOT NULL
, vehicle_type TEXT
);
This table might have values such as
id | name | vehicle_type
----+---------+---------
1 | Joe | sedan
2 | Sue | truck
3 | Larry | motorcycle
4 | Mary | sedan
5 | John | truck
6 | Crystal | motorcycle
7 | Matt | sedan
The values in the car_type column are restricted to the set {sedan, truck, motorcycle}.
Is there a way to formalize this restriction in postgresql?
Personally I would use foreign key and lookup table.
Anyway you could use enums. I recommend to read article PostgreSQL Domain Integrity In Depth:
A few RDBMSes (PostgreSQL and MySQL) have a special enum type that
ensures a variable or column must be one of a certain list of values.
This is also enforcible with custom domains.
However the problem is technically best thought of as referential
integrity rather than domain integrity, and usually best enforced with
foreign keys and a reference table. Putting values in a regular
reference table rather than storing them in the schema treats those
values as first-class data. Modifying the set of possible values can
then be performed with DML (data manipulation language) rather than
DDL (data definition language)....
However when the possible enumerated values are very unlikely to
change, then using the enum type provides a few minor advantages.
Enums values have human-readable names but internally they are simple integers. They don’t take much storage space. To compete with
this efficiency using a reference table would require using an
artificial integer key, rather than a natural primary key of the value
description. Even then the enum does not require any foreign key
validation or join query overhead.
Enums and domains are enforced everywhere, even in stored procedure arguments, whereas lookup table values are not. Reference
table enumerations are enforced with foreign keys, which apply only to
rows in a table.
The enum type defines an automatic (but customizable) order relation:
CREATE TYPE log_level AS ENUM ('notice', 'warning', 'error', 'severe');
CREATE TABLE log(i SERIAL, level log_level);
INSERT INTO log(level)
VALUES ('notice'::log_level), ('error'::log_level), ('severe'::log_level);
SELECT * FROM log WHERE level >= 'warning';
DBFiddle Demo
Drawback:
Unlike a restriction of values enforced by foreign key, there is no way to delete a value from an existing enum type. The only workarounds are messing with system tables or renaming the enum, recreating it with the desired values, then altering tables to use the replacement enum. Not pretty.

Custom constraints in postgres

I have the below table
Column | Type | Modifiers
-----------+--------------------------+---------------------------------------------------------
id | integer | not null default nextval('votes_vote_id_seq'::regclass)
voter | character varying |
votee | character varying |
timestamp | timestamp with time zone | default now()
Currently , i have a unique constraint with voter and votee meaning that there is only 1 vote per user
I would like to enforce a condition which allows votes to happen weekly using the timestamp column. A User can only vote for the votee only once a week.
Is there a way i can add custom constraints to postgres? Are they the same thing as functions?
A constraint is something like a special trigger in PostgreSQL.
Normal triggers won't do, because they cannot see concurrent modifications of the database, so two concurrent transactions could both see their condition fulfilled, but after they commit, the condition might be violated.
The solution I'd recommend is to use SERIALIZABLE transactions throughout (everybody, including readers, has to use them for it to work) and verify your condition in a BEFORE trigger.
SERIALIZABLE will guarantee that the above scenario cannot happen.

Split table with duplicates into 2 normalized tables?

I have a table with some duplicate rows that I want to normalize into 2 tables.
user | url | keyword
-----|-----|--------
fred | foo | kw1
fred | bar | kw1
sam | blah| kw2
I'd like to start by normalizing this into two tables (user, and url_keyword). Is there a query I can run to normalize this, or do I need to loop through the table with a script to build the tables?
You can do it with a few queries, but I'm not familiar with postgreSQL. Create a table users first, with an identity column. Also add a column userID to the existing table:
Then something along these lines:
INSERT INTO users (userName)
SELECT DISTINCT user FROM url_keyword
UPDATE url_keyword
SET userID=(SELECT ID FROM users WHERE userName=user)
Then you can drop the old user column, create the foreign key constraint, etc.