From 302b654faaca7077c46bd7722baa5433842806ee Mon Sep 17 00:00:00 2001 From: aaronrussell <124721263+aaronrussell@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:33:14 +0100 Subject: [PATCH] Add :authorizer connection option (#358) Allow a deny list to be applied to DBConnection managed connections via Exqlite.Sqlite3.set_authorizer/2. The authorizer is installed as the final step of connection setup so it does not interfere with the PRAGMA and load_extension statements issued while connecting. --- CHANGELOG.md | 2 ++ lib/exqlite/connection.ex | 15 +++++++++- test/exqlite/connection_test.exs | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9311b3a5..19a3d7a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: `:authorizer` connection option to deny SQL actions on `DBConnection` managed connections via `Exqlite.Sqlite3.set_authorizer/2`. + ## v0.40.0 - changed: Update sqlite to `3.53.4`. diff --git a/lib/exqlite/connection.ex b/lib/exqlite/connection.ex index d08b4bb3..11bd6b89 100644 --- a/lib/exqlite/connection.ex +++ b/lib/exqlite/connection.ex @@ -144,6 +144,9 @@ defmodule Exqlite.Connection do `1` disable the progress handler, which reduces per-op overhead but means `interrupt/1` and `cancel/1` only take effect once SQLite returns from the current call. + * `:authorizer` - A list of SQL actions to deny on the connection. + Example: `authorizer: [:attach, :detach]`. Defaults to `[]` (no authorizer). + See `Exqlite.Sqlite3.set_authorizer/2` for the list of action atoms. * `:chunk_size` - The chunk size for bulk fetching. Defaults to `50`. * `:key` - Optional key to set during database initialization. This PRAGMA is often used to set up database level encryption. @@ -554,6 +557,15 @@ defmodule Exqlite.Connection do end end + # Must run last in `do_connect/2`. Connection setup issues its own PRAGMA and + # SELECT statements, which would be rejected if the authorizer denied them. + defp set_authorizer(db, options) do + case Keyword.get(options, :authorizer, []) do + [] -> :ok + deny_list -> Sqlite3.set_authorizer(db, deny_list) + end + end + defp load_extensions(db, options) do global_extensions = Application.get_env(:exqlite, :load_extensions, []) @@ -601,7 +613,8 @@ defmodule Exqlite.Connection do :ok <- set_soft_heap_limit(db, options), :ok <- set_hard_heap_limit(db, options), :ok <- load_extensions(db, options), - :ok <- deserialize(db, options) do + :ok <- deserialize(db, options), + :ok <- set_authorizer(db, options) do state = %__MODULE__{ db: db, default_transaction_mode: diff --git a/test/exqlite/connection_test.exs b/test/exqlite/connection_test.exs index 79de9355..87556581 100644 --- a/test/exqlite/connection_test.exs +++ b/test/exqlite/connection_test.exs @@ -183,6 +183,53 @@ defmodule Exqlite.ConnectionTest do File.rm(path) end + + test "setting authorizer denies listed actions" do + path = Temp.path!() + other_path = Temp.path!() + + {:ok, state} = Connection.connect(database: path, authorizer: [:attach]) + + assert {:error, "not authorized"} = + Sqlite3.execute(state.db, "ATTACH DATABASE '#{other_path}' AS other") + + # Actions not in the deny list still work + assert :ok = Sqlite3.execute(state.db, "CREATE TABLE test (id INTEGER)") + + File.rm(path) + File.rm(other_path) + end + + test "authorizer is enforced through DBConnection callbacks" do + path = Temp.path!() + other_path = Temp.path!() + + {:ok, state} = Connection.connect(database: path, authorizer: [:attach]) + query = %Query{statement: "ATTACH DATABASE '#{other_path}' AS other"} + + assert {:error, %Exqlite.Error{message: "not authorized"}, _state} = + Connection.handle_prepare(query, [], state) + + File.rm(path) + File.rm(other_path) + end + + test "authorizer does not interfere with connection setup" do + path = Temp.path!() + + # Setup runs PRAGMA statements, so denying :pragma must not break connect + {:ok, state} = + Connection.connect(database: path, authorizer: [:pragma], journal_mode: :wal) + + assert {:error, "not authorized"} = + Sqlite3.execute(state.db, "PRAGMA journal_mode = delete") + + # Verify the setup pragma took effect via an unrestricted connection + {:ok, other} = Connection.connect(database: path) + assert {:ok, "wal"} = get_pragma(other.db, :journal_mode) + + File.rm(path) + end end defp get_pragma(db, pragma_name) do