PostgreSQL RLS with JSONB for Dynamic Attribute-Based Access Control

14 min read
Goh Ling Yong
Technology enthusiast and software architect specializing in AI-driven development tools and modern software engineering practices. Passionate about the intersection of artificial intelligence and human creativity in building tomorrow's digital solutions.

The Inevitable Limits of Static RBAC

In any sufficiently complex application, especially multi-tenant SaaS platforms, the authorization model eventually outgrows simple Role-Based Access Control (RBAC). The initial users, roles, and user_roles tables that served you well in the beginning start to fray at the edges. You're faced with requirements that don't map cleanly to a static role:

* "A manager can only approve expenses up to $5,000, but a director can approve up to $20,000."

* "A support_agent from the EU can only view tickets from EU customers to comply with GDPR."

* "An editor can only modify a document if it's in a draft state and belongs to a project they are explicitly assigned to."

Implementing this logic in the application layer leads to a scattered and brittle authorization system. Queries become decorated with an ever-growing list of WHERE clauses, developers forget to apply checks on new endpoints, and ensuring consistency across multiple microservices becomes a Herculean task. The authorization logic becomes divorced from the data it protects.

This is where Attribute-Based Access Control (ABAC) provides a more powerful paradigm. Access is not granted based on a static role, but by evaluating policies against the attributes of the user, the resource being accessed, and the environment.

This article details a robust pattern for implementing a dynamic ABAC system directly in PostgreSQL. We will leverage two of its most powerful features: Row-Level Security (RLS) to enforce access control at the database engine level, and the JSONB data type to store a flexible set of user and object attributes. By pushing authorization to the data layer, we create a single, authoritative source of truth that is atomic, language-agnostic, and incredibly performant when indexed correctly.


Core Architecture: RLS Policies Fueled by JSONB Attributes

The core concept is to centralize user permissions into a single JSONB column. This avoids the endless JOINs and schema migrations associated with traditional permission modeling. We then write RLS policies that consult this JSONB blob to make access decisions.

1. Schema Design

Let's model a simple document management system for a multi-tenant organization.

sql
-- A tenant in our system
CREATE TABLE organizations ( 
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL
);

-- Users belong to an organization and have a JSONB attribute store
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES organizations(id),
    email TEXT NOT NULL UNIQUE,
    attributes JSONB NOT NULL DEFAULT '{}'::jsonb
);

-- The resources we want to protect
CREATE TABLE documents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES organizations(id),
    project_id UUID NOT NULL,
    title TEXT NOT NULL,
    content TEXT,
    status TEXT NOT NULL DEFAULT 'draft', -- e.g., 'draft', 'in_review', 'published'
    region TEXT NOT NULL -- e.g., 'EU', 'US-WEST', 'APAC'
);

-- Enable RLS on the table we want to protect
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- By default, access is denied. We must create policies to allow access.
ALTER TABLE documents FORCE ROW LEVEL SECURITY;

The key is the users.attributes column. A user's attributes might look like this:

json
{
  "clearance_level": 3,
  "regions": ["EU", "US-WEST"],
  "projects": {
    "a1b2c3d4-e5f6-7890-1234-567890abcdef": "editor",
    "b2c3d4e5-f6a7-8901-2345-67890abcdef0": "viewer"
  },
  "can_publish": false
}

This structure is infinitely extensible without schema changes. Need to add department-based access? Simply add a "department": "engineering" key-value pair to the JSONB.

2. Establishing User Context in a Session

RLS policies need to know who is making the query. The most robust way to handle this is by setting a session-level configuration variable when a user authenticates and a connection is established from the pool.

sql
-- From your application's connection logic:
-- SET LOCAL is transaction-scoped, which is ideal.
SET LOCAL "app.current_user_id" = 'user-uuid-goes-here';

Now, within a transaction, we can create helper functions to securely access this context.

sql
-- Helper to get the current user's ID. Returns NULL if not set.
CREATE OR REPLACE FUNCTION auth.current_user_id() RETURNS UUID AS $$
BEGIN
    RETURN current_setting('app.current_user_id', true)::UUID;
EXCEPTION
    WHEN OTHERS THEN
        RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;

-- A more powerful helper to get the entire attributes blob for the current user.
-- This function is the cornerstone of our policies.
CREATE OR REPLACE FUNCTION auth.current_user_attributes() RETURNS JSONB AS $$
DECLARE
    attr_blob JSONB;
BEGIN
    SELECT attributes INTO attr_blob
    FROM public.users
    WHERE id = auth.current_user_id();
    RETURN attr_blob;
END;
$$ LANGUAGE plpgsql STABLE;

Note on STABLE: We mark these functions as STABLE because their output is consistent within a single statement, which allows the query planner to optimize more effectively.


Advanced Policy Authoring with JSONB Operators

Now we can combine these pieces to write expressive RLS policies. An RLS policy is essentially a USING clause that PostgreSQL appends to every query against the table.

Scenario 1: Basic Tenant Isolation

First, a foundational policy: users can only see documents within their own organization. This is a classic multi-tenancy check.

sql
CREATE POLICY tenant_isolation ON documents
FOR SELECT
USING (organization_id = (
    SELECT organization_id FROM users WHERE id = auth.current_user_id()
));

While this works, it requires a sub-query for every RLS check. We can optimize this by setting the organization ID in the session as well, but for now, let's focus on the attributes.

Scenario 2: Region-Based Access

Requirement: A user can only see documents from the regions they are assigned to in their attributes.

sql
-- Let's drop the previous one to add a more complex one.
DROP POLICY IF EXISTS tenant_isolation ON documents;

CREATE POLICY select_documents_policy ON documents
FOR SELECT
USING (
    -- Standard tenant isolation
    organization_id = (SELECT organization_id FROM users WHERE id = auth.current_user_id())
    
    -- AND the document's region must be in the user's allowed regions array
    AND region = ANY(SELECT jsonb_array_elements_text(auth.current_user_attributes() -> 'regions'))
);

Here, we use -> 'regions' to extract the regions array from the JSONB blob. Then jsonb_array_elements_text unnests this JSONB array into a set of text rows, which we can use with the ANY operator. This is powerful but can be inefficient. A better way uses the JSONB containment operator @>.

Let's refine our helper and policy for performance. We can't use @> directly on the text region column. Instead, we can construct a JSONB value to check against.

sql
-- More performant version of the region check
CREATE OR REPLACE POLICY select_documents_policy ON documents
FOR SELECT
USING (
    organization_id = (SELECT organization_id FROM users WHERE id = auth.current_user_id())
    AND (auth.current_user_attributes() -> 'regions') @> to_jsonb(region)
);

This is much cleaner and allows for GIN index usage, which we'll cover in the performance section.

Scenario 3: Role-Based Access within a Project

Requirement: A user can see any document in a project where they are an editor or viewer, but can only update a document if they are an editor and the document's status is draft.

This requires two separate policies: one for SELECT (USING) and one for UPDATE (USING for row visibility and WITH CHECK for the update condition).

sql
-- Drop the previous policy to build a new composite one
DROP POLICY IF EXISTS select_documents_policy ON documents;

CREATE POLICY view_project_documents ON documents
FOR SELECT
USING (
    -- User must have the project_id as a key in their 'projects' attribute map
    auth.current_user_attributes() -> 'projects' ? project_id::text
);

-- Policy for updates. This is far more restrictive.
CREATE POLICY update_draft_documents_as_editor ON documents
FOR UPDATE
USING (
    -- The user must be able to see the row in the first place (implicitly covered by SELECT policy)
    true
)
WITH CHECK (
    -- The user's role for this project must be 'editor'
    auth.current_user_attributes() -> 'projects' ->> project_id::text = 'editor'
    
    -- AND the document status must be 'draft'
    AND status = 'draft'
);

Let's break down the WITH CHECK policy:

  • auth.current_user_attributes() -> 'projects': Accesses the projects object within the JSONB.
  • ->> project_id::text: Accesses the value associated with the key matching the document's project_id. The ->> operator returns the value as text.
  • = 'editor': We check if that value is exactly 'editor'.
  • AND status = 'draft': We combine it with a check on the resource's own attributes.
  • This demonstrates the power of ABAC: the policy combines attributes of the user (role in project) and attributes of the resource (status).


    Performance Deep Dive: Making ABAC Production-Ready

    Naively implemented RLS can destroy your database's performance. Every query against a protected table is rewritten, and if your policy contains slow functions or subqueries, every single SELECT, INSERT, UPDATE, or DELETE pays that price. Here’s how to make it fast.

    1. Indexing for JSONB Policies

    The single most important optimization is to create a GIN index on your attributes column.

    sql
    CREATE INDEX idx_users_attributes_gin ON users USING GIN (attributes jsonb_path_ops);

    Why GIN? A standard B-tree index can't efficiently index the contents of a JSONB document. A GIN (Generalized Inverted Index) index creates an index entry for each key and value within the JSONB, allowing for extremely fast lookups using operators like:

    * @> (contains): attributes @> '{"department": "engineering"}'

    * ? (key exists): attributes ? 'can_publish'

    * ?| (any key exists): attributes ?| array['is_admin', 'is_auditor']

    * ?& (all keys exist): attributes ?& array['department', 'region']

    The jsonb_path_ops operator class is optimized for the @> operator, while the default jsonb_ops is better for ?, ?|, and ?&. Choose based on your most common policy patterns.

    Let's analyze the impact. Consider our policy auth.current_user_attributes() -> 'projects' ? project_id::text. The function auth.current_user_attributes() involves a lookup on the users table:

    SELECT attributes FROM public.users WHERE id = auth.current_user_id()

    This lookup on users.id is already fast because it's a primary key. However, if our policies needed to scan the users table to find users with certain attributes, the GIN index would be critical.

    Example Benchmark:

    Imagine a policy that allows a manager to see all documents from users in their department. USING ( (SELECT attributes->>'department' FROM users WHERE users.id = documents.author_id) = (auth.current_user_attributes()->>'department') ). This is a terrible policy because it runs a correlated subquery. A better approach is to denormalize the department onto the documents table. But if you must do such a lookup, a GIN index on (attributes->'department') would be essential.

    2. The `SECURITY DEFINER` vs. `SECURITY INVOKER` Minefield

    Our auth.current_user_attributes() function has a subtle, critical flaw. If we were to enable RLS on the users table itself (e.g., to prevent users from seeing each other), that policy would apply inside our function! This creates a circular dependency: to check the policy on documents, we call a function that tries to read from users, which triggers the RLS policy on users, which might itself call a function... a recipe for disaster.

    The solution is SECURITY DEFINER.

    sql
    CREATE OR REPLACE FUNCTION auth.current_user_attributes() RETURNS JSONB AS $$
    -- ... function body as before ...
    $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

    A SECURITY DEFINER function executes with the permissions of the user who defined the function, not the user who invokes it. This allows the function to bypass the RLS policies of the calling user, enabling it to read the necessary row from public.users to fetch the attributes.

    CRITICAL SECURITY WARNING: SECURITY DEFINER is a powerful tool that can be easily misused. You must ensure that such functions are completely secure and do not leak data. For instance, never make the user ID an argument to the function. Always source it from a secure context like current_setting. Also, it's wise to set a secure search_path for the function to prevent hijacking:

    sql
    ALTER FUNCTION auth.current_user_attributes() SET search_path = public;

    This prevents the function from accidentally calling a malicious user-defined function in another schema.

    3. Analyzing Policy Performance with `EXPLAIN ANALYZE`

    If a query is slow, your first step should always be EXPLAIN ANALYZE. When RLS is active, you will see the policy's conditions injected directly into the query plan.

    sql
    -- In a session where app.current_user_id is set
    EXPLAIN ANALYZE SELECT * FROM documents WHERE project_id = '...';

    Output without optimization: You might see a slow Seq Scan on users inside the policy function call for every single row being evaluated from documents.

    Output with optimization: You should see an Index Scan on users (using its primary key) happening just once, and the resulting attribute value being used in the filter on documents.

    Pay close attention to the cost and timing of nodes related to your RLS functions. If they are being called per-row and are slow, that's your bottleneck.


    Edge Cases and Production Patterns

    1. Superuser / Admin Access

    How do you bypass RLS for administrative tasks, internal tools, or support staff? There are two primary methods:

    * The BYPASSRLS Attribute: You can grant this attribute to a specific database role. Any user connected with that role will bypass all RLS policies. This is a big hammer but is often the cleanest solution for trusted internal roles.

    sql
        ALTER ROLE my_admin_role BYPASSRLS;

    * Policy-based Bypass: Modify your policies to include a bypass condition based on a user attribute.

    sql
        CREATE POLICY my_policy ON my_table FOR SELECT USING (
            (auth.current_user_attributes() ->> 'is_admin')::boolean
            OR
            ( -- normal policy logic here -- )
        );

    2. Debugging Policies: The Wall of Silence

    One of the most frustrating aspects of RLS is that when a row is filtered, it's simply gone. There's no error, no message. It's just an empty result set. This makes debugging a nightmare.

    Strategies for Debugging:

  • Direct EXPLAIN: As mentioned, EXPLAIN will show you the exact query being run, policy included. You can copy this rewritten query and run it manually to see what's happening.
  • Logging in Policies: For complex debugging, you can temporarily add RAISE NOTICE statements inside a plpgsql function used by your policy to print out intermediate values.
  • SECURITY DEFINER Debug View: Create a view that bypasses RLS for administrators, allowing them to see the raw data. This is invaluable for support staff trying to understand why a user can't see a specific record.
  • sql
        CREATE VIEW admin_views.all_documents AS
        SELECT * FROM public.documents;
        
        -- Grant access only to the admin role
        GRANT SELECT ON admin_views.all_documents TO my_admin_role;

    3. Managing Policies in Migrations

    RLS policies are DDL and should be managed with your database migration tool (e.g., Flyway, Sqitch, Alembic). Always write idempotent scripts.

    sql
    -- A sample migration script
    BEGIN;
    
    -- Drop the policy if it exists to ensure a clean state
    DROP POLICY IF EXISTS view_project_documents ON public.documents;
    
    -- Recreate the policy with the new/updated logic
    CREATE POLICY view_project_documents ON public.documents
    FOR SELECT
    USING (
        auth.current_user_attributes() -> 'projects' ? project_id::text
    );
    
    COMMIT;

    This ensures that your migrations can be run repeatedly without errors.

    Conclusion: A Centralized, Data-Centric Authorization Model

    By combining PostgreSQL's Row-Level Security with the JSONB data type, we can architect a sophisticated Attribute-Based Access Control system that lives alongside the data it protects. This pattern offers significant advantages over application-layer enforcement:

    * Centralized Logic: The policies are defined in one place, not scattered across a dozen microservices.

    * Language Agnostic: The rules are enforced no matter what language or tool connects to the database.

    * Atomicity: Data mutations and permission checks occur within the same atomic transaction.

    * Performance: With proper indexing and function design, the overhead of RLS can be negligible.

    This approach is not a silver bullet. It requires a deep understanding of PostgreSQL, careful performance tuning, and a robust strategy for debugging and administration. But for applications with complex, dynamic authorization requirements, pushing the logic down into the database provides a level of security, consistency, and power that is difficult to achieve otherwise.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles