Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/mix/tasks/supabase.gen.schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,8 @@ defmodule Mix.Tasks.Supabase.Gen.Schema do
end

defp format_generated_files(config) do
Mix.Tasks.Format.run(["#{config.output_dir}/**/*.ex"])
alias Mix.Tasks.Format
Format.run(["#{config.output_dir}/**/*.ex"])
end

defp extract_tables(ast) do
Expand Down
173 changes: 173 additions & 0 deletions lib/supabase/postgrest/rls.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
alias Ecto.Adapters.SQL, as: EctoSQL

if Code.ensure_loaded?(EctoSQL) do
defmodule Supabase.PostgREST.RLS do
@moduledoc """
Helpers for working with PostgreSQL Row Level Security (RLS) policies in Ecto.

This module provides utilities to set session context for RLS policies when using
the Ecto schemas generated by `mix supabase.gen.schema`.

## Usage

Wrap operations in a transaction and set session variables that are automatically
cleaned up when the transaction completes:

defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app

import Supabase.PostgREST.RLS

def with_user_context(user_id, fun) do
transaction(fn ->
set_rls_context(__MODULE__, "request.jwt.claims.sub", user_id)
fun.()
end)
end
end

# Usage
MyApp.Repo.with_user_context(user_id, fn ->
MyApp.Repo.all(MyApp.Accounts.User)
end)

## Common Supabase RLS Patterns

Supabase RLS policies commonly use these session variables:

- `request.jwt.claims.sub` - User ID from JWT
- `request.jwt.claims.role` - User role from JWT
- `request.jwt.claims.email` - User email from JWT

Example policy in your database:

CREATE POLICY "Users can view their own data"
ON users FOR SELECT
USING (id = current_setting('request.jwt.claims.sub')::uuid);

## Testing with RLS

When testing, you may want to bypass RLS policies. You can disable RLS for specific tables:

Ecto.Adapters.SQL.query!(MyApp.Repo, "ALTER TABLE users DISABLE ROW LEVEL SECURITY")
"""

@doc """
Sets a PostgreSQL session configuration variable for the current transaction.

This function executes `set_config/3` with the `is_local` parameter set to `true`,
ensuring the configuration is transaction-scoped and automatically cleared when
the transaction completes.

## Parameters

* `repo` - The Ecto repository module
* `key` - The configuration parameter name (e.g., "request.jwt.claims.sub")
* `value` - The value to set (will be converted to string)

## Examples

# Setting user ID for RLS policies
Repo.transaction(fn ->
set_rls_context(repo, "request.jwt.claims.sub", user.id)
Repo.all(User)
end)

# Setting user role
Repo.transaction(fn ->
set_rls_context(repo, "request.jwt.claims.role", "authenticated")
Repo.all(User)
end)

# Setting user email
Repo.transaction(fn ->
set_rls_context(repo, "request.jwt.claims.email", user.email)
Repo.all(User)
end)

# With custom variable name for multi-tenancy
Repo.transaction(fn ->
set_rls_context(repo, "app.current_tenant", tenant_id)
Repo.all(from u in User, where: ...)
end)

## Security Considerations

- Always validate and sanitize the `value` parameter before passing it to this function
- Never pass user input directly without validation
- Consider using prepared statements for complex values

"""
def set_rls_context(repo, key, value) when is_binary(key) do
value_str = to_string(value)

EctoSQL.query!(
repo,
"SELECT set_config($1, $2, true)",
[key, value_str]
)
end

@doc """
Sets multiple RLS context variables at once in a single database query.

This is more efficient than calling `set_rls_context/3` multiple times when you need
to set several session variables.

## Parameters

* `repo` - The Ecto repository module
* `claims` - A map of configuration keys to values

## Examples

# Set multiple JWT claims at once
Repo.transaction(fn ->
set_rls_claims(repo, %{
"request.jwt.claims.sub" => user.id,
"request.jwt.claims.role" => "authenticated",
"request.jwt.claims.email" => user.email
})
Repo.all(User)
end)

"""
def set_rls_claims(repo, claims) when is_map(claims) and map_size(claims) > 0 do
{keys, values} = Enum.unzip(Map.to_list(claims))

set_config_calls =
keys
|> Enum.with_index(1)
|> Enum.map_join(", ", fn {_key, idx} ->
key_param = idx * 2 - 1
val_param = idx * 2
"set_config($#{key_param}, $#{val_param}, true)"
end)

query = "SELECT " <> set_config_calls

params =
Enum.zip(keys, Enum.map(values, &to_string/1))
|> Enum.flat_map(&Tuple.to_list/1)

EctoSQL.query!(repo, query, params)
end

@doc """
Gets the current value of an RLS context variable.

Returns `nil` if the variable is not set.

## Examples

current_user_id = get_rls_context(MyApp.Repo, "request.jwt.claims.sub")

"""
def get_rls_context(repo, key) when is_binary(key) do
case EctoSQL.query(repo, "SELECT current_setting($1, true)", [key]) do
{:ok, %{rows: [[value]]}} when value != "" -> value
_ -> nil
end
end
end
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ defmodule PostgREST.MixProject do
defp deps do
[
supabase_dep(),
{:ecto_sql, "~> 3.13", optional: true},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.3", only: [:dev, :test], runtime: false}
Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
%{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
"ex_doc": {:hex, :ex_doc, "0.38.4", "ab48dff7a8af84226bf23baddcdda329f467255d924380a0cf0cee97bb9a9ede", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "f7b62346408a83911c2580154e35613eb314e0278aeea72ed7fedef9c1f165b2"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
Expand Down