diff --git a/lib/mix/tasks/supabase.gen.schema.ex b/lib/mix/tasks/supabase.gen.schema.ex index 2902155..9fe72c4 100644 --- a/lib/mix/tasks/supabase.gen.schema.ex +++ b/lib/mix/tasks/supabase.gen.schema.ex @@ -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 diff --git a/lib/supabase/postgrest/rls.ex b/lib/supabase/postgrest/rls.ex new file mode 100644 index 0000000..9548b87 --- /dev/null +++ b/lib/supabase/postgrest/rls.ex @@ -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 diff --git a/mix.exs b/mix.exs index d0406bc..127a88a 100644 --- a/mix.exs +++ b/mix.exs @@ -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} diff --git a/mix.lock b/mix.lock index 72d0e10..becb2a1 100644 --- a/mix.lock +++ b/mix.lock @@ -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"},