Breadcrumbs

RLS Policies

To protect your data, you can apply row-level-security policies to your data. This article lists some common policies you can apply to your data in NextKit.


Supabase uses RLS policies to protect your data. RLS are useful for two main reasons:

  1. Supabase exposes your database directly to your clients. This means that your clients can query your database directly. RLS policies allow you to restrict what data they can access.
  2. You can use SQL policies to apply access rules to your data. This helps you to keep your data secure without having to write any additional code.

In this document, we'll list some common RLS policies you can apply to your data in NextKit.

You can copy and paste some of this (provided the table names match) into your database so that you can get started quickly.

Common RLS Policies

Restricting access to a table based on a column

This policy will restrict access to a table based on a column. In this example, we're restricting access to the users table based on the id column.

sqlcreate policy "Restrict access to user tasks to their own tasks"
  on tasks
  for select
  to authenticated
  using (user_id = auth.uid());

Restricting write access to a table based on a column

This policy will restrict write access to a table based on a column. In this example, we're restricting write access to the users table based on the id column.

sqlcreate policy "Restrict write access to authenticated users"
  on users
  for insert
  to authenticated
  with check (id = auth.uid());

Truthfully, this is not a very useful policy. It's unlikely that you'll want to restrict access to a table based on the user ID in a multi-tenant application. However, it's a good example if you store per-user data in a table.

Restrictive Policies

If you want these rules to be restrictive (i.e. they apply to all queries), then you can add as restrictive:

sqlcreate policy "Restrict write access to authenticated users"
  on users
  as restrictive;
  for insert
  to authenticated
  with check (id = auth.uid())

This means that if you have 2 select policies, one restrictive and one permissive, then the restrictive policy will take precedence. If the restrictive policy fails, then the permissive policy will not apply.

This is useful when a policy needs to apply to all queries.

Restricting access to a table based on a column and a role

In NextKit - multi-tenancy is achieved using organizations. As such, you'll want to restrict access to data based on the organization_id column.

Let's create a policy that restricts access to the tasks table based on the organization_id column - taking into consideration the user's role in the organization.

User roles are defined using a hierarchy. The hierarchy is as follows:

  1. Owner (role = 2)
  2. Admin (role = 1)
  3. Member (role = 0)

You can update this as you see fit - but this is the default hierarchy. Remember that the role column is stored in the memberships table and needs to be updates should you wish to change the hierarchy.

Create the function to get the user's role

sqlcreate or replace function get_current_user_role(org_id bigint)
returns int as $$
declare
    user_role int;
begin
    select role
        from memberships
        where organization_id = org_id and user_id = auth.uid()
        into user_role;
 
    if user_role is null then
        raise exception 'User is not a member of the organization';
    end if;
 
    return user_role;
end;
$$ language plpgsql;

Create the policies

Now that we have the function to get the user's role, we can create the policies. The policy state that only the owner can insert, update, or delete tasks.

sqlcreate or replace policy "Only the owner can update tasks"
    on tasks
    as restrictive
    for update
    to authenticated
    using (
        get_current_user_role(organization_id) >= 1
    ) with check (
        get_current_user_role(organization_id) >= 1
    );
 
create or replace policy "Only the owner can delete tasks"
    on tasks
    as restrictive
    for delete
    to authenticated
    using (
        get_current_user_role(organization_id) >= 1
    );

Let's now allow every organization member to read tasks.

We can use the built-in current_user_is_member_of_organization function to check if the user is a member of the organization.

sqlcreate or replace policy "Only the owner can delete tasks"
    on tasks
    as restrictive
    for select
    to authenticated
    using (
        current_user_is_member_of_organization(organization_id)
    );

NB: when using a select policy, you don't need to use with check. This is because the using clause is sufficient to restrict access to the data. With that said, remember than failing policies won't throw an error - they'll just return no data.

We can also allow every member to create tasks within their organization using the insert policy:

sqlcreate or replace policy "Only the owner can insert tasks"
    on tasks
    as restrictive
    for insert
    to authenticated
    with check (
        current_user_is_member_of_organization(organization_id)
    );

Subscriptions and RLS

RLS policies are applied to subscriptions. This means that if you have a subscription that returns data that the user doesn't have access to - then the subscription will fail.

Let's see how we can use RLS for restricting access to subscriptions.

This is very dependent on your use case. You'll need to think about how you want to restrict access to your data. But for the sake of this example, let's assume that we want to restrict how many tasks a user can write to the database.

Storing your Plans in the database

First, we'll need to store our plans in the database. We'll create a plans table that stores the id, name, and max_tasks for each plan.

You have two options here when talking about Stripe:

  1. assign a plan to a Product ID
  2. assign a plan to a Price ID

If you need different quotas for monthly and yearly plans - then you'll need to assign a plan to a Price ID. If you don't need different quotas for monthly and yearly plans - then you can assign a plan to a Product ID.

In the example below, we're assigning a plan to a Price ID.

sqlcreate table plans (
  name text not null,
  price_id text not null,
  task_quota int not null,
  primary key (product_id)
);

We also allow read access to this table for all authenticated users:

sqlcreate policy "Allow all authenticated users to read plans"
    on plans
    as restrictive
    for select
    to authenticated
    using (true);

A function to get the organization's subscription

Let's create an SQL function that returns the organization's subscription:

sqlcreate or replace function get_active_subscription(org_id bigint)
returns table (
  period_starts_at timestamptz,
  period_ends_at timestamptz,
  price_id text,
  "interval" text
) as $$
begin
    return query select subscriptions.period_starts_at, subscriptions.period_ends_at, subscriptions.price_id, subscriptions."interval" from public.subscriptions
    join organizations_subscriptions on subscriptions.id = organizations_subscriptions.subscription_id
    where organizations_subscriptions.organization_id = org_id;
end;
$$ language plpgsql;

A function to get the organization's task count

Let's create an SQL function that returns the organization's task count:

sqlcreate or replace function get_organization_task_count(org_id bigint)
returns int as $$
declare
    task_count int;
begin
    select count(*)
        from tasks
        where organization_id = org_id
        into task_count;
 
    return task_count;
end;
$$ language plpgsql;

A function to check if the organization can create a task

Let's create an SQL function that returns true if the organization can create a task:

sqlcreate or replace function organization_can_create_task(org_id bigint)
returns boolean as $$
declare
    task_count int;
    organization_price_id text;
    plan_task_quota int;
begin
    select get_organization_task_count(org_id)
        into task_count;
 
    select price_id
    into organization_price_id
    from get_active_subscription(org_id);
 
    if organization_price_id is null then
        raise exception 'Organization does not have an active subscription';
    end if;
 
    select task_quota from plans where price_id = organization_price_id into plan_task_quota;
 
    return task_count < plan_task_quota;
end;
$$ language plpgsql;

Here we store the total tasks count in a variable:

sqlselect get_organization_task_count(org_id) into task_count;

Then we get the organization's price ID:

sqlselect price_id
    into organization_price_id
    from get_active_subscription(org_id);

If the organization doesn't have an active subscription - then we throw an error:

sqlif organization_price_id is null then
    raise exception 'Organization does not have an active subscription';
end if;

What if you wanted to provide a default plan for organizations that don't have an active subscription? You could do something like this for allowing 10 tasks for organizations that don't have an active subscription:

sqlif organization_price_id is null then
  return task_count < 10;
end if;

Then we get the plan's task quota:

sqlselect task_quota from plans where price_id = organization_price_id into plan_task_quota;

Finally, we check that the count of tasks is less than the plan's task quota:

sqlreturn task_count < plan_task_quota;

Wrapping it all up in a policy

We can now write a policy that restricts access to the tasks table based on the organization's task quota:

sqlcreate or replace policy "Only allow organizations to create tasks if they have enough quota"
    on tasks
    as restrictive
    for insert
    to authenticated
    with check (
        organization_can_create_task(organization_id)
    );

You don't always need RLS

Sometimes - you don't need RLS. As long as RLS is enabled on the table - you can think about not applying any RLS policies.

Instead - you can manage access to the data using traditional server-side code. This is useful if you want to apply more complex access rules to your data or when the data is not directly updated by the client - but instead by a server-side process (such as a cron job or a webhook).

Restricting Functions and Stored Procedures

Sometimes - you need a security definer function or stored procedure. This is useful for bypassing RLS policies. But it's risky.

In Makerit, we added a function to restrict calling these functions only from the server using a service role key - so that these functions can't be called from the client - and allowing you to perform the required checks before calling the function.

The function is assert_service_role. You can perform a check like this:

sqlcreate or replace function create_task(user_id bigint, name text)
returns tasks as $$
begin
    perform assert_service_role();
 
    insert into tasks (user_id, name) values (user_id, name);
 
    return (select * from tasks where id = currval('tasks_id_seq'));
end;
$$ language plpgsql security definer search_path = public;

This function will throw an error if it's called from the client. You can then call this function from the server using a service role key and ensure that the user has enough quota to create a task server-side before calling this function.