Row-Level Security
Introduction
This is an Access Control feature used to secure data. It determines whether a user has the right to see or modify a specific row of data.
RLSallows you to controlSELECT, INSERT, UPDATE, or DELETEprivileges on specific rows of data based on the identity of the user executing the command.- How it works
- First, you need to enable
RLSfor eachtableyou want to use - Then, you define
Policiesto permit how users can perform data operations. If noPolicyexists, by default no one (except theSuperuser/Owner) can see any row of data in the table anymore - Next, when a query is submitted, the system does not run that command immediately but performs a step called
Query Rewrite - It will automatically add filter conditions (like a
WHEREclause) based on the createdPoliciesto hide rows that the user does not have permission to view.
- First, you need to enable
- Example: In a
SaaSsystem, you enableRLSto ensure that customers of one Company only see data rows belonging to that Company, remaining completely unaware of the existence of data from other Companies (even though all data is stored in the sameTable)
Characteristics
- Performance impact
- Since
RLSautomatically adds filter conditions to theSQLquery, its performance depends on whether those conditions are optimized - You should ensure that columns used in the
Policyhave anIndexcreated soPostgrescan query faster.
- Since
- Common applications
RLSis extremely useful inMulti-tenantapplications (SaaS software, where multiple companies share the sameDatabasebut must absolutely never see each other's data)- Instead of writing manual filtering code at the
Application(Backend) layer which is highly prone to omissions, you useRLSto block it right at theDatabaselayer to ensure absolute security.
Detail
First, create the tables and Index as follows
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
price NUMERIC(12, 2) NOT NULL,
stock INT NOT NULL DEFAULT 0
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
customer_name VARCHAR(255) NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL,
status VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_products_tenant ON products(tenant_id);
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status);
Enable Row level security for tables and also for the Owner user
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenants FORCE ROW LEVEL SECURITY;
ALTER TABLE products FORCE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
After that, create Users and grant permissions to use the Schema and Tables as follows
CREATE USER app_user WITH PASSWORD 'password';
CREATE USER "36321616-093d-491e-b9b3-49256f59585c" WITH PASSWORD 'password';
GRANT USAGE ON SCHEMA public TO "36321616-093d-491e-b9b3-49256f59585c";
GRANT USAGE ON SCHEMA public TO app_user;
GRANT ALL ON TABLE tenants TO "36321616-093d-491e-b9b3-49256f59585c";
GRANT ALL ON TABLE tenants TO app_user;
GRANT ALL ON TABLE products TO app_user;
GRANT ALL ON TABLE orders TO app_user;
Next, create the Policy as follows
CREATE POLICY tenant_read_policy ON tenants
FOR ALL
TO public
USING (id::text = current_user);
CREATE POLICY product_write_policy ON products
FOR ALL
TO app_user
USING (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
)
WITH CHECK (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
);
CREATE POLICY order_select_policy ON orders
FOR SELECT
TO app_user
USING (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
AND status <> 'cancelled'
);
CREATE POLICY order_insert_policy ON orders
FOR INSERT
TO app_user
WITH CHECK (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
);
CREATE POLICY order_update_policy ON orders
FOR UPDATE
TO app_user
USING (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
AND status <> 'cancelled'
)
WITH CHECK (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
);
- Policy 1: This
Policyonly allows operations on theTenantwhose id matches the current userON tenants: creates the Policy for tabletenantsFOR ALL: applies toSELECT, INSERT, UPDATE, DELETETO public: applies to all current Users and Users created in the futureUSING (id::text = current_user): this condition automatically appendsWHERE id = current_userinto the corresponding statement, since the id column type isUUID, I useid::textto cast it toTEXTto compare it withcurrent_user
- Policy 2: Policy restricts that a
Tenantcan only add products with atenant_idmatching their own ID and can only edit or delete products owned by thatTenantTO app_user: only applies to theapp_usercreated abovetenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuidapp.current_tenant_idis the value passed into each Transaction, which will be retrieved using thecurrent_settingfunction- If using
current_useras required byPolicy 1, eachTenantwould need a differentUser - This approach is not suitable for a large system where multiple
Podsconnect via aConnection Poolthat uses only 1User - Therefore, using
current_settingto let the user pass their own tenant ID for processing is a more reasonable and effective solution
- If using
NULLIFchecks ifapp.current_tenant_idhas a value to use, otherwise it returns''::uuidcasts the result to theUUIDtypeWITH CHECKis used to inspect data about to be used in anINSERTor data after anUPDATE, ensuring it must be assigned with your correcttenant_id(not accidentally assigned to another tenant)
- Policy 3: Creates a
PolicyforSELECTto only allow viewing your own orders (except cancelled orders) - Policy 4: Creates a
PolicyforINSERTto only allow creating new orders for your own tenant - Policy 5: Creates a
PolicyforUPDATE- Only allows editing your own uncancelled orders and the data after updating must still belong to your tenant.
- No need to create a
PolicyforDELETEbecause by default deletion is not allowed in that table
SELECT
First, seed data for tables as follows
Then, switch to this role to test the tenant Policy
SET ROLE "36321616-093d-491e-b9b3-49256f59585c";
For the following examples, use this role to check
SET ROLE app_user;
BEGIN;
SET LOCAL app.current_tenant_id = '3ecdc59a-5e7f-40a1-9bad-b55916c9b4c6';
SELECT * FROM products
SELECT * FROM orders
- Retrieve the
productrecords belonging to the current tenant - Retrieve the
orderrecords belonging to the currenttenantwherestatus <> 'cancelled' - Use
SET LOCALso thatapp.current_tenant_idonly exists within that specificTransaction, avoiding otherrequestsfrom using the same tenant information - This is a commonly used approach when multiple
Podsconnect to theDatabasethrough aConnection Pool, because theConnection Pooluses only 1Rolemaking it very difficult to applycurrent_user, so you should pass an additionaltenant_idfor proper validation
If you pass a tenant_id that contains no data, you will not find the corresponding product information
BEGIN;
SET LOCAL app.current_tenant_id = '3ecdc59a-5e7f-40a1-9bad-b55916c9b4c1';
SELECT * FROM products
INSERT
BEGIN;
SET LOCAL app.current_tenant_id = '3ecdc59a-5e7f-40a1-9bad-b55916c9b4c6';
-- Statement 1
INSERT INTO products (tenant_id, name, price, stock)
VALUES ('3ecdc59a-5e7f-40a1-9bad-b55916c9b4c6', 'Invalid tenant', 110.00, 100);
-- Statement 2
INSERT INTO products (tenant_id, name, price, stock)
VALUES ('3ecdc59a-5e7f-40a1-9bad-b55916c9b4c1', 'Invalid tenant', 110.00, 100);
- Statement 1:
INSERTsucceeds because it uses the sametenant_idas the active one - Statement 2:
Policyblocks it because it attempts toINSERTwith a differenttenant_idthan the active one
UPDATE & DELETE
BEGIN;
SET LOCAL app.current_tenant_id = '3ecdc59a-5e7f-40a1-9bad-b55916c9b4c6';
-- Statement 1
UPDATE orders
SET status = 'shipped'
WHERE id = 'e838d9ea-d548-4fd5-9fe3-aab0ed2c9037';
-- Statement 2
UPDATE orders
SET total_amount = 100.00
WHERE id = 'b466cf05-11cc-4d34-a7a4-e1e436b6bbf5';
-- Statement 3
UPDATE orders
SET tenant_id = '3ecdc59a-5e7f-40a1-9bad-b55916c9b4c2'
WHERE id = 'e838d9ea-d548-4fd5-9fe3-aab0ed2c9037';
-- Statement 4
DELETE FROM orders
WHERE id = 'e838d9ea-d548-4fd5-9fe3-aab0ed2c9037';
- Statement 1: Changes
statustoshipped, this statement is valid so it executes successfully - Statement 2: Update target is an
orderwith astatusofcancelled, which is blocked by thePolicy - Statement 3: Updates the
tenant_idfor theorder, which is blocked by thePolicy, resulting in no order found to update - Statement 4: Deleting the
orderis also blocked by thePolicy, so no row will be found to delete
Happy coding!
Comments
Post a Comment