diff --git a/.changeset/log-accessible-tables.md b/.changeset/log-accessible-tables.md new file mode 100644 index 0000000000..aa86ec164e --- /dev/null +++ b/.changeset/log-accessible-tables.md @@ -0,0 +1,5 @@ +--- +'@core/sync-service': patch +--- + +Log all tables accessible to Electric at startup. This helps operators understand which tables the connected database user has SELECT permission on and aids in debugging permission issues. diff --git a/packages/sync-service/lib/electric/connection/manager.ex b/packages/sync-service/lib/electric/connection/manager.ex index 0bad7e00e3..356ea9ba2e 100644 --- a/packages/sync-service/lib/electric/connection/manager.ex +++ b/packages/sync-service/lib/electric/connection/manager.ex @@ -110,6 +110,7 @@ defmodule Electric.Connection.Manager do :max_shapes, :persistent_kv, purge_all_shapes?: false, + logged_accessible_tables?: false, # PIDs of the database connection pools pool_pids: %{admin: nil, snapshot: nil}, validated_connection_opts: %{replication: nil, pool: nil}, @@ -118,6 +119,7 @@ defmodule Electric.Connection.Manager do end use GenServer, shutdown: :infinity + alias Electric.Postgres.Configuration alias Electric.Postgres.LockBreakerConnection alias Electric.Connection.Manager.ConnectionBackoff alias Electric.Connection.Manager.ConnectionResolver @@ -483,6 +485,14 @@ defmodule Electric.Connection.Manager do ) end + state = + if state.logged_accessible_tables? do + state + else + log_accessible_tables(state) + %{state | logged_accessible_tables?: true} + end + repl_sup_opts = [ stack_id: state.stack_id, shape_cache_opts: state.shape_cache_opts, @@ -1234,6 +1244,26 @@ defmodule Electric.Connection.Manager do ) end + defp log_accessible_tables(state) do + pool = pool_name(state.stack_id, :admin) + + case Configuration.run_handling_db_connection_errors(fn -> + Configuration.list_accessible_tables!(pool) + end) do + {:error, reason} -> + Logger.warning( + "Failed to list accessible tables for stack #{state.stack_id}: #{inspect(reason)}" + ) + + tables when is_list(tables) -> + table_names = Enum.map(tables, &Electric.Utils.relation_to_sql/1) + + Logger.info( + "#{length(table_names)} tables are accessible to Electric: #{inspect(table_names)}" + ) + end + end + defp should_retry_connection?( %State{ current_phase: :connection_setup, diff --git a/packages/sync-service/lib/electric/postgres/configuration.ex b/packages/sync-service/lib/electric/postgres/configuration.ex index a25121fcb9..69f8d92705 100644 --- a/packages/sync-service/lib/electric/postgres/configuration.ex +++ b/packages/sync-service/lib/electric/postgres/configuration.ex @@ -382,4 +382,30 @@ defmodule Electric.Postgres.Configuration do @spec trim_relation_with_replica(relation_with_replica()) :: Electric.oid_relation() defp trim_relation_with_replica({oid, relation, _replident}), do: {oid, relation} + + @doc """ + List all tables accessible to Electric (tables the connected user has SELECT permission on). + + Returns a list of `{schema, table}` tuples for ordinary and partitioned tables, + excluding system schemas (pg_catalog, information_schema). + """ + @spec list_accessible_tables!(Postgrex.conn()) :: list(Electric.relation()) + def list_accessible_tables!(conn) do + %Postgrex.Result{rows: rows} = + Postgrex.query!( + conn, + """ + SELECT n.nspname, c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('r', 'p') + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND has_table_privilege(c.oid, 'SELECT') + ORDER BY n.nspname, c.relname + """, + [] + ) + + Enum.map(rows, fn [schema, table] -> {schema, table} end) + end end