Skip to content

Commit 4927967

Browse files
committed
RLS-based persistence
1 parent 7b3bbb2 commit 4927967

18 files changed

Lines changed: 255 additions & 430 deletions

File tree

lib/realtime/api/channel.ex

Lines changed: 0 additions & 30 deletions
This file was deleted.

lib/realtime/api/message.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ defmodule Realtime.Api.Message do
1212
@timestamps_opts [type: :naive_datetime_usec]
1313
schema "messages" do
1414
field(:topic, :string)
15-
field(:extension, Ecto.Enum, values: [:broadcast, :presence])
15+
field(:extension, Ecto.Enum, values: [:broadcast, :presence, :persistence])
1616
field(:payload, :map)
1717
field(:event, :string)
1818
field(:private, :boolean)

lib/realtime/application.ex

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@ defmodule Realtime.Application do
121121
id: Realtime.LogThrottle
122122
),
123123
Realtime.Tenants.Cache,
124-
Realtime.Channels,
125124
Realtime.FeatureFlags.Cache,
126125
Realtime.RateCounter.DynamicSupervisor,
127126
Realtime.Latency,

lib/realtime/channels.ex

Lines changed: 0 additions & 65 deletions
This file was deleted.

lib/realtime/messages.ex

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ defmodule Realtime.Messages do
44
"""
55

66
alias Realtime.Api.Message
7-
alias Realtime.Channels
8-
alias Realtime.Tenants.Connect
97
alias Realtime.Tenants.Repo
108

119
import Ecto.Query, only: [from: 2]
@@ -14,50 +12,32 @@ defmodule Realtime.Messages do
1412
@default_timeout 5_000
1513

1614
@doc """
17-
Persists a broadcast sent over WebSocket for `topic` if storage is enabled for such topic.
15+
Persists a broadcast sent over WebSocket for `topic` as a `persistence` message.
1816
19-
Bypass RLS because the check has already been done on the message broadcast,
20-
and then set `broadcasted_at` to avoid re-broadcasting the message twice.
17+
Only called after the sender's `persistence` policy authorized it, so the persisted row is always
18+
private. Bypasses RLS because that authorization already happened on the broadcast, and sets
19+
`broadcasted_at` to avoid re-broadcasting the message twice.
2120
2221
Automatically uses RPC if the database connection is not on the same node.
2322
"""
24-
@spec store(DBConnection.conn(), String.t(), String.t(), String.t(), term(), boolean()) ::
25-
{:ok, binary()} | {:error, any()} | {:error, :rpc_error, term} | {:error, :storage_disabled}
26-
def store(conn, tenant_id, topic, event, payload, private) when node(conn) == node() do
27-
if Channels.storage_enabled?(conn, tenant_id, topic) do
28-
insert(conn, topic, event, payload, private)
29-
else
30-
{:error, :storage_disabled}
31-
end
23+
@spec store(DBConnection.conn(), String.t(), String.t(), term()) ::
24+
{:ok, binary()} | {:error, any()} | {:error, :rpc_error, term}
25+
def store(conn, topic, event, payload) when node(conn) == node() do
26+
insert(conn, topic, event, payload)
3227
end
3328

34-
def store(conn, tenant_id, topic, event, payload, private) do
35-
Realtime.GenRpc.call(node(conn), __MODULE__, :store, [conn, tenant_id, topic, event, payload, private], key: topic)
36-
end
37-
38-
@doc """
39-
Similar to `store/6` but runs asynchronously for callers that don't already hold a connection.
40-
"""
41-
@spec store_async(String.t(), String.t(), String.t(), term()) :: :ok
42-
def store_async(tenant_id, topic, event, payload) do
43-
Task.Supervisor.start_child(Realtime.TaskSupervisor, fn ->
44-
case Connect.lookup_or_start_connection(tenant_id) do
45-
{:ok, conn} -> store(conn, tenant_id, topic, event, payload, false)
46-
{:error, _reason} -> :skip
47-
end
48-
end)
49-
50-
:ok
29+
def store(conn, topic, event, payload) do
30+
Realtime.GenRpc.call(node(conn), __MODULE__, :store, [conn, topic, event, payload], key: topic)
5131
end
5232

53-
defp insert(conn, topic, event, payload, private) do
33+
defp insert(conn, topic, event, payload) do
5434
changeset =
5535
Message.changeset(%Message{}, %{
5636
topic: topic,
57-
extension: :broadcast,
37+
extension: :persistence,
5838
event: event,
5939
payload: payload,
60-
private: private,
40+
private: true,
6141
broadcasted_at: NaiveDateTime.utc_now(:microsecond)
6242
})
6343

@@ -110,7 +90,7 @@ defmodule Realtime.Messages do
11090
where:
11191
m.topic == ^topic and
11292
m.private == true and
113-
m.extension == :broadcast and
93+
m.extension in [:broadcast, :persistence] and
11494
m.inserted_at >= ^since and
11595
m.inserted_at < ^now,
11696
limit: ^limit,

lib/realtime/tenants/authorization.ex

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -320,26 +320,35 @@ defmodule Realtime.Tenants.Authorization do
320320
end
321321

322322
defp check_write_policies(conn, authorization_context, extensions, policies) do
323+
with {:ok, policies} <- check_extension_write_policies(conn, authorization_context, extensions, policies),
324+
{:ok, persistence_write?} <- authorize_write(conn, authorization_context, :persistence) do
325+
{:ok, Policies.update_policies(policies, :persistence, :write, persistence_write?)}
326+
end
327+
end
328+
329+
defp check_extension_write_policies(conn, authorization_context, extensions, policies) do
323330
Enum.reduce_while(@all_extensions, {:ok, policies}, fn extension, {:ok, acc} ->
324331
if extension in extensions do
325-
changeset = Message.changeset(%Message{}, %{topic: authorization_context.topic, extension: extension})
326-
327-
case Repo.insert(conn, changeset, Message, mode: :savepoint, returning: false) do
328-
{:ok, _} ->
329-
{:cont, {:ok, Policies.update_policies(acc, extension, :write, true)}}
330-
331-
{:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} ->
332-
{:cont, {:ok, Policies.update_policies(acc, extension, :write, false)}}
333-
334-
{:error, reason} ->
335-
{:halt, {:error, reason}}
332+
case authorize_write(conn, authorization_context, extension) do
333+
{:ok, allowed?} -> {:cont, {:ok, Policies.update_policies(acc, extension, :write, allowed?)}}
334+
{:error, reason} -> {:halt, {:error, reason}}
336335
end
337336
else
338337
{:cont, {:ok, Policies.update_policies(acc, extension, :write, false)}}
339338
end
340339
end)
341340
end
342341

342+
defp authorize_write(conn, authorization_context, extension) do
343+
changeset = Message.changeset(%Message{}, %{topic: authorization_context.topic, extension: extension})
344+
345+
case Repo.insert(conn, changeset, Message, mode: :savepoint, returning: false) do
346+
{:ok, _} -> {:ok, true}
347+
{:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} -> {:ok, false}
348+
{:error, reason} -> {:error, reason}
349+
end
350+
end
351+
343352
defp rate_counter(tenant_id) do
344353
%Tenant{} = tenant = Realtime.Tenants.Cache.get_tenant_by_external_id(tenant_id)
345354
rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(tenant)

lib/realtime/tenants/authorization/policies.ex

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,24 @@ defmodule Realtime.Tenants.Authorization.Policies do
22
@moduledoc """
33
Policies structure that holds the required authorization information for a given connection.
44
5-
Currently there are two types of policies:
5+
Currently there are three types of policies:
66
* Realtime.Tenants.Authorization.Policies.BroadcastPolicies - Used to store the access to Broadcast feature on a given Topic
77
* Realtime.Tenants.Authorization.Policies.PresencePolicies - Used to store the access to Presence feature on a given Topic
8+
* Realtime.Tenants.Authorization.Policies.PersistencePolicies - Used to store whether messages may be persisted on a given Topic
89
"""
910

1011
alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies
1112
alias Realtime.Tenants.Authorization.Policies.PresencePolicies
13+
alias Realtime.Tenants.Authorization.Policies.PersistencePolicies
1214

1315
defstruct broadcast: %BroadcastPolicies{},
14-
presence: %PresencePolicies{}
16+
presence: %PresencePolicies{},
17+
persistence: %PersistencePolicies{}
1518

1619
@type t :: %__MODULE__{
1720
broadcast: BroadcastPolicies.t(),
18-
presence: PresencePolicies.t()
21+
presence: PresencePolicies.t(),
22+
persistence: PersistencePolicies.t()
1923
}
2024

2125
@doc """
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
defmodule Realtime.Tenants.Authorization.Policies.PersistencePolicies do
2+
@moduledoc """
3+
PersistencePolicies structure that holds whether a connection is authorized to persist messages for a given Topic.
4+
"""
5+
defstruct write: nil
6+
7+
@type t :: %__MODULE__{
8+
write: boolean() | nil
9+
}
10+
end

lib/realtime/tenants/repo.ex

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,6 @@ defmodule Realtime.Tenants.Repo do
106106
end
107107
end
108108

109-
defp result_to_single_struct(
110-
{:error, %Postgrex.Error{postgres: %{code: :unique_violation, constraint: "channels_topic_index"}}},
111-
_struct,
112-
changeset
113-
) do
114-
Ecto.Changeset.add_error(changeset, :topic, "has already been taken")
115-
end
116-
117109
defp result_to_single_struct({:error, _} = error, _, _), do: error
118110

119111
defp result_to_single_struct({:ok, %Postgrex.Result{rows: []}}, _, _) do

lib/realtime/tenants/repo/migrations/20260714120000_add_broadcast_storage.ex

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,67 +4,9 @@ defmodule Realtime.Tenants.Migrations.AddBroadcastStorage do
44

55
def up do
66
execute("ALTER TABLE realtime.messages ADD COLUMN IF NOT EXISTS broadcasted_at timestamp")
7-
8-
execute("""
9-
CREATE TABLE IF NOT EXISTS realtime.channels (
10-
id bigserial PRIMARY KEY,
11-
topic text NOT NULL,
12-
broadcast_storage_enabled_at timestamp,
13-
inserted_at timestamp NOT NULL DEFAULT now(),
14-
updated_at timestamp NOT NULL DEFAULT now()
15-
)
16-
""")
17-
18-
execute("""
19-
DO $$ BEGIN
20-
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channels_topic_index') THEN
21-
ALTER TABLE realtime.channels ADD CONSTRAINT channels_topic_index UNIQUE (topic);
22-
END IF;
23-
END $$;
24-
""")
25-
26-
execute("ALTER TABLE realtime.channels OWNER TO supabase_realtime_admin")
27-
execute("GRANT SELECT, INSERT, UPDATE, DELETE ON realtime.channels TO postgres, anon, authenticated, service_role")
28-
execute("GRANT USAGE ON SEQUENCE realtime.channels_id_seq TO postgres, anon, authenticated, service_role")
29-
30-
execute("""
31-
CREATE OR REPLACE FUNCTION realtime.enable_broadcast_storage(topic text)
32-
RETURNS boolean
33-
AS $$
34-
BEGIN
35-
INSERT INTO realtime.channels (topic, broadcast_storage_enabled_at)
36-
VALUES (topic, now())
37-
ON CONFLICT ON CONSTRAINT channels_topic_index DO UPDATE
38-
SET broadcast_storage_enabled_at = now(),
39-
updated_at = now();
40-
41-
RETURN true;
42-
END;
43-
$$ LANGUAGE plpgsql;
44-
""")
45-
46-
execute("""
47-
CREATE OR REPLACE FUNCTION realtime.disable_broadcast_storage(topic text)
48-
RETURNS boolean
49-
AS $$
50-
BEGIN
51-
UPDATE realtime.channels
52-
SET broadcast_storage_enabled_at = NULL, updated_at = now()
53-
WHERE channels.topic = disable_broadcast_storage.topic;
54-
55-
RETURN true;
56-
END;
57-
$$ LANGUAGE plpgsql;
58-
""")
59-
60-
execute("ALTER FUNCTION realtime.enable_broadcast_storage(text) OWNER TO supabase_realtime_admin")
61-
execute("ALTER FUNCTION realtime.disable_broadcast_storage(text) OWNER TO supabase_realtime_admin")
627
end
638

649
def down do
65-
execute("DROP FUNCTION IF EXISTS realtime.disable_broadcast_storage(text)")
66-
execute("DROP FUNCTION IF EXISTS realtime.enable_broadcast_storage(text)")
67-
execute("DROP TABLE IF EXISTS realtime.channels")
6810
execute("ALTER TABLE realtime.messages DROP COLUMN IF EXISTS broadcasted_at")
6911
end
7012
end

0 commit comments

Comments
 (0)