refactor(portal): reduce cache memory usage (#10058)

Napkin math shows that we can save substantial memory (~3x or more) on
the API nodes as connected clients/gateways grow if we just store the
fields we need in order to keep the client and gateway state maintained
in the channel pids.

To facilitate this, we create new `Cacheable` structs that represent
their `Domain` cousins, which use byte arrays for `id`s and strip out
unused fields.

Additionally, all business logic involved with maintaining these caches
is now contained within two modules: `Domain.Cache.Client` and
`Domain.Cache.Gateway`, and type specs have been added to aid in static
analysis and code documentation.

Comprehensive testing is now added not only for the cache modules, but
for their associated channel modules as well to ensure we handle
different kinds of edge cases gracefully.

The `Events` nomenclature was renamed to `Changes` to better name what
we are doing: Change-Data-Capture.

Lastly, the following related changes are included in this PR since they
were "in the way" so to speak of getting this done:

- We save the last received LSN in each channel and drop the `change`
with a warning if we receive it twice in a row, or we receive it out of
order
- The client/gateway version compatibility calculations have been moved
to `Domain.Resources` and `Domain.Gateways` and have been simplified to
make them easier to understand and maintain going forward.


Related: #10174 
Fixes: #9392 
Fixes: #9965
Fixes: #9501 
Fixes: #10227

---------

Signed-off-by: Jamil <jamilbk@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jamil
2025-08-22 17:52:29 -04:00
committed by GitHub
parent 544ba11f21
commit cafe6554ff
92 changed files with 6103 additions and 3885 deletions

4
elixir/.gitignore vendored
View File

@@ -32,3 +32,7 @@ apps/*/screenshots
# Uploads
apps/web/priv/static/uploads
# ElixirLS
# ElixirLS uses this directory to store its state.
.elixir_ls/

File diff suppressed because it is too large Load Diff

View File

@@ -54,5 +54,6 @@ defmodule API.Client.Socket do
end
@impl true
def id(socket), do: Tokens.socket_id(socket.assigns.subject.token_id)
end

View File

@@ -1,13 +1,13 @@
defmodule API.Client.Views.GatewayGroup do
alias Domain.Gateways
alias Domain.Cache.Cacheable
def render_many(gateway_groups) do
Enum.map(gateway_groups, &render/1)
end
def render(%Gateways.Group{} = gateway_group) do
def render(%Cacheable.GatewayGroup{} = gateway_group) do
%{
id: gateway_group.id,
id: Ecto.UUID.load!(gateway_group.id),
name: gateway_group.name
}
end

View File

@@ -1,12 +1,19 @@
defmodule API.Client.Views.Resource do
alias API.Client.Views
alias Domain.Resources
alias Domain.Cache.Cacheable
def render_many(resources) do
Enum.map(resources, &render/1)
end
def render(%Resources.Resource{type: :internet} = resource) do
def render(%Cacheable.Resource{} = resource) do
resource
|> Map.from_struct()
|> Map.put(:id, Ecto.UUID.load!(resource.id))
|> render_resource()
end
defp render_resource(%{type: :internet} = resource) do
%{
id: resource.id,
type: :internet,
@@ -15,7 +22,7 @@ defmodule API.Client.Views.Resource do
}
end
def render(%Resources.Resource{type: :ip} = resource) do
defp render_resource(%{type: :ip} = resource) do
{:ok, inet} = Domain.Types.IP.cast(resource.address)
netmask = Domain.Types.CIDR.max_netmask(inet)
address = to_string(%{inet | netmask: netmask})
@@ -31,7 +38,7 @@ defmodule API.Client.Views.Resource do
}
end
def render(%Resources.Resource{} = resource) do
defp render_resource(%{} = resource) do
%{
id: resource.id,
type: resource.type,
@@ -44,7 +51,7 @@ defmodule API.Client.Views.Resource do
|> maybe_put_ip_stack(resource)
end
def render_filter(%Resources.Resource.Filter{ports: ports} = filter) when length(ports) > 0 do
defp render_filter(%{ports: ports} = filter) when length(ports) > 0 do
Enum.map(filter.ports, fn port ->
case String.split(port, "-") do
[port_start, port_end] ->
@@ -69,7 +76,7 @@ defmodule API.Client.Views.Resource do
end)
end
def render_filter(%Resources.Resource.Filter{} = filter) do
defp render_filter(%{} = filter) do
[
%{
protocol: filter.protocol

View File

@@ -1,13 +1,23 @@
defmodule API.Gateway.Channel do
use API, :channel
alias API.Gateway.Views
alias Domain.{Accounts, Flows, Gateways, PubSub, Relays, Resources}
alias Domain.{
Accounts,
Cache,
Changes.Change,
Flows,
Gateways,
PubSub,
Relays,
Resources
}
alias Domain.Relays.Presence.Debouncer
require Logger
require OpenTelemetry.Tracer
# The interval at which the flow cache is pruned.
@prune_flow_cache_every :timer.minutes(1)
@prune_cache_every :timer.minutes(1)
# All relayed connections are dropped when this expires, so use
# a long expiration time to avoid frequent disconnections.
@@ -26,8 +36,8 @@ defmodule API.Gateway.Channel do
@impl true
def handle_info(:after_join, socket) do
# Initialize the cache
socket = hydrate_flows(socket)
Process.send_after(self(), :prune_flow_cache, @prune_flow_cache_every)
socket = assign(socket, cache: Cache.Gateway.hydrate(socket.assigns.gateway))
Process.send_after(self(), :prune_cache, @prune_cache_every)
# Track gateway's presence
:ok = Gateways.Presence.connect(socket.assigns.gateway)
@@ -50,30 +60,9 @@ defmodule API.Gateway.Channel do
{:noreply, socket}
end
def handle_info(:prune_flow_cache, socket) do
Process.send_after(self(), :prune_flow_cache, @prune_flow_cache_every)
now = DateTime.utc_now()
# 1. Remove individual flows older than 14 days, then remove access entry if no flows left
flows =
socket.assigns.flows
|> Enum.map(fn {tuple, flow_id_map} ->
flow_id_map =
Enum.reject(flow_id_map, fn {_flow_id, expires_at} ->
DateTime.compare(expires_at, now) == :lt
end)
|> Enum.into(%{})
{tuple, flow_id_map}
end)
|> Enum.into(%{})
|> Enum.reject(fn {_tuple, flow_id_map} -> map_size(flow_id_map) == 0 end)
|> Enum.into(%{})
# The gateway has its own flow expiration, so no need to send `reject_access`
{:noreply, assign(socket, flows: flows)}
def handle_info(:prune_cache, socket) do
Process.send_after(self(), :prune_cache, @prune_cache_every)
{:noreply, assign(socket, cache: Cache.Gateway.prune(socket.assigns.cache))}
end
# Called to actually push relays_presence with a disconnected relay to the gateway
@@ -85,115 +74,22 @@ defmodule API.Gateway.Channel do
##### Reacting to domain events ####
####################################
# ACCOUNTS
def handle_info(%Change{lsn: lsn} = change, socket) do
last_lsn = Map.get(socket.assigns, :last_lsn, 0)
# Resend init when config changes so that new slug may be applied
def handle_info(
{:updated, %Accounts.Account{slug: old_slug}, %Accounts.Account{slug: slug} = account},
socket
if lsn <= last_lsn do
Logger.warning("Out of order or duplicate change received; ignoring",
change: change,
last_lsn: last_lsn
)
when old_slug != slug do
{:ok, relays} = select_relays(socket)
init(socket, account, relays)
{:noreply, socket}
end
# FLOWS
def handle_info(
{:deleted, %Flows.Flow{gateway_id: gateway_id} = flow},
%{
assigns: %{gateway: %{id: id}}
} = socket
)
when gateway_id == id do
tuple = {flow.client_id, flow.resource_id}
if flows_map = Map.get(socket.assigns.flows, tuple) do
flow_map = Map.delete(flows_map, flow.id)
remaining = map_size(flow_map)
if remaining == 0 do
case Flows.reauthorize_flow(flow) do
{:ok, new_flow} ->
flow_map = %{
new_flow.id => new_flow.expires_at
}
Logger.info("Updated flow authorization",
old_flow_id: flow.id,
account_id: flow.account_id,
client_id: flow.client_id,
resource_id: flow.resource_id,
new_flow_id: new_flow.id
)
push(
socket,
"access_authorization_expiry_updated",
Views.Flow.render(new_flow, new_flow.expires_at)
)
{:noreply, assign(socket, flows: Map.put(socket.assigns.flows, tuple, flow_map))}
{:error, :forbidden} ->
Logger.info("Last flow deleted; revoking access",
flow_id: flow.id,
account_id: flow.account_id,
client_id: flow.client_id,
resource_id: flow.resource_id
)
# Send reject_access if access is no longer granted
# TODO: Verify that if the client's websocket connection flaps at the moment this
# message is received by the gateway, and the client still has access to this resource,
# the client will clear its state so it can request a new flow.
push(socket, "reject_access", %{
client_id: flow.client_id,
resource_id: flow.resource_id
})
{:noreply, assign(socket, flows: Map.delete(socket.assigns.flows, tuple))}
end
else
Logger.info("Flow deleted but still has remaining flows for client/resource",
flow_id: flow.id,
account_id: flow.account_id,
client_id: flow.client_id,
resource_id: flow.resource_id,
remaining: remaining
)
{:noreply, assign(socket, flows: Map.put(socket.assigns.flows, tuple, flow_map))}
end
else
{:noreply, socket}
else
socket = assign(socket, last_lsn: lsn)
handle_change(change, socket)
end
end
# RESOURCES
# The gateway only handles filter changes. Other breaking changes are handled by deleting
# relevant flows for the resource.
def handle_info(
{:updated, %Resources.Resource{filters: old_filters},
%Resources.Resource{filters: filters, id: id} = resource},
socket
)
when old_filters != filters do
has_flows? =
socket.assigns.flows
|> Enum.any?(fn {{_client_id, resource_id}, _flow_map} -> resource_id == id end)
if has_flows? do
push(socket, "resource_updated", Views.Resource.render(resource))
end
{:noreply, socket}
end
####################################
#### Reacting to relay presence ####
####################################
@@ -216,8 +112,7 @@ defmodule API.Gateway.Channel do
:ok =
Enum.each(relays, fn relay ->
# TODO: WAL
# Why are we unsubscribing and subscribing again?
# TODO: Why are we unsubscribing and subscribing again?
:ok = Domain.Relays.unsubscribe_from_relay_presence(relay)
:ok = Domain.Relays.subscribe_to_relay_presence(relay)
end)
@@ -259,8 +154,7 @@ defmodule API.Gateway.Channel do
:ok =
Enum.each(relays, fn relay ->
# TODO: WAL
# Why are we unsubscribing and subscribing again?
# TODO: Why are we unsubscribing and subscribing again?
:ok = Relays.unsubscribe_from_relay_presence(relay)
:ok = Relays.subscribe_to_relay_presence(relay)
end)
@@ -300,9 +194,8 @@ defmodule API.Gateway.Channel do
def handle_info(
{{:ice_candidates, gateway_id}, client_id, candidates},
%{assigns: %{gateway: %{id: id}}} = socket
)
when gateway_id == id do
%{assigns: %{gateway: %{id: gateway_id}}} = socket
) do
push(socket, "ice_candidates", %{
client_id: client_id,
candidates: candidates
@@ -313,9 +206,8 @@ defmodule API.Gateway.Channel do
def handle_info(
{{:invalidate_ice_candidates, gateway_id}, client_id, candidates},
%{assigns: %{gateway: %{id: id}}} = socket
)
when gateway_id == id do
%{assigns: %{gateway: %{id: gateway_id}}} = socket
) do
push(socket, "invalidate_ice_candidates", %{
client_id: client_id,
candidates: candidates
@@ -326,12 +218,11 @@ defmodule API.Gateway.Channel do
def handle_info(
{{:authorize_flow, gateway_id}, {channel_pid, socket_ref}, payload},
%{assigns: %{gateway: %{id: id}}} = socket
)
when gateway_id == id do
%{assigns: %{gateway: %{id: gateway_id}}} = socket
) do
%{
client: client,
resource: resource,
resource: %Cache.Cacheable.Resource{} = resource,
flow_id: flow_id,
authorization_expires_at: authorization_expires_at,
ice_credentials: ice_credentials,
@@ -356,38 +247,36 @@ defmodule API.Gateway.Channel do
expires_at: DateTime.to_unix(authorization_expires_at, :second)
})
# Start tracking flow
tuple = {client.id, resource.id}
cache =
socket.assigns.cache
|> Cache.Gateway.put(
client.id,
resource.id,
flow_id,
authorization_expires_at
)
flow_map =
Map.get(socket.assigns.flows, tuple, %{})
|> Map.put(flow_id, authorization_expires_at)
flows = Map.put(socket.assigns.flows, tuple, flow_map)
socket = assign(socket, flows: flows)
{:noreply, socket}
{:noreply, assign(socket, cache: cache)}
end
# DEPRECATED IN 1.4
def handle_info(
{{:allow_access, gateway_id}, {channel_pid, socket_ref}, attrs},
%{assigns: %{gateway: %{id: id}}} = socket
)
when gateway_id == id do
%{assigns: %{gateway: %{id: gateway_id}}} = socket
) do
%{
client: client,
resource: resource,
resource: %Cache.Cacheable.Resource{} = resource,
flow_id: flow_id,
authorization_expires_at: authorization_expires_at,
client_payload: payload
} = attrs
case API.Client.Channel.map_or_drop_compatible_resource(
resource,
socket.assigns.gateway.last_seen_version
) do
{:cont, resource} ->
case Resources.adapt_resource_for_version(resource, socket.assigns.gateway.last_seen_version) do
nil ->
{:noreply, socket}
resource ->
ref =
encode_ref(
socket,
@@ -404,43 +293,38 @@ defmodule API.Gateway.Channel do
client_ipv6: client.ipv6
})
# Start tracking the flow
tuple = {client.id, resource.id}
cache =
socket.assigns.cache
|> Cache.Gateway.put(
client.id,
resource.id,
flow_id,
authorization_expires_at
)
flow_map =
Map.get(socket.assigns.flows, tuple, %{})
|> Map.put(flow_id, authorization_expires_at)
flows = Map.put(socket.assigns.flows, tuple, flow_map)
socket = assign(socket, flows: flows)
{:noreply, socket}
:drop ->
{:noreply, socket}
{:noreply, assign(socket, cache: cache)}
end
end
# DEPRECATED IN 1.4
def handle_info(
{{:request_connection, gateway_id}, {channel_pid, socket_ref}, attrs},
%{assigns: %{gateway: %{id: id}}} = socket
)
when gateway_id == id do
%{assigns: %{gateway: %{id: gateway_id}}} = socket
) do
%{
client: client,
resource: resource,
resource: %Cache.Cacheable.Resource{} = resource,
flow_id: flow_id,
authorization_expires_at: authorization_expires_at,
client_payload: payload,
client_preshared_key: preshared_key
} = attrs
case API.Client.Channel.map_or_drop_compatible_resource(
resource,
socket.assigns.gateway.last_seen_version
) do
{:cont, resource} ->
case Resources.adapt_resource_for_version(resource, socket.assigns.gateway.last_seen_version) do
nil ->
{:noreply, socket}
resource ->
ref =
encode_ref(
socket,
@@ -454,20 +338,16 @@ defmodule API.Gateway.Channel do
expires_at: DateTime.to_unix(authorization_expires_at, :second)
})
# Start tracking the flow
tuple = {client.id, resource.id}
cache =
socket.assigns.cache
|> Cache.Gateway.put(
client.id,
resource.id,
flow_id,
authorization_expires_at
)
flow_map =
Map.get(socket.assigns.flows, tuple, %{})
|> Map.put(flow_id, authorization_expires_at)
flows = Map.put(socket.assigns.flows, tuple, flow_map)
socket = assign(socket, flows: flows)
{:noreply, socket}
:drop ->
{:noreply, socket}
{:noreply, assign(socket, cache: cache)}
end
end
@@ -520,10 +400,10 @@ defmodule API.Gateway.Channel do
socket
) do
case decode_ref(socket, signed_ref) do
{:ok, {channel_pid, socket_ref, resource_id}} ->
{:ok, {channel_pid, socket_ref, rid_bytes}} ->
send(
channel_pid,
{:connect, socket_ref, resource_id, socket.assigns.gateway.public_key, payload}
{:connect, socket_ref, rid_bytes, socket.assigns.gateway.public_key, payload}
)
{:reply, :ok, socket}
@@ -622,7 +502,7 @@ defmodule API.Gateway.Channel do
DateTime.utc_now() |> DateTime.add(@relay_credentials_expire_in_hours, :hour)
push(socket, "init", %{
authorizations: Views.Flow.render_many(socket.assigns.flows),
authorizations: Views.Flow.render_many(socket.assigns.cache),
account_slug: account.slug,
interface: Views.Interface.render(socket.assigns.gateway),
relays:
@@ -647,28 +527,149 @@ defmodule API.Gateway.Channel do
end
end
defp hydrate_flows(socket) do
OpenTelemetry.Tracer.with_span "gateway.hydrate_flows",
attributes: %{
gateway_id: socket.assigns.gateway.id,
account_id: socket.assigns.gateway.account_id
} do
flows =
Flows.all_gateway_flows_for_cache!(socket.assigns.gateway)
# Reduces [ {client_id, resource_id}, {flow_id, inserted_at} ]
#
# to %{ {client_id, resource_id} => %{flow_id => expires_at} }
#
# This data structure is used to efficiently:
# 1. Check if there are any active flows remaining for this client/resource?
# 2. Remove a deleted flow
|> Enum.reduce(%{}, fn {{client_id, resource_id}, {flow_id, expires_at}}, acc ->
flow_id_map = Map.get(acc, {client_id, resource_id}, %{})
##########################################
#### Handling changes from the domain ####
##########################################
Map.put(acc, {client_id, resource_id}, Map.put(flow_id_map, flow_id, expires_at))
end)
# ACCOUNTS
assign(socket, flows: flows)
# Resend init when config changes so that new slug may be applied
defp handle_change(
%Change{
op: :update,
old_struct: %Accounts.Account{slug: old_slug},
struct: %Accounts.Account{slug: slug} = account
},
socket
)
when old_slug != slug do
{:ok, relays} = select_relays(socket)
init(socket, account, relays)
{:noreply, socket}
end
# FLOWS
defp handle_change(
%Change{
op: :delete,
old_struct:
%Flows.Flow{gateway_id: gateway_id, client_id: client_id, resource_id: resource_id} =
flow
},
%{
assigns: %{gateway: %{id: gateway_id}}
} = socket
) do
socket.assigns.cache
|> Cache.Gateway.reauthorize_deleted_flow(flow)
|> case do
{:ok, expires_at_unix, cache} ->
Logger.info("Updating authorization expiration for deleted flow",
deleted_flow: inspect(flow),
new_expires_at: DateTime.from_unix!(expires_at_unix, :second)
)
push(
socket,
"access_authorization_expiry_updated",
Views.Flow.render(flow, expires_at_unix)
)
{:noreply, assign(socket, cache: cache)}
{:error, :unauthorized, cache} ->
Logger.info("No authorizations remaining for deleted flow, revoking access",
deleted_flow: inspect(flow)
)
# Note: There is an edge case here:
# - Client authorizes flow for resource
# - Client's websocket temporarily gets cut
# - Admin deletes the policy
# - We send reject_access to the gateway
# - Admin recreates the same policy (same access)
# - Client connection resumes
# - Client sees exactly the same resource list
# - Client now has lost the ability to recreate the flow because from its perspective, it is still connected
# to this gateway.
# - Packets to gateway are essentially blackholed until the client signs out and back in
# This will be fixed when the client responds to the ICMP prohibited by filter message:
# https://github.com/firezone/firezone/issues/10074
push(
socket,
"reject_access",
%{client_id: client_id, resource_id: resource_id}
)
{:noreply, assign(socket, cache: cache)}
{:error, :not_found} ->
{:noreply, socket}
end
end
# GATEWAYS
defp handle_change(
%Change{
op: :delete,
old_struct: %Gateways.Gateway{id: gateway_id}
},
%{
assigns: %{gateway: %{id: gateway_id}}
} = socket
) do
{:stop, :shutdown, socket}
end
# RESOURCES
# The gateway only handles filter changes for resources. Other addressability changes like address,
# type, or ip_stack require sending reject_access to remove the resource state from the gateway.
defp handle_change(
%Change{
op: :update,
old_struct: %Resources.Resource{
address: old_address,
ip_stack: old_ip_stack,
type: old_type
},
struct: %Resources.Resource{address: address, ip_stack: ip_stack, type: type, id: id}
},
socket
)
when old_address != address or
old_ip_stack != ip_stack or
old_type != type do
for {client_id, resource_id} <- Cache.Gateway.all_pairs_for_resource(socket.assigns.cache, id) do
# Send reject_access to the gateway to reset state. Clients will need to reauthorize the resource.
push(socket, "reject_access", %{client_id: client_id, resource_id: resource_id})
end
# The cache will be updated by the flow deletion handler.
{:noreply, socket}
end
defp handle_change(
%Change{
op: :update,
old_struct: %Resources.Resource{filters: old_filters},
struct: %Resources.Resource{filters: filters} = resource
},
socket
)
when old_filters != filters do
# Send regardless of cache state - if the Gateway has no flows for this resource,
# it will simply ignore the message.
resource = Cache.Cacheable.to_cache(resource)
push(socket, "resource_updated", Views.Resource.render(resource))
{:noreply, socket}
end
defp handle_change(%Change{}, socket), do: {:noreply, socket}
end

View File

@@ -1,21 +1,22 @@
defmodule API.Gateway.Views.Flow do
def render(flow, expires_at) do
def render(flow, expires_at_unix) do
%{
client_id: flow.client_id,
resource_id: flow.resource_id,
expires_at: DateTime.to_unix(expires_at, :second)
expires_at: expires_at_unix
}
end
def render_many(flows) do
flows
|> Enum.map(fn {{client_id, resource_id}, flow_map} ->
expires_at = Enum.min(Map.values(flow_map))
def render_many(cache) do
cache
|> Enum.map(fn {{cid_bytes, rid_bytes}, flow_map} ->
# Use longest expiration to minimize unnecessary access churn
expires_at_unix = Enum.max(Map.values(flow_map))
%{
client_id: client_id,
resource_id: resource_id,
expires_at: DateTime.to_unix(expires_at, :second)
client_id: Ecto.UUID.load!(cid_bytes),
resource_id: Ecto.UUID.load!(rid_bytes),
expires_at: expires_at_unix
}
end)
end

View File

@@ -1,14 +1,23 @@
defmodule API.Gateway.Views.Resource do
alias Domain.Resources
alias Domain.Cache.Cacheable
def render(%Resources.Resource{type: :internet} = resource) do
def render(%Cacheable.Resource{} = resource) do
resource =
resource
|> Map.from_struct()
|> Map.put(:id, Ecto.UUID.load!(resource.id))
render_resource(resource)
end
defp render_resource(%{type: :internet} = resource) do
%{
id: resource.id,
type: :internet
}
end
def render(%Resources.Resource{type: :dns} = resource) do
defp render_resource(%{type: :dns} = resource) do
%{
id: resource.id,
type: :dns,
@@ -18,7 +27,7 @@ defmodule API.Gateway.Views.Resource do
}
end
def render(%Resources.Resource{type: :cidr} = resource) do
defp render_resource(%{type: :cidr} = resource) do
%{
id: resource.id,
type: :cidr,
@@ -28,7 +37,7 @@ defmodule API.Gateway.Views.Resource do
}
end
def render(%Resources.Resource{type: :ip} = resource) do
defp render_resource(%{type: :ip} = resource) do
{:ok, inet} = Domain.Types.IP.cast(resource.address)
netmask = Domain.Types.CIDR.max_netmask(inet)
address = to_string(%{inet | netmask: netmask})
@@ -42,7 +51,7 @@ defmodule API.Gateway.Views.Resource do
}
end
def render_filter(%Resources.Resource.Filter{ports: ports} = filter) when length(ports) > 0 do
defp render_filter(%{ports: ports} = filter) when length(ports) > 0 do
Enum.map(filter.ports, fn port ->
case String.split(port, "-") do
[port_start, port_end] ->
@@ -67,7 +76,7 @@ defmodule API.Gateway.Views.Resource do
end)
end
def render_filter(%Resources.Resource.Filter{} = filter) do
defp render_filter(%{} = filter) do
[
%{
protocol: filter.protocol

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
defmodule API.Gateway.ChannelTest do
use API.ChannelCase, async: true
alias Domain.{Accounts, Events, Gateways, PubSub}
alias Domain.{Accounts, Changes, Gateways, PubSub}
import Domain.Cache.Cacheable, only: [to_cache: 1]
import ExUnit.CaptureLog
setup do
account = Fixtures.Accounts.create_account()
@@ -91,10 +93,184 @@ defmodule API.Gateway.ChannelTest do
end
describe "handle_info/2" do
test "logs warning and ignores out of order %Change{}", %{socket: socket} do
send(socket.channel_pid, %Changes.Change{lsn: 100})
assert %{assigns: %{last_lsn: 100}} = :sys.get_state(socket.channel_pid)
message =
capture_log(fn ->
send(socket.channel_pid, %Changes.Change{lsn: 50})
# Wait for the channel to process and emit the log
Process.sleep(1)
end)
assert message =~ "[warning] Out of order or duplicate change received; ignoring"
assert %{assigns: %{last_lsn: 100}} = :sys.get_state(socket.channel_pid)
end
test ":prune_cache removes key completely when all flows are expired", %{
account: account,
client: client,
resource: resource,
socket: socket,
gateway: gateway
} do
expired_flow =
Fixtures.Flows.create_flow(
account: account,
client: client,
resource: resource,
gateway: gateway
)
expired_expiration = DateTime.utc_now() |> DateTime.add(-30, :second)
channel_pid = self()
socket_ref = make_ref()
preshared_key = "PSK"
ice_credentials = %{
client: %{username: "A", password: "B"},
gateway: %{username: "C", password: "D"}
}
send(
socket.channel_pid,
{{:authorize_flow, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: to_cache(resource),
flow_id: expired_flow.id,
authorization_expires_at: expired_expiration,
ice_credentials: ice_credentials,
preshared_key: preshared_key
}}
)
assert_push "authorize_flow", _payload
cid_bytes = Ecto.UUID.dump!(client.id)
rid_bytes = Ecto.UUID.dump!(resource.id)
assert %{
assigns: %{
cache: %{
{^cid_bytes, ^rid_bytes} => _flows
}
}
} = :sys.get_state(socket.channel_pid)
send(socket.channel_pid, :prune_cache)
assert %{
assigns: %{
cache: %{}
}
} = :sys.get_state(socket.channel_pid)
end
test ":prune_cache prunes only expired flows from the cache", %{
account: account,
client: client,
resource: resource,
socket: socket,
gateway: gateway
} do
expired_flow =
Fixtures.Flows.create_flow(
account: account,
client: client,
resource: resource,
gateway: gateway
)
unexpired_flow =
Fixtures.Flows.create_flow(
account: account,
client: client,
resource: resource,
gateway: gateway
)
expired_expiration = DateTime.utc_now() |> DateTime.add(-30, :second)
unexpired_expiration = DateTime.utc_now() |> DateTime.add(30, :second)
channel_pid = self()
socket_ref = make_ref()
preshared_key = "PSK"
ice_credentials = %{
client: %{username: "A", password: "B"},
gateway: %{username: "C", password: "D"}
}
send(
socket.channel_pid,
{{:authorize_flow, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: to_cache(resource),
flow_id: expired_flow.id,
authorization_expires_at: expired_expiration,
ice_credentials: ice_credentials,
preshared_key: preshared_key
}}
)
assert_push "authorize_flow", _payload
send(
socket.channel_pid,
{{:authorize_flow, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: to_cache(resource),
flow_id: unexpired_flow.id,
authorization_expires_at: unexpired_expiration,
ice_credentials: ice_credentials,
preshared_key: preshared_key
}}
)
cid_bytes = Ecto.UUID.dump!(client.id)
rid_bytes = Ecto.UUID.dump!(resource.id)
assert %{
assigns: %{
cache: %{
{^cid_bytes, ^rid_bytes} => flows
}
}
} = :sys.get_state(socket.channel_pid)
assert flows == %{
Ecto.UUID.dump!(expired_flow.id) => DateTime.to_unix(expired_expiration, :second),
Ecto.UUID.dump!(unexpired_flow.id) =>
DateTime.to_unix(unexpired_expiration, :second)
}
send(socket.channel_pid, :prune_cache)
assert %{
assigns: %{
cache: %{
{^cid_bytes, ^rid_bytes} => flows
}
}
} = :sys.get_state(socket.channel_pid)
assert flows == %{
Ecto.UUID.dump!(unexpired_flow.id) =>
DateTime.to_unix(unexpired_expiration, :second)
}
end
test "resends init when account slug changes", %{
account: account
} do
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = PubSub.Account.subscribe(account.id)
old_data = %{
"id" => account.id,
@@ -106,9 +282,13 @@ defmodule API.Gateway.ChannelTest do
"slug" => "new-slug"
}
Events.Hooks.Accounts.on_update(old_data, data)
Changes.Hooks.Accounts.on_update(100, old_data, data)
assert_receive {:updated, %Accounts.Account{}, %Accounts.Account{}}
assert_receive %Changes.Change{
lsn: 100,
old_struct: %Accounts.Account{},
struct: %Accounts.Account{slug: "new-slug"}
}
# Consume first init from join
assert_push "init", _payload
@@ -122,11 +302,7 @@ defmodule API.Gateway.ChannelTest do
account: account,
token: token
} do
# Prevents test from failing due to expected socket disconnect
Process.flag(:trap_exit, true)
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = Domain.PubSub.subscribe("sessions:#{token.id}")
:ok = PubSub.subscribe("sessions:#{token.id}")
data = %{
"id" => token.id,
@@ -134,9 +310,7 @@ defmodule API.Gateway.ChannelTest do
"type" => "gateway_group"
}
Events.Hooks.Tokens.on_delete(data)
assert_receive {:deleted, deleted_token}
Changes.Hooks.Tokens.on_delete(100, data)
assert_receive %Phoenix.Socket.Broadcast{
topic: topic,
@@ -144,7 +318,29 @@ defmodule API.Gateway.ChannelTest do
}
assert topic == "sessions:#{token.id}"
assert deleted_token.id == token.id
end
test "disconnect socket when gateway is deleted", %{
account: account,
gateway: gateway
} do
Process.flag(:trap_exit, true)
:ok = PubSub.Account.subscribe(account.id)
data = %{
"id" => gateway.id,
"account_id" => account.id
}
Changes.Hooks.Gateways.on_delete(100, data)
assert_receive %Changes.Change{
lsn: 100,
old_struct: %Gateways.Gateway{}
}
assert_receive {:EXIT, _pid, _reason}
end
test "pushes allow_access message", %{
@@ -175,7 +371,7 @@ defmodule API.Gateway.ChannelTest do
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload
@@ -239,7 +435,7 @@ defmodule API.Gateway.ChannelTest do
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload
@@ -266,24 +462,26 @@ defmodule API.Gateway.ChannelTest do
client: client,
resource: resource,
gateway: gateway,
relay: relay,
socket: socket,
subject: subject
} do
channel_pid = self()
socket_ref = make_ref()
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
client_payload = "RTC_SD_or_DNS_Q"
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
in_one_hour = DateTime.utc_now() |> DateTime.add(1, :hour)
in_one_day = DateTime.utc_now() |> DateTime.add(1, :day)
:ok = PubSub.Account.subscribe(account.id)
flow1 =
Fixtures.Flows.create_flow(
account: account,
subject: subject,
client: client,
resource: resource
gateway: gateway,
resource: resource,
expires_at: in_one_hour
)
flow2 =
@@ -291,51 +489,70 @@ defmodule API.Gateway.ChannelTest do
account: account,
subject: subject,
client: client,
resource: resource
gateway: gateway,
resource: resource,
expires_at: in_one_day
)
send(
socket.channel_pid,
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: to_cache(resource),
flow_id: flow1.id,
authorization_expires_at: flow1.expires_at,
client_payload: client_payload
}}
)
assert_push "allow_access", %{}
send(
socket.channel_pid,
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: to_cache(resource),
flow_id: flow2.id,
authorization_expires_at: flow2.expires_at,
client_payload: client_payload
}}
)
assert_push "allow_access", %{}
data = %{
"id" => flow1.id,
"client_id" => client.id,
"resource_id" => resource.id,
"account_id" => account.id,
"token_id" => flow1.token_id,
"gateway_id" => gateway.id,
"policy_id" => flow1.policy_id,
"actor_group_membership_id" => flow1.actor_group_membership_id,
"expires_at" => flow1.expires_at
}
send(
socket.channel_pid,
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
flow_id: flow1.id,
authorization_expires_at: expires_at,
client_payload: client_payload
}}
)
Changes.Hooks.Flows.on_delete(100, data)
assert_push "allow_access", %{}
flow_id = flow1.id
send(
socket.channel_pid,
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
flow_id: flow2.id,
authorization_expires_at: expires_at,
client_payload: client_payload
}}
)
assert_push "allow_access", %{}
Events.Hooks.Flows.on_delete(data)
assert_receive %Changes.Change{
lsn: 100,
old_struct: %Domain.Flows.Flow{id: ^flow_id}
}
refute_push "allow_access", _payload
refute_push "reject_access", %{}
assert_push "access_authorization_expiry_updated", payload
assert payload == %{
client_id: client.id,
resource_id: resource.id,
expires_at: DateTime.to_unix(flow2.expires_at, :second)
}
end
test "handles flow deletion event when access is removed", %{
@@ -381,7 +598,7 @@ defmodule API.Gateway.ChannelTest do
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload
@@ -390,7 +607,7 @@ defmodule API.Gateway.ChannelTest do
assert_push "allow_access", %{}
Events.Hooks.Flows.on_delete(data)
Changes.Hooks.Flows.on_delete(100, data)
assert_push "reject_access", %{
client_id: client_id,
@@ -460,7 +677,7 @@ defmodule API.Gateway.ChannelTest do
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload
@@ -474,7 +691,7 @@ defmodule API.Gateway.ChannelTest do
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: other_client,
resource: resource,
resource: to_cache(resource),
flow_id: other_flow1.id,
authorization_expires_at: expires_at,
client_payload: client_payload
@@ -488,7 +705,7 @@ defmodule API.Gateway.ChannelTest do
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: other_resource,
resource: to_cache(other_resource),
flow_id: other_flow2.id,
authorization_expires_at: expires_at,
client_payload: client_payload
@@ -497,13 +714,19 @@ defmodule API.Gateway.ChannelTest do
assert_push "allow_access", %{}
assert %{assigns: %{flows: flows}} =
assert %{assigns: %{cache: cache}} =
:sys.get_state(socket.channel_pid)
assert flows == %{
{client.id, resource.id} => %{flow.id => expires_at},
{other_client.id, resource.id} => %{other_flow1.id => expires_at},
{client.id, other_resource.id} => %{other_flow2.id => expires_at}
assert cache == %{
{Ecto.UUID.dump!(client.id), Ecto.UUID.dump!(resource.id)} => %{
Ecto.UUID.dump!(flow.id) => DateTime.to_unix(expires_at, :second)
},
{Ecto.UUID.dump!(other_client.id), Ecto.UUID.dump!(resource.id)} => %{
Ecto.UUID.dump!(other_flow1.id) => DateTime.to_unix(expires_at, :second)
},
{Ecto.UUID.dump!(client.id), Ecto.UUID.dump!(other_resource.id)} => %{
Ecto.UUID.dump!(other_flow2.id) => DateTime.to_unix(expires_at, :second)
}
}
data = %{
@@ -518,7 +741,7 @@ defmodule API.Gateway.ChannelTest do
"expires_at" => other_flow1.expires_at
}
Events.Hooks.Flows.on_delete(data)
Changes.Hooks.Flows.on_delete(100, data)
assert_push "reject_access", %{
client_id: client_id,
@@ -540,7 +763,7 @@ defmodule API.Gateway.ChannelTest do
"expires_at" => other_flow2.expires_at
}
Events.Hooks.Flows.on_delete(data)
Changes.Hooks.Flows.on_delete(200, data)
assert_push "reject_access", %{
client_id: client_id,
@@ -580,7 +803,7 @@ defmodule API.Gateway.ChannelTest do
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload
@@ -597,27 +820,101 @@ defmodule API.Gateway.ChannelTest do
data = Map.put(old_data, "name", "New Resource Name")
Events.Hooks.Resources.on_update(old_data, data)
Changes.Hooks.Resources.on_update(100, old_data, data)
client_id = client.id
resource_id = resource.id
flow_id = flow.id
cid_bytes = Ecto.UUID.dump!(client.id)
rid_bytes = Ecto.UUID.dump!(resource.id)
fid_bytes = Ecto.UUID.dump!(flow.id)
expires_at_unix = DateTime.to_unix(expires_at, :second)
assert %{
assigns: %{
flows: %{{^client_id, ^resource_id} => %{^flow_id => ^expires_at}}
cache: %{{^cid_bytes, ^rid_bytes} => %{^fid_bytes => ^expires_at_unix}}
}
} = :sys.get_state(socket.channel_pid)
refute_push "resource_updated", _payload
end
test "sends reject_access when resource addressability changes", %{
client: client,
gateway: gateway,
account: account,
resource: resource,
socket: socket
} do
channel_pid = self()
socket_ref = make_ref()
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
client_payload = "RTC_SD_or_DNS_Q"
flow =
Fixtures.Flows.create_flow(
account: account,
client: client,
resource: resource,
gateway: gateway
)
:ok = PubSub.Account.subscribe(account.id)
send(
socket.channel_pid,
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload
}}
)
assert_push "allow_access", %{}
old_data = %{
"id" => resource.id,
"account_id" => resource.account_id,
"address" => resource.address,
"name" => resource.name,
"type" => "dns",
"filters" => [],
"ip_stack" => "dual"
}
data = %{
"id" => resource.id,
"account_id" => resource.account_id,
"address" => "new-address",
"name" => resource.name,
"type" => "dns",
"filters" => [],
"ip_stack" => "dual"
}
:ok = Changes.Hooks.Resources.on_update(100, old_data, data)
resource_id = resource.id
assert_receive %Changes.Change{
lsn: 100,
old_struct: %Domain.Resources.Resource{id: ^resource_id},
struct: %Domain.Resources.Resource{id: ^resource_id, address: "new-address"}
}
assert_push "reject_access", payload
assert payload == %{
client_id: client.id,
resource_id: resource.id
}
end
test "sends resource_updated when filters change", %{
client: client,
gateway: gateway,
account: account,
resource: resource,
relay: relay,
socket: socket
} do
flow =
@@ -632,15 +929,12 @@ defmodule API.Gateway.ChannelTest do
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
client_payload = "RTC_SD_or_DNS_Q"
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
send(
socket.channel_pid,
{{:allow_access, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload
@@ -667,7 +961,7 @@ defmodule API.Gateway.ChannelTest do
data = Map.put(old_data, "filters", filters)
Events.Hooks.Resources.on_update(old_data, data)
Changes.Hooks.Resources.on_update(100, old_data, data)
assert_push "resource_updated", payload
@@ -685,6 +979,48 @@ defmodule API.Gateway.ChannelTest do
}
end
test "sends resource_updated when filters change even without resource in cache", %{
resource: resource,
socket: _socket
} do
# Don't create any flows - simulate a gateway that reconnected
# and doesn't have this resource in its cache yet
old_data = %{
"id" => resource.id,
"account_id" => resource.account_id,
"address" => resource.address,
"name" => resource.name,
"type" => "dns",
"filters" => [],
"ip_stack" => "dual"
}
filters = [
%{"protocol" => "tcp", "ports" => ["443"]},
%{"protocol" => "udp", "ports" => ["53"]}
]
data = Map.put(old_data, "filters", filters)
# Trigger the resource update
Changes.Hooks.Resources.on_update(100, old_data, data)
# Should still receive the update even though resource isn't in cache
assert_push "resource_updated", payload
assert payload == %{
address: resource.address,
id: resource.id,
name: resource.name,
type: :dns,
filters: [
%{protocol: :tcp, port_range_start: 443, port_range_end: 443},
%{protocol: :udp, port_range_start: 53, port_range_end: 53}
]
}
end
test "subscribes for relays presence", %{gateway: gateway, gateway_group: gateway_group} do
relay_group = Fixtures.Relays.create_global_group()
stamp_secret = Ecto.UUID.generate()
@@ -876,7 +1212,7 @@ defmodule API.Gateway.ChannelTest do
{{:request_connection, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload,
@@ -949,7 +1285,7 @@ defmodule API.Gateway.ChannelTest do
{{:request_connection, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: client_payload,
@@ -971,7 +1307,7 @@ defmodule API.Gateway.ChannelTest do
"expires_at" => flow.expires_at
}
Events.Hooks.Flows.on_delete(data)
Changes.Hooks.Flows.on_delete(100, data)
assert_push "reject_access", %{
client_id: client_id,
@@ -1011,7 +1347,7 @@ defmodule API.Gateway.ChannelTest do
{{:authorize_flow, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
ice_credentials: ice_credentials,
@@ -1083,7 +1419,7 @@ defmodule API.Gateway.ChannelTest do
{{:authorize_flow, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
ice_credentials: ice_credentials,
@@ -1105,7 +1441,7 @@ defmodule API.Gateway.ChannelTest do
"expires_at" => flow.expires_at
}
Events.Hooks.Flows.on_delete(data)
Changes.Hooks.Flows.on_delete(100, data)
assert_push "reject_access", %{
client_id: client_id,
@@ -1146,7 +1482,7 @@ defmodule API.Gateway.ChannelTest do
gateway_public_key = gateway.public_key
gateway_ipv4 = gateway.ipv4
gateway_ipv6 = gateway.ipv6
resource_id = resource.id
rid_bytes = Ecto.UUID.dump!(resource.id)
ice_credentials = %{
client: %{username: "A", password: "B"},
@@ -1158,7 +1494,7 @@ defmodule API.Gateway.ChannelTest do
{{:authorize_flow, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
ice_credentials: ice_credentials,
@@ -1174,7 +1510,7 @@ defmodule API.Gateway.ChannelTest do
assert_receive {
:connect,
^socket_ref,
^resource_id,
^rid_bytes,
^gateway_group_id,
^gateway_id,
^gateway_public_key,
@@ -1226,7 +1562,7 @@ defmodule API.Gateway.ChannelTest do
{{:request_connection, gateway.id}, {channel_pid, socket_ref},
%{
client: client,
resource: resource,
resource: to_cache(resource),
flow_id: flow.id,
authorization_expires_at: expires_at,
client_payload: payload,
@@ -1261,8 +1597,8 @@ defmodule API.Gateway.ChannelTest do
})
assert_reply push_ref, :ok
assert_receive {:connect, ^socket_ref, resource_id, ^gateway_public_key, ^payload}
assert resource_id == resource.id
assert_receive {:connect, ^socket_ref, rid_bytes, ^gateway_public_key, ^payload}
assert Ecto.UUID.load!(rid_bytes) == resource.id
end
test "connection_ready pushes an error when ref is invalid", %{
@@ -1283,7 +1619,7 @@ defmodule API.Gateway.ChannelTest do
} do
candidates = ["foo", "bar"]
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = PubSub.Account.subscribe(account.id)
attrs = %{
"candidates" => candidates,
@@ -1309,7 +1645,7 @@ defmodule API.Gateway.ChannelTest do
:ok = Domain.Clients.Presence.connect(client)
PubSub.subscribe(Domain.Tokens.socket_id(subject.token_id))
:ok = Domain.PubSub.Account.subscribe(gateway.account_id)
:ok = PubSub.Account.subscribe(gateway.account_id)
push(socket, "broadcast_ice_candidates", attrs)
@@ -1326,7 +1662,7 @@ defmodule API.Gateway.ChannelTest do
} do
candidates = ["foo", "bar"]
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = PubSub.Account.subscribe(account.id)
attrs = %{
"candidates" => candidates,
@@ -1350,7 +1686,7 @@ defmodule API.Gateway.ChannelTest do
"client_ids" => [client.id]
}
:ok = Domain.PubSub.Account.subscribe(gateway.account_id)
:ok = PubSub.Account.subscribe(gateway.account_id)
:ok = Domain.Clients.Presence.connect(client)
PubSub.subscribe(Domain.Tokens.socket_id(subject.token_id))

View File

@@ -81,10 +81,9 @@ defmodule Domain.Actors do
|> Repo.preload(preload)
end
def all_memberships_for_actor!(%Actor{} = actor) do
def all_memberships_for_actor_id!(actor_id) do
Membership.Query.all()
|> Membership.Query.by_account_id(actor.account_id)
|> Membership.Query.by_actor_id(actor.id)
|> Membership.Query.by_actor_id(actor_id)
|> Repo.all()
end
@@ -255,7 +254,7 @@ defmodule Domain.Actors do
{:ok, [group]} ->
{:ok, _policies} = Policies.delete_policies_for(group, subject)
# TODO: WAL
# TODO: Hard delete
# Consider using a trigger or transaction to handle the side effects of soft-deletions to ensure consistency
{_count, _memberships} =
Membership.Query.all()
@@ -282,7 +281,7 @@ defmodule Domain.Actors do
|> Group.Query.by_provider_id(provider.id)
|> Group.Query.by_account_id(provider.account_id)
# TODO: WAL
# TODO: Hard delete
# Consider using a trigger or transaction to handle the side effects of soft-deletions to ensure consistency
{_count, _memberships} =
Membership.Query.by_group_provider_id(provider.id)
@@ -319,7 +318,7 @@ defmodule Domain.Actors do
{:ok, _policies} = Domain.Policies.delete_policies_for(group)
end)
# TODO: WAL
# TODO: Hard delete
# Consider using a trigger or transaction to handle the side effects of soft-deletions to ensure consistency
{_count, _memberships} =
Membership.Query.by_group_id({:in, Enum.map(groups, & &1.id)})
@@ -598,7 +597,7 @@ defmodule Domain.Actors do
:ok = Auth.delete_identities_for(actor, subject)
:ok = Clients.delete_clients_for(actor, subject)
# TODO: WAL
# TODO: Hard delete
# Consider using a trigger or transaction to handle the side effects of soft-deletions to ensure consistency
{_count, _memberships} =
Membership.Query.by_actor_id(actor.id)

View File

@@ -1,6 +1,13 @@
defmodule Domain.Actors.Membership do
use Domain, :schema
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
group_id: Ecto.UUID.t(),
actor_id: Ecto.UUID.t(),
account_id: Ecto.UUID.t()
}
schema "actor_group_memberships" do
belongs_to :group, Domain.Actors.Group
belongs_to :actor, Domain.Actors.Actor

View File

@@ -81,7 +81,7 @@ defmodule Domain.Application do
defp replication do
connection_modules = [
Domain.Events.ReplicationConnection,
Domain.Changes.ReplicationConnection,
Domain.ChangeLogs.ReplicationConnection
]

View File

@@ -0,0 +1,45 @@
defprotocol Domain.Cache.Cacheable do
@type uuid_binary :: <<_::128>>
@doc "Converts a Domain struct to its cache representation."
def to_cache(struct)
end
defimpl Domain.Cache.Cacheable, for: Domain.Gateways.Group do
def to_cache(%Domain.Gateways.Group{} = gateway_group) do
%Domain.Cache.Cacheable.GatewayGroup{
id: Ecto.UUID.dump!(gateway_group.id),
name: gateway_group.name
}
end
end
defimpl Domain.Cache.Cacheable, for: Domain.Resources.Resource do
def to_cache(%Domain.Resources.Resource{} = resource) do
%Domain.Cache.Cacheable.Resource{
id: Ecto.UUID.dump!(resource.id),
type: resource.type,
name: resource.name,
address: resource.address,
address_description: resource.address_description,
ip_stack: resource.ip_stack,
filters: Enum.map(resource.filters, &Map.from_struct/1),
gateway_groups:
if(is_list(resource.gateway_groups),
do: Enum.map(resource.gateway_groups, &Domain.Cache.Cacheable.to_cache/1),
else: []
)
}
end
end
defimpl Domain.Cache.Cacheable, for: Domain.Policies.Policy do
def to_cache(%Domain.Policies.Policy{} = policy) do
%Domain.Cache.Cacheable.Policy{
id: Ecto.UUID.dump!(policy.id),
resource_id: Ecto.UUID.dump!(policy.resource_id),
actor_group_id: Ecto.UUID.dump!(policy.actor_group_id),
conditions: Enum.map(policy.conditions, &Map.from_struct/1)
}
end
end

View File

@@ -0,0 +1,11 @@
defmodule Domain.Cache.Cacheable.GatewayGroup do
defstruct [
:id,
:name
]
@type t :: %__MODULE__{
id: Domain.Cache.Cacheable.uuid_binary(),
name: String.t()
}
end

View File

@@ -0,0 +1,34 @@
defmodule Domain.Cache.Cacheable.Policy do
defstruct [
:id,
:resource_id,
:actor_group_id,
:conditions
]
@type condition :: %{
property:
:remote_ip_location_region
| :remote_ip
| :provider_id
| :current_utc_datetime
| :client_verified,
operator:
:contains
| :does_not_contain
| :is_in
| :is_not_in
| :is_in_day_of_week_time_ranges
| :is_in_cidr
| :is_not_in_cidr
| :is,
values: [String.t()]
}
@type t :: %__MODULE__{
id: Domain.Cache.Cacheable.uuid_binary(),
resource_id: Domain.Cache.Cacheable.uuid_binary(),
actor_group_id: Domain.Cache.Cacheable.uuid_binary(),
conditions: [condition()]
}
end

View File

@@ -0,0 +1,28 @@
defmodule Domain.Cache.Cacheable.Resource do
defstruct [
:id,
:name,
:type,
:address,
:address_description,
:ip_stack,
:filters,
:gateway_groups
]
@type filter :: %{
protocol: :tcp | :udp | :icmp,
ports: [String.t()]
}
@type t :: %__MODULE__{
id: Domain.Cache.Cacheable.uuid_binary(),
name: String.t(),
type: :cidr | :ip | :dns | :internet,
address: String.t(),
address_description: String.t(),
ip_stack: atom(),
filters: [filter()],
gateway_groups: [Domain.Cache.Cacheable.GatewayGroup.t()]
}
end

View File

@@ -0,0 +1,561 @@
defmodule Domain.Cache.Client do
@moduledoc """
This cache is used in the client channel to maintain a materialized view of the client access state.
The cache is updated via WAL messages streamed from the Domain.Changes.ReplicationConnection module.
We use basic data structures and binary representations instead of full Ecto schema structs
to minimize memory usage. The rough structure of the cache data structure and some napkin math
on their memory usage (assuming "worst-case" usage scenarios) is described below.
Data structure:
%{
policies: %{id:uuidv4:16 => {
resource_id:uuidv4:16,
actor_group_id:uuidv4:16,
conditions:[%{
property:atom:0,
operator:atom:0,
values:
[string:varies]:(16 * len)}:(40 - small map)
]:(16 * len)
}:16
}:(num_keys * 1.8 * 8 - large map)
resources: %{id:uuidv4:16 => {
name: string:(~ 1.25 bytes per char),
address:string:(~ 1.25 bytes per char),
address_description:string:(~ 1.25 bytes per char),
ip_stack: atom:0,
type: atom:0,
filters: [%{protocol: atom:0, ports: [string:(~ 1.25 bytes per char)]}:(40 - small map)]:(16 * len),
gateway_groups: [%{
name:string:(~1.25 bytes per char),
resource_id:uuidv4:16,
gateway_group_id:uuidv4:16
}]
}},
memberships: %{group_id:uuidv4:16 => membership_id:uuidv4:16},
connectable_resources: [Cache.Cacheable.Resource.t()]
}
For 1,000 policies, 500 resources, 100 memberships, 100 flows (per connected client):
513,400 bytes, 280,700 bytes, 24,640 bytes, 24,640 bytes
= 843,380 bytes
= ~ 1 MB (per client)
"""
alias Domain.{Actors, Auth, Clients, Cache, Gateways, Resources, Policies}
require Logger
require OpenTelemetry.Tracer
import Ecto.UUID, only: [dump!: 1, load!: 1]
defstruct [
# A map of all the policies that match an actor group we're in.
:policies,
# A map of all the resources associated to the policies above.
:resources,
# A map of actor group IDs to membership IDs we're in.
:memberships,
# The list resources the client can currently connect to. This is defined as:
# 1. The resource is authorized based on policies and conditions
# 2. The resource is compatible with the client (i.e. the client can connect to it)
# 3. The resource has at least one gateway group associated with it
:connectable_resources
]
@type t :: %__MODULE__{
policies: %{Cache.Cacheable.uuid_binary() => Domain.Cache.Cacheable.Policy.t()},
resources: %{Cache.Cacheable.uuid_binary() => Domain.Cache.Cacheable.Resource.t()},
memberships: %{Cache.Cacheable.uuid_binary() => Cache.Cacheable.uuid_binary()},
connectable_resources: [Cache.Cacheable.Resource.t()]
}
@doc """
Authorizes a new flow for the given client and resource or returns a list of violated properties if
the resource is not authorized for the client.
"""
@spec authorize_resource(t(), Clients.Client.t(), Ecto.UUID.t(), Auth.Subject.t()) ::
{:ok, Cache.Cacheable.Resource.t(), Ecto.UUID.t(), Ecto.UUID.t(), non_neg_integer()}
| {:error, :not_found}
| {:error, {:forbidden, violated_properties: [atom()]}}
def authorize_resource(cache, client, resource_id, subject) do
rid_bytes = dump!(resource_id)
resource = Enum.find(cache.connectable_resources, :not_found, fn r -> r.id == rid_bytes end)
policy =
cache.policies
|> Enum.filter(fn {_id, policy} -> policy.resource_id == rid_bytes end)
|> Enum.map(fn {_id, policy} -> policy end)
|> Policies.longest_conforming_policy_for_client(client, subject.expires_at)
with %Cache.Cacheable.Resource{} <- resource,
{:ok, policy, expires_at} <- policy,
{:ok, mid_bytes} <- Map.fetch(cache.memberships, policy.actor_group_id) do
membership_id = load!(mid_bytes)
policy_id = load!(policy.id)
{:ok, resource, membership_id, policy_id, expires_at}
else
:not_found ->
Logger.warning("resource not found in connectable resources",
connectable_resources: inspect(cache.connectable_resources),
subject: inspect(subject),
client: inspect(client),
resource_id: resource_id
)
{:error, :not_found}
:error ->
Logger.warning("membership not found in cache",
memberships: inspect(cache.memberships),
subject: inspect(subject),
client: inspect(client),
resource_id: resource_id
)
{:error, :not_found}
{:error, {:forbidden, violated_properties: violated_properties}} ->
{:error, {:forbidden, violated_properties: violated_properties}}
end
end
@doc """
Recomputes the list of connectable resources, returning the newly connectable resources
and the IDs of resources that are no longer connectable so that the client may update its
state. This should be called periodically to handle differences due to time-based policy conditions.
If opts[:toggle] is set to true, we ensure that all added resources also have
"""
@spec recompute_connectable_resources(t() | nil, Clients.Client.t(), Keyword.t()) ::
{:ok, [Domain.Cache.Cacheable.Resource.t()], [Ecto.UUID.t()], t()}
def recompute_connectable_resources(nil, client) do
hydrate(client)
|> recompute_connectable_resources(client)
end
def recompute_connectable_resources(cache, client, opts \\ []) do
{toggle, _opts} = Keyword.pop(opts, :toggle)
connectable_resources =
cache.policies
|> conforming_resource_ids(client)
|> adapted_resources(cache.resources, client)
added = connectable_resources -- cache.connectable_resources
added_ids = Enum.map(added, & &1.id)
# connlib can handle all resource attribute changes except for changing sites, so we can omit the deleted IDs
# of added resources since they'll be updated gracefully.
removed =
(cache.connectable_resources -- connectable_resources)
|> Enum.filter(fn r ->
if toggle do
r
else
r.id not in added_ids
end
end)
cache = %{cache | connectable_resources: connectable_resources}
{:ok, added, Enum.map(removed, & &1.id) |> Enum.map(&load!/1), cache}
end
@doc """
Fetches a membership id by an actor_group_id.
"""
@spec fetch_membership_id(t(), Cache.Cacheable.uuid_binary()) ::
{:ok, Ecto.UUID.t()} | {:error, :not_found}
def fetch_membership_id(cache, gid_bytes) do
cache.memberships
|> Map.fetch(gid_bytes)
|> case do
{:ok, mid_bytes} -> {:ok, load!(mid_bytes)}
:error -> {:error, :not_found}
end
end
@doc """
Adds a new membership to the cache, potentially fetching the missing policies and resources
that we don't already have in our cache.
Since this affects connectable resources, we recompute the connectable resources, which could
yield deleted IDs, so we send those back.
"""
@spec add_membership(t(), Clients.Client.t()) ::
{:ok, [Domain.Cache.Cacheable.Resource.t()], [Ecto.UUID.t()], t()}
def add_membership(cache, client) do
# TODO: Optimization
# For simplicity, we rehydrate the cache here. This could be made more efficient by calculating which
# policies and resources we are missing, and selectively fetching, filtering, and updating the cache.
# This is not expected to cause an issue in production since in most cases, bulk new memberships would imply
# bulk new groups, which shouldn't have much if any policies associated to them.
previously_connectable = cache.connectable_resources
# Use the previous connectable IDs so that the recomputation yields the difference
cache = %{hydrate(client) | connectable_resources: previously_connectable}
recompute_connectable_resources(cache, client)
end
@doc """
Removes all policies, resources, and memberships associated with the given group_id from the cache.
"""
@spec delete_membership(t(), Actors.Membership.t(), Clients.Client.t()) ::
{:ok, [Cache.Cacheable.Resource.t()], [Ecto.UUID.t()], t()}
def delete_membership(cache, membership, client) do
gid_bytes = dump!(membership.group_id)
updated_policies =
cache.policies
|> Enum.reject(fn {_id, p} -> p.actor_group_id == gid_bytes end)
|> Enum.into(%{})
# Only remove resources that have no remaining policies
remaining_resource_ids =
updated_policies
|> Enum.map(fn {_id, p} -> p.resource_id end)
|> Enum.uniq()
|> MapSet.new()
updated_resources =
cache.resources
|> Enum.filter(fn {rid_bytes, _resource} ->
MapSet.member?(remaining_resource_ids, rid_bytes)
end)
|> Enum.into(%{})
updated_memberships =
cache.memberships
|> Map.delete(gid_bytes)
cache = %{
cache
| policies: updated_policies,
resources: updated_resources,
memberships: updated_memberships
}
recompute_connectable_resources(cache, client)
end
@doc """
Updates any relevant resources in the cache with the new group name.
"""
@spec update_resources_with_group_name(t(), Gateways.Group.t(), Clients.Client.t()) ::
{:ok, [Domain.Cache.Cacheable.Resource.t()], [Ecto.UUID.t()], t()}
def update_resources_with_group_name(cache, group, client) do
group = Domain.Cache.Cacheable.to_cache(group)
# Get updated resources
resources =
cache.resources
|> Enum.map(fn {id, resource} ->
# Replace old group with new
gateway_groups =
Enum.map(resource.gateway_groups, fn gg ->
if gg.id == group.id do
group
else
gg
end
end)
{id, %{resource | gateway_groups: gateway_groups}}
end)
|> Enum.into(%{})
cache = %{cache | resources: resources}
# For these updates we need to make sure the resource is toggled deleted then created.
# See https://github.com/firezone/firezone/issues/9881
recompute_connectable_resources(cache, client, toggle: true)
end
@doc """
Adds a new policy to the cache. If the policy includes a resource we do not already have in the cache,
we fetch the resource from the database and add it to the cache.
If the resource is compatible with and authorized for the current client, we return the resource,
otherwise we just return the updated cache.
"""
@spec add_policy(t(), Policies.Policy.t(), Clients.Client.t(), Auth.Subject.t()) ::
{:ok, [Domain.Cache.Cacheable.Resource.t()], [Ecto.UUID.t()], t()}
def add_policy(cache, %{resource_id: resource_id} = policy, client, subject) do
policy = Domain.Cache.Cacheable.to_cache(policy)
if Map.has_key?(cache.memberships, policy.actor_group_id) do
# Add policy to the cache
cache = %{cache | policies: Map.put(cache.policies, policy.id, policy)}
# Maybe add resource to the cache if we don't already have it
cache =
if Map.has_key?(cache.resources, policy.resource_id) do
cache
else
# Need to fetch the resource from the DB
opts = [preload: :gateway_groups]
{:ok, resource} = Resources.fetch_resource_by_id(resource_id, subject, opts)
resource = Domain.Cache.Cacheable.to_cache(resource)
%{cache | resources: Map.put(cache.resources, resource.id, resource)}
end
recompute_connectable_resources(cache, client)
else
{:ok, [], [], cache}
end
end
@doc """
Updates policy in cache with given policy if it exists. Breaking policy changes are handled separately
with a delete and then add operation.
"""
@spec update_policy(t(), Policies.Policy.t()) :: {:ok, [], [], t()}
def update_policy(cache, policy) do
policy = Domain.Cache.Cacheable.to_cache(policy)
policies = Map.replace(cache.policies, policy.id, policy)
{:ok, [], [], %{cache | policies: policies}}
end
@doc """
Removes a policy from the cache. If we can't find another policy granting access to the resource,
we return the deleted resource ID.
"""
@spec delete_policy(t(), Policies.Policy.t(), Clients.Client.t()) ::
{:ok, [Domain.Cache.Cacheable.Resource.t()], [Ecto.UUID.t()], t()}
def delete_policy(cache, policy, client) do
policy = Domain.Cache.Cacheable.to_cache(policy)
if Map.has_key?(cache.policies, policy.id) do
# Update the cache
cache = %{cache | policies: Map.delete(cache.policies, policy.id)}
# Remove the resource if no policies are left for it
no_more_policies? =
cache.policies
|> Enum.all?(fn {_id, p} -> p.resource_id != policy.resource_id end)
resources =
if no_more_policies? do
Map.delete(cache.resources, policy.resource_id)
else
cache.resources
end
cache = %{cache | resources: resources}
recompute_connectable_resources(cache, client)
else
{:ok, [], [], cache}
end
end
@doc """
Adds a gateway group (by virtue of the added resource connection) to the appropriate resource in the cache.
Since resource connection is a join record, we need to fetch the group from the DB to get its name.
Since adding a gateway group requires re-evaluating policies, the resource could now be connectable or not connectable
so we return either the deleted resource ID or the updated resource if there's a change. Otherwise we simply
return the updated cache.
"""
@spec add_resource_connection(
t(),
Resources.Connection.t(),
Auth.Subject.t(),
Clients.Client.t()
) ::
{:ok, [Domain.Cache.Cacheable.Resource.t()], [Ecto.UUID.t()], t()}
def add_resource_connection(cache, connection, subject, client) do
rid_bytes = dump!(connection.resource_id)
if Map.has_key?(cache.resources, rid_bytes) do
# We need the gateway group to add it
{:ok, gateway_group} = Gateways.fetch_group_by_id(connection.gateway_group_id, subject)
gateway_group = Domain.Cache.Cacheable.to_cache(gateway_group)
# Update the cache
resources =
cache.resources
|> Map.update!(rid_bytes, fn resource ->
if gateway_group in resource.gateway_groups do
# Duplicates here mean something is amiss, so be noisy about it.
Logger.error("Duplicate gateway group in resource cache",
resource: resource,
gateway_group: gateway_group
)
resource
else
%{resource | gateway_groups: [gateway_group | resource.gateway_groups]}
end
end)
cache = %{cache | resources: resources}
# For these updates we need to make sure the resource is toggled deleted then created.
# See https://github.com/firezone/firezone/issues/9881
recompute_connectable_resources(cache, client, toggle: true)
else
{:ok, [], [], cache}
end
end
@doc """
Deletes a gateway group (by virtue of the deleted resource connection) from the appropriate resource in the cache.
If the resource has no more gateway groups, we return the resource ID so the client can remove it. Otherwise, we
return the updated resource.
"""
@spec delete_resource_connection(t(), Resources.Connection.t(), Clients.Client.t()) ::
{:ok, [Domain.Cache.Cacheable.Resource.t()], [Ecto.UUID.t()], t()}
def delete_resource_connection(cache, connection, client) do
rid_bytes = dump!(connection.resource_id)
if Map.has_key?(cache.resources, rid_bytes) do
# Update the cache
resources =
cache.resources
|> Map.update!(rid_bytes, fn resource ->
gateway_groups =
Enum.reject(resource.gateway_groups, fn gg ->
gg.id == dump!(connection.gateway_group_id)
end)
%{resource | gateway_groups: gateway_groups}
end)
cache = %{cache | resources: resources}
# For these updates we need to make sure the resource is toggled deleted then created.
# See https://github.com/firezone/firezone/issues/9881
recompute_connectable_resources(cache, client, toggle: true)
else
{:ok, [], [], cache}
end
end
@doc """
Updates a resource in the cache with the given resource if it exists.
If the resource's address has changed and we are no longer compatible with it, we
need to remove it from the client's list of resources.
Otherwise, if the resource's address has changed and we are _now_ compatible with it, we need
to add it to the client's list of resources.
If the resource has not meaningfully changed (i.e. the cached versions are the same),
we return only the updated cache.
"""
@spec update_resource(t(), Resources.Resource.t(), Clients.Client.t()) ::
{:ok, [Domain.Cache.Cacheable.Resource.t()], [Ecto.UUID.t()], t()}
def update_resource(cache, resource, client) do
resource = Domain.Cache.Cacheable.to_cache(resource)
if Map.has_key?(cache.resources, resource.id) do
# Copy preloaded gateway groups
resource = %{
resource
| gateway_groups: Map.get(cache.resources, resource.id).gateway_groups
}
# Update the cache
resources = %{cache.resources | resource.id => resource}
cache = %{cache | resources: resources}
recompute_connectable_resources(cache, client)
else
{:ok, [], [], cache}
end
end
defp hydrate(client) do
attributes = %{
actor_id: client.actor_id
}
OpenTelemetry.Tracer.with_span "Cache.Cacheable.hydrate", attributes: attributes do
{_policies, cache} =
Policies.all_policies_for_actor_id!(client.actor_id)
|> Enum.map_reduce(%{policies: %{}, resources: %{}}, fn policy, cache ->
resource = Cache.Cacheable.to_cache(policy.resource)
resources = Map.put(cache.resources, resource.id, resource)
policy = Cache.Cacheable.to_cache(policy)
policies = Map.put(cache.policies, policy.id, policy)
{policy, %{cache | policies: policies, resources: resources}}
end)
memberships =
Actors.all_memberships_for_actor_id!(client.actor_id)
|> Enum.map(fn membership ->
{dump!(membership.group_id), dump!(membership.id)}
end)
|> Enum.into(%{})
cache
|> Map.put(:memberships, memberships)
|> Map.put(:connectable_resources, [])
end
end
defp adapted_resources(conforming_resource_ids, resources, client) do
conforming_resource_ids
|> Enum.map(fn id -> Map.get(resources, id) end)
|> Enum.map(fn r -> adapt(r, client) end)
|> Enum.reject(fn r -> is_nil(r) end)
|> Enum.filter(fn r -> r.gateway_groups != [] end)
end
defp conforming_resource_ids(policies, client) when is_map(policies) do
policies
|> Map.values()
|> conforming_resource_ids(client)
end
defp conforming_resource_ids(policies, client) do
policies
|> Policies.filter_by_conforming_policies_for_client(client)
|> Enum.map(& &1.resource_id)
|> Enum.uniq()
end
defp adapt(resource, client) do
Resources.adapt_resource_for_version(resource, client.last_seen_version)
end
end

View File

@@ -0,0 +1,206 @@
defmodule Domain.Cache.Gateway do
@moduledoc """
This cache is used in the gateway channel processes to maintain a materialized view of the gateway flow state.
The cache is updated via WAL messages streamed from the Domain.Changes.ReplicationConnection module.
We use basic data structures and binary representations instead of full Ecto schema structs
to minimize memory usage. The rough structure of the two cached data structures and some napkin math
on their memory usage (assuming "worst-case" usage scenarios) is described below.
Data structure:
%{{client_id:uuidv4:16, resource_id:uuidv4:16}:16 => %{flow_id:uuidv4:16 => expires_at:integer:8}:40}:(num_keys * 1.8 * 8 - large map)
For 10,000 client/resource entries, consisting of 10 flows each:
10,000 keys, 100,000 values
480,000 bytes (outer map keys), 6,400,000 bytes (inner map), 144,000 bytes (outer map overhead)
= 7,024,000
= ~ 7 MB
"""
alias Domain.{Cache, Flows, Gateways}
import Ecto.UUID, only: [dump!: 1, load!: 1]
require OpenTelemetry.Tracer
# Type definitions
@type client_resource_key ::
{client_id :: Cache.Cacheable.uuid_binary(),
resource_id :: Cache.Cacheable.uuid_binary()}
@type flow_map :: %{
(flow_id :: Cache.Cacheable.uuid_binary()) => expires_at_unix :: non_neg_integer
}
@type t :: %{client_resource_key() => flow_map()}
@doc """
Fetches relevant flows from the DB and transforms them into the cache format.
"""
@spec hydrate(Gateways.Gateway.t()) :: t()
def hydrate(gateway) do
OpenTelemetry.Tracer.with_span "Domain.Cache.hydrate_flows",
attributes: %{
gateway_id: gateway.id,
account_id: gateway.account_id
} do
Flows.all_gateway_flows_for_cache!(gateway)
|> Enum.reduce(%{}, fn {{client_id, resource_id}, {flow_id, expires_at}}, acc ->
cid_bytes = dump!(client_id)
rid_bytes = dump!(resource_id)
fid_bytes = dump!(flow_id)
expires_at_unix = DateTime.to_unix(expires_at, :second)
flow_id_map = Map.get(acc, {cid_bytes, rid_bytes}, %{})
Map.put(acc, {cid_bytes, rid_bytes}, Map.put(flow_id_map, fid_bytes, expires_at_unix))
end)
end
end
@doc """
Removes expired flows from the cache.
"""
@spec prune(t()) :: t()
def prune(cache) do
now_unix = DateTime.utc_now() |> DateTime.to_unix(:second)
# 1. Remove individual flows older than 14 days, then remove access entry if no flows left
cache
|> Enum.map(fn {tuple, flow_id_map} ->
flow_id_map =
Enum.reject(flow_id_map, fn {_fid_bytes, expires_at_unix} ->
expires_at_unix < now_unix
end)
|> Enum.into(%{})
{tuple, flow_id_map}
end)
|> Enum.into(%{})
|> Enum.reject(fn {_tuple, flow_id_map} -> map_size(flow_id_map) == 0 end)
|> Enum.into(%{})
end
@doc """
Fetches the max expiration for a client-resource from the cache, or nil if not found.
"""
@spec get(t(), Ecto.UUID.t(), Ecto.UUID.t()) :: non_neg_integer() | nil
def get(cache, client_id, resource_id) do
tuple = {dump!(client_id), dump!(resource_id)}
case Map.get(cache, tuple) do
nil ->
nil
flow_id_map ->
# Use longest expiration to minimize unnecessary access churn
flow_id_map
|> Map.values()
|> Enum.max()
end
end
@doc """
Add a flow to the cache. Returns the updated cache.
"""
@spec put(t(), Ecto.UUID.t(), Cache.Cacheable.uuid_binary(), Ecto.UUID.t(), DateTime.t()) :: t()
def put(%{} = cache, client_id, rid_bytes, flow_id, %DateTime{} = expires_at) do
tuple = {dump!(client_id), rid_bytes}
flow_id_map =
Map.get(cache, tuple, %{})
|> Map.put(dump!(flow_id), DateTime.to_unix(expires_at, :second))
Map.put(cache, tuple, flow_id_map)
end
@doc """
Delete a flow from the cache. If another flow exists for the same client/resource,
we return the max expiration for that resource.
If not, we optimistically try to reauthorize access by creating a new flow. This prevents
removal of access on the Gateway but not the client, which would cause connectivity issues.
If we can't create a new authorization, we send unauthorized so that access is removed.
"""
@spec reauthorize_deleted_flow(t(), Flows.Flow.t()) ::
{:ok, non_neg_integer(), t()} | {:error, :unauthorized, t()} | {:error, :not_found}
def reauthorize_deleted_flow(cache, %Flows.Flow{} = flow) do
key = flow_key(flow)
flow_id = dump!(flow.id)
case get_and_remove_flow(cache, key, flow_id) do
{:not_found, _cache} ->
{:error, :not_found}
{:last_flow_removed, cache} ->
handle_last_flow_removal(cache, key, flow)
{:flow_removed, remaining_flows, cache} ->
max_expiration = remaining_flows |> Map.values() |> Enum.max()
{:ok, max_expiration, cache}
end
end
defp flow_key(%Flows.Flow{client_id: client_id, resource_id: resource_id}) do
{dump!(client_id), dump!(resource_id)}
end
defp get_and_remove_flow(cache, key, flow_id) do
case Map.fetch(cache, key) do
:error ->
{:not_found, cache}
{:ok, flow_map} ->
case Map.pop(flow_map, flow_id) do
{nil, _} ->
{:not_found, cache}
{_expiration, remaining_flows} when remaining_flows == %{} ->
{:last_flow_removed, Map.delete(cache, key)}
{_expiration, remaining_flows} ->
{:flow_removed, remaining_flows, Map.put(cache, key, remaining_flows)}
end
end
end
defp handle_last_flow_removal(cache, key, flow) do
case Flows.reauthorize_flow(flow) do
{:ok, new_flow} ->
new_flow_id = dump!(new_flow.id)
expires_at_unix = DateTime.to_unix(new_flow.expires_at, :second)
new_flow_map = %{new_flow_id => expires_at_unix}
{:ok, expires_at_unix, Map.put(cache, key, new_flow_map)}
:error ->
{:error, :unauthorized, cache}
end
end
@doc """
Check if the cache has a resource entry for the given resource_id.
Returns true if the resource is present, false otherwise.
"""
@spec has_resource?(t(), Ecto.UUID.t()) :: boolean()
def has_resource?(%{} = cache, resource_id) do
rid_bytes = dump!(resource_id)
cache
|> Map.keys()
|> Enum.any?(fn {_, rid} ->
rid == rid_bytes
end)
end
@doc """
Return a list of all pairs matching the resource ID.
"""
@spec all_pairs_for_resource(t(), Ecto.UUID.t()) :: [{Ecto.UUID.t(), Ecto.UUID.t()}]
def all_pairs_for_resource(%{} = cache, resource_id) do
rid_bytes = dump!(resource_id)
cache
|> Enum.filter(fn {{_, rid}, _} -> rid == rid_bytes end)
|> Enum.map(fn {{cid, _}, _} -> {load!(cid), resource_id} end)
end
end

View File

@@ -0,0 +1,15 @@
defmodule Domain.Changes.Change do
defstruct [
:lsn,
:op,
:old_struct,
:struct
]
@type t :: %__MODULE__{
lsn: integer(),
op: :insert | :update | :delete,
old_struct: struct() | nil,
struct: struct() | nil
}
end

View File

@@ -0,0 +1,10 @@
defmodule Domain.Changes.Hooks do
@moduledoc """
A simple behavior to define hooks needed for processing WAL events.
"""
@callback on_insert(lsn :: integer(), data :: map()) :: :ok | {:error, term()}
@callback on_update(lsn :: integer(), old_data :: map(), data :: map()) ::
:ok | {:error, term()}
@callback on_delete(lsn :: integer(), old_data :: map()) :: :ok | {:error, term()}
end

View File

@@ -0,0 +1,54 @@
defmodule Domain.Changes.Hooks.Accounts do
@behaviour Domain.Changes.Hooks
alias Domain.{Accounts, Changes.Change, Flows, PubSub}
import Domain.SchemaHelpers
require Logger
@impl true
def on_insert(_lsn, _data), do: :ok
# Account slug changed - disconnect gateways for updated init
@impl true
# Account disabled - process as a delete
def on_update(
lsn,
%{"disabled_at" => nil} = old_data,
%{"disabled_at" => disabled_at}
)
when not is_nil(disabled_at) do
on_delete(lsn, old_data)
end
# Account soft-deleted - process as a delete
def on_update(
lsn,
%{"deleted_at" => nil} = old_data,
%{"deleted_at" => deleted_at}
)
when not is_nil(deleted_at) do
on_delete(lsn, old_data)
end
def on_update(lsn, old_data, data) do
old_account = struct_from_params(Accounts.Account, old_data)
account = struct_from_params(Accounts.Account, data)
change = %Change{lsn: lsn, op: :update, old_struct: old_account, struct: account}
PubSub.Account.broadcast(account.id, change)
end
@impl true
def on_delete(lsn, old_data) do
account = struct_from_params(Accounts.Account, old_data)
change = %Change{lsn: lsn, op: :delete, old_struct: account}
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Flows.delete_flows_for(account)
PubSub.Account.broadcast(account.id, change)
end
end

View File

@@ -0,0 +1,28 @@
defmodule Domain.Changes.Hooks.ActorGroupMemberships do
@behaviour Domain.Changes.Hooks
alias Domain.{Actors, Changes.Change, Flows, PubSub}
import Domain.SchemaHelpers
@impl true
def on_insert(lsn, data) do
membership = struct_from_params(Actors.Membership, data)
change = %Change{lsn: lsn, op: :insert, struct: membership}
PubSub.Account.broadcast(membership.account_id, change)
end
@impl true
def on_update(_lsn, _old_data, _data), do: :ok
@impl true
def on_delete(lsn, old_data) do
membership = struct_from_params(Actors.Membership, old_data)
change = %Change{lsn: lsn, op: :delete, old_struct: membership}
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Flows.delete_flows_for(membership)
PubSub.Account.broadcast(membership.account_id, change)
end
end

View File

@@ -0,0 +1,44 @@
defmodule Domain.Changes.Hooks.Clients do
@behaviour Domain.Changes.Hooks
alias Domain.{Changes.Change, Clients, Flows, PubSub}
import Domain.SchemaHelpers
@impl true
def on_insert(_lsn, _data), do: :ok
@impl true
# Soft-delete
def on_update(lsn, %{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(lsn, old_data)
end
# Regular update
def on_update(lsn, old_data, data) do
old_client = struct_from_params(Clients.Client, old_data)
client = struct_from_params(Clients.Client, data)
change = %Change{lsn: lsn, op: :update, old_struct: old_client, struct: client}
# Unverifying a client
# This is a special case - we need to delete associated flows when unverifying a client since
# it could affect connectivity if any policies are based on the verified status.
if not is_nil(old_client.verified_at) and is_nil(client.verified_at) do
Flows.delete_flows_for(client)
end
PubSub.Account.broadcast(client.account_id, change)
end
@impl true
def on_delete(lsn, old_data) do
client = struct_from_params(Clients.Client, old_data)
change = %Change{lsn: lsn, op: :delete, old_struct: client}
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Flows.delete_flows_for(client)
PubSub.Account.broadcast(client.account_id, change)
end
end

View File

@@ -0,0 +1,26 @@
defmodule Domain.Changes.Hooks.Flows do
@behaviour Domain.Changes.Hooks
alias Domain.{Changes.Change, Flows, PubSub}
import Domain.SchemaHelpers
@impl true
# We don't react directly to flow creation events because connection setup
# is latency sensitive and we've already broadcasted the relevant message from
# client pid to gateway pid directly.
def on_insert(_lsn, _data), do: :ok
@impl true
# Flows are never updated
def on_update(_lsn, _old_data, _data), do: :ok
@impl true
# This will trigger reject_access for any subscribed gateways
def on_delete(lsn, old_data) do
flow = struct_from_params(Flows.Flow, old_data)
change = %Change{lsn: lsn, op: :delete, old_struct: flow}
PubSub.Account.broadcast(flow.account_id, change)
end
end

View File

@@ -0,0 +1,30 @@
defmodule Domain.Changes.Hooks.GatewayGroups do
@behaviour Domain.Changes.Hooks
alias Domain.{Changes.Change, Gateways, PubSub}
import Domain.SchemaHelpers
@impl true
def on_insert(_lsn, _data), do: :ok
# Soft-delete
@impl true
def on_update(lsn, %{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(lsn, old_data)
end
# Regular update
def on_update(lsn, old_data, data) do
old_gateway_group = struct_from_params(Gateways.Group, old_data)
gateway_group = struct_from_params(Gateways.Group, data)
change = %Change{lsn: lsn, op: :update, old_struct: old_gateway_group, struct: gateway_group}
PubSub.Account.broadcast(gateway_group.account_id, change)
end
@impl true
# Deleting a gateway group will delete the associated resource connection, where
# we handle removing it from the client's resource list.
def on_delete(_lsn, _old_data), do: :ok
end

View File

@@ -0,0 +1,30 @@
defmodule Domain.Changes.Hooks.Gateways do
@behaviour Domain.Changes.Hooks
alias Domain.{Changes.Change, Flows, Gateways, PubSub}
import Domain.SchemaHelpers
@impl true
def on_insert(_lsn, _data), do: :ok
# Soft-delete
@impl true
def on_update(lsn, %{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(lsn, old_data)
end
# Regular update
def on_update(_lsn, _old_data, _data), do: :ok
@impl true
def on_delete(lsn, old_data) do
gateway = struct_from_params(Gateways.Gateway, old_data)
change = %Change{lsn: lsn, op: :delete, old_struct: gateway}
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Flows.delete_flows_for(gateway)
PubSub.Account.broadcast(gateway.account_id, change)
end
end

View File

@@ -0,0 +1,64 @@
defmodule Domain.Changes.Hooks.Policies do
@behaviour Domain.Changes.Hooks
alias Domain.{Changes.Change, Flows, Policies, PubSub}
import Domain.SchemaHelpers
@impl true
def on_insert(lsn, data) do
policy = struct_from_params(Policies.Policy, data)
change = %Change{lsn: lsn, op: :insert, struct: policy}
PubSub.Account.broadcast(policy.account_id, change)
end
@impl true
# Disable - process as delete
def on_update(lsn, %{"disabled_at" => nil}, %{"disabled_at" => disabled_at} = data)
when not is_nil(disabled_at) do
on_delete(lsn, data)
end
# Enable - process as insert
def on_update(lsn, %{"disabled_at" => disabled_at}, %{"disabled_at" => nil} = data)
when not is_nil(disabled_at) do
on_insert(lsn, data)
end
# Soft-delete - process as delete
def on_update(lsn, %{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(lsn, old_data)
end
# Regular update
def on_update(lsn, old_data, data) do
old_policy = struct_from_params(Policies.Policy, old_data)
policy = struct_from_params(Policies.Policy, data)
change = %Change{lsn: lsn, op: :update, old_struct: old_policy, struct: policy}
# Breaking updates
# This is a special case - we need to delete related flows because connectivity has changed
# The Gateway PID will receive flow deletion messages and process them to potentially reject
# access. The client PID (if connected) will toggle the resource deleted/created.
if old_policy.conditions != policy.conditions or
old_policy.actor_group_id != policy.actor_group_id or
old_policy.resource_id != policy.resource_id do
Flows.delete_flows_for(old_policy)
end
PubSub.Account.broadcast(policy.account_id, change)
end
@impl true
def on_delete(lsn, old_data) do
policy = struct_from_params(Policies.Policy, old_data)
change = %Change{lsn: lsn, op: :delete, old_struct: policy}
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Flows.delete_flows_for(policy)
PubSub.Account.broadcast(policy.account_id, change)
end
end

View File

@@ -0,0 +1,26 @@
defmodule Domain.Changes.Hooks.ResourceConnections do
@behaviour Domain.Changes.Hooks
alias Domain.{Changes.Change, Flows, Resources, PubSub}
import Domain.SchemaHelpers
@impl true
def on_insert(lsn, data) do
connection = struct_from_params(Resources.Connection, data)
change = %Change{lsn: lsn, op: :insert, struct: connection}
PubSub.Account.broadcast(connection.account_id, change)
end
@impl true
def on_update(_lsn, _old_data, _data), do: :ok
@impl true
def on_delete(lsn, old_data) do
connection = struct_from_params(Resources.Connection, old_data)
change = %Change{lsn: lsn, op: :delete, old_struct: connection}
Flows.delete_flows_for(connection)
PubSub.Account.broadcast(connection.account_id, change)
end
end

View File

@@ -0,0 +1,57 @@
defmodule Domain.Changes.Hooks.Resources do
@behaviour Domain.Changes.Hooks
alias Domain.{Changes.Change, Flows, PubSub, Resources}
import Domain.SchemaHelpers
@impl true
def on_insert(lsn, data) do
resource = struct_from_params(Resources.Resource, data)
change = %Change{lsn: lsn, op: :insert, struct: resource}
PubSub.Account.broadcast(resource.account_id, change)
end
@impl true
# Soft-delete - process as delete
def on_update(lsn, %{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(lsn, old_data)
end
# Regular update
def on_update(lsn, old_data, data) do
old_resource = struct_from_params(Resources.Resource, old_data)
resource = struct_from_params(Resources.Resource, data)
change = %Change{lsn: lsn, op: :update, old_struct: old_resource, struct: resource}
# Breaking updates
# This is a special case - we need to delete related flows because connectivity has changed
# Gateway _does_ handle resource filter changes so we don't need to delete flows
# for those changes - they're processed by the Gateway channel pid.
# The Gateway channel will process these flow deletions and re-authorize the flow.
# However, the gateway will also react to the resource update and send reject_access
# so that the Gateway's state is updated correctly, and the client can create a new flow.
if old_resource.ip_stack != resource.ip_stack or
old_resource.type != resource.type or
old_resource.address != resource.address do
Flows.delete_flows_for(resource)
end
PubSub.Account.broadcast(resource.account_id, change)
end
@impl true
def on_delete(lsn, old_data) do
resource = struct_from_params(Resources.Resource, old_data)
change = %Change{lsn: lsn, op: :delete, old_struct: resource}
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Flows.delete_flows_for(resource)
PubSub.Account.broadcast(resource.account_id, change)
end
end

View File

@@ -0,0 +1,51 @@
defmodule Domain.Changes.Hooks.Tokens do
@behaviour Domain.Changes.Hooks
alias Domain.{Flows, PubSub, Tokens}
import Domain.SchemaHelpers
@impl true
def on_insert(_lsn, _data), do: :ok
@impl true
# updates for email and relay_group tokens have no side effects
def on_update(_lsn, %{"type" => "email"}, _data), do: :ok
def on_update(_lsn, _old_data, %{"type" => "email"}), do: :ok
def on_update(_lsn, %{"type" => "relay_group"}, _data), do: :ok
def on_update(_lsn, _old_data, %{"type" => "relay_group"}), do: :ok
# Soft-delete - process as delete
def on_update(lsn, %{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(lsn, old_data)
end
# Regular update
def on_update(_lsn, _old_data, _new_data), do: :ok
@impl true
def on_delete(_lsn, old_data) do
token = struct_from_params(Tokens.Token, old_data)
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Flows.delete_flows_for(token)
# We don't need to broadcast deleted tokens since the disconnect_socket/1
# function will handle any disconnects for us directly.
# Disconnect all sockets using this token
disconnect_socket(token)
end
# This is a special message that disconnects all sockets using this token,
# such as for LiveViews.
defp disconnect_socket(token) do
topic = Domain.Tokens.socket_id(token.id)
payload = %Phoenix.Socket.Broadcast{topic: topic, event: "disconnect"}
PubSub.broadcast(topic, payload)
end
end

View File

@@ -1,6 +1,6 @@
defmodule Domain.Events.ReplicationConnection do
defmodule Domain.Changes.ReplicationConnection do
use Domain.Replication.Connection
alias Domain.Events.Hooks
alias Domain.Changes.Hooks
@tables_to_hooks %{
"accounts" => Hooks.Accounts,
@@ -15,12 +15,12 @@ defmodule Domain.Events.ReplicationConnection do
"tokens" => Hooks.Tokens
}
def on_write(state, _lsn, op, table, old_data, data) do
def on_write(state, lsn, op, table, old_data, data) do
if hook = Map.get(@tables_to_hooks, table) do
case op do
:insert -> :ok = hook.on_insert(data)
:update -> :ok = hook.on_update(old_data, data)
:delete -> :ok = hook.on_delete(old_data)
:insert -> :ok = hook.on_insert(lsn, data)
:update -> :ok = hook.on_update(lsn, old_data, data)
:delete -> :ok = hook.on_delete(lsn, old_data)
end
else
log_warning(op, table)
@@ -31,7 +31,7 @@ defmodule Domain.Events.ReplicationConnection do
defp log_warning(op, table) do
Logger.warning(
"No hook defined for #{op} on table #{table}. Please implement Domain.Events.Hooks for this table."
"No hook defined for #{op} on table #{table}. Please implement Domain.Changes.Hooks for this table."
)
end
end

View File

@@ -1,6 +1,39 @@
defmodule Domain.Clients.Client do
use Domain, :schema
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
external_id: String.t(),
name: String.t(),
public_key: String.t(),
psk_base: binary(),
ipv4: Domain.Types.IP.t(),
ipv6: Domain.Types.IP.t(),
last_seen_user_agent: String.t(),
last_seen_remote_ip: Domain.Types.IP.t(),
last_seen_remote_ip_location_region: String.t(),
last_seen_remote_ip_location_city: String.t(),
last_seen_remote_ip_location_lat: float(),
last_seen_remote_ip_location_lon: float(),
last_seen_version: String.t(),
last_seen_at: DateTime.t(),
online?: boolean(),
account_id: Ecto.UUID.t(),
actor_id: Ecto.UUID.t(),
identity_id: Ecto.UUID.t(),
last_used_token_id: Ecto.UUID.t(),
device_serial: String.t() | nil,
device_uuid: String.t() | nil,
identifier_for_vendor: String.t() | nil,
firebase_installation_id: String.t() | nil,
verified_at: DateTime.t() | nil,
verified_by: :system | :actor | :identity | nil,
verified_by_subject: map() | nil,
deleted_at: DateTime.t() | nil,
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
schema "clients" do
field :external_id, :string

View File

@@ -323,12 +323,12 @@ defmodule Domain.Config.Definitions do
@doc """
Name of the replication slot used by Firezone.
"""
defconfig(:database_events_replication_slot_name, :string, default: "events_slot")
defconfig(:database_changes_replication_slot_name, :string, default: "changes_slot")
@doc """
Name of the publication used by Firezone.
"""
defconfig(:database_events_publication_name, :string, default: "events")
defconfig(:database_changes_publication_name, :string, default: "changes")
@doc """
Name of the replication slot used by Firezone.

View File

@@ -1,9 +0,0 @@
defmodule Domain.Events.Hooks do
@moduledoc """
A simple behavior to define hooks needed for processing WAL events.
"""
@callback on_insert(data :: map()) :: :ok | {:error, term()}
@callback on_update(old_data :: map(), data :: map()) :: :ok | {:error, term()}
@callback on_delete(old_data :: map()) :: :ok | {:error, term()}
end

View File

@@ -1,48 +0,0 @@
defmodule Domain.Events.Hooks.Accounts do
@behaviour Domain.Events.Hooks
alias Domain.{Accounts, PubSub, SchemaHelpers}
require Logger
@impl true
def on_insert(_data), do: :ok
# Account slug changed - disconnect gateways for updated init
@impl true
# Account disabled - process as a delete
def on_update(
%{"disabled_at" => nil} = old_data,
%{"disabled_at" => disabled_at}
)
when not is_nil(disabled_at) do
on_delete(old_data)
end
# Account soft-deleted - process as a delete
def on_update(
%{"deleted_at" => nil} = old_data,
%{"deleted_at" => deleted_at}
)
when not is_nil(deleted_at) do
on_delete(old_data)
end
def on_update(old_data, data) do
old_account = SchemaHelpers.struct_from_params(Accounts.Account, old_data)
account = SchemaHelpers.struct_from_params(Accounts.Account, data)
PubSub.Account.broadcast(account.id, {:updated, old_account, account})
end
@impl true
def on_delete(old_data) do
account = SchemaHelpers.struct_from_params(Accounts.Account, old_data)
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Domain.Flows.delete_flows_for(account)
PubSub.Account.broadcast(account.id, {:deleted, account})
end
end

View File

@@ -1,24 +0,0 @@
defmodule Domain.Events.Hooks.ActorGroupMemberships do
@behaviour Domain.Events.Hooks
alias Domain.{Actors, SchemaHelpers, PubSub}
@impl true
def on_insert(data) do
membership = SchemaHelpers.struct_from_params(Actors.Membership, data)
PubSub.Account.broadcast(membership.account_id, {:created, membership})
end
@impl true
def on_update(_old_data, _data), do: :ok
@impl true
def on_delete(old_data) do
membership = SchemaHelpers.struct_from_params(Actors.Membership, old_data)
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Domain.Flows.delete_flows_for(membership)
PubSub.Account.broadcast(membership.account_id, {:deleted, membership})
end
end

View File

@@ -1,41 +0,0 @@
defmodule Domain.Events.Hooks.Clients do
@behaviour Domain.Events.Hooks
alias Domain.{Clients, SchemaHelpers, PubSub}
@impl true
def on_insert(_data), do: :ok
@impl true
# Soft-delete
def on_update(%{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(old_data)
end
# Regular update
def on_update(old_data, data) do
old_client = SchemaHelpers.struct_from_params(Clients.Client, old_data)
client = SchemaHelpers.struct_from_params(Clients.Client, data)
# Unverifying a client
# This is a special case - we need to delete associated flows when unverifying a client since
# it could affect connectivity if any policies are based on the verified status.
if not is_nil(old_client.verified_at) and is_nil(client.verified_at) do
Domain.Flows.delete_flows_for(client)
end
PubSub.Account.broadcast(client.account_id, {:updated, old_client, client})
end
@impl true
def on_delete(old_data) do
client = SchemaHelpers.struct_from_params(Clients.Client, old_data)
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Domain.Flows.delete_flows_for(client)
PubSub.Account.broadcast(client.account_id, {:deleted, client})
end
end

View File

@@ -1,24 +0,0 @@
defmodule Domain.Events.Hooks.Flows do
@behaviour Domain.Events.Hooks
alias Domain.{Flows, PubSub, SchemaHelpers}
@impl true
# We don't react directly to flow creation events because connection setup
# is latency sensitive and we've already broadcasted the relevant message from
# client pid to gateway pid directly.
def on_insert(_data), do: :ok
@impl true
# Flows are never updated
def on_update(_old_data, _data), do: :ok
@impl true
# This will trigger reject_access for any subscribed gateways
def on_delete(old_data) do
flow = SchemaHelpers.struct_from_params(Flows.Flow, old_data)
PubSub.Account.broadcast(flow.account_id, {:deleted, flow})
end
end

View File

@@ -1,31 +0,0 @@
defmodule Domain.Events.Hooks.GatewayGroups do
@behaviour Domain.Events.Hooks
alias Domain.{Gateways, PubSub, SchemaHelpers}
@impl true
def on_insert(_data), do: :ok
# Soft-delete
@impl true
def on_update(%{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(old_data)
end
# Regular update
def on_update(old_data, data) do
old_gateway_group = SchemaHelpers.struct_from_params(Gateways.Group, old_data)
gateway_group = SchemaHelpers.struct_from_params(Gateways.Group, data)
PubSub.Account.broadcast(
gateway_group.account_id,
{:updated, old_gateway_group, gateway_group}
)
end
@impl true
# Deleting a gateway group will delete the associated resource connection, where
# we handle removing it from the client's resource list.
def on_delete(_old_data), do: :ok
end

View File

@@ -1,28 +0,0 @@
defmodule Domain.Events.Hooks.Gateways do
@behaviour Domain.Events.Hooks
alias Domain.{Gateways, PubSub, SchemaHelpers}
@impl true
def on_insert(_data), do: :ok
# Soft-delete
@impl true
def on_update(%{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(old_data)
end
# Regular update
def on_update(_old_data, _data), do: :ok
@impl true
def on_delete(old_data) do
gateway = SchemaHelpers.struct_from_params(Gateways.Gateway, old_data)
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Domain.Flows.delete_flows_for(gateway)
PubSub.Account.broadcast(gateway.account_id, {:deleted, gateway})
end
end

View File

@@ -1,60 +0,0 @@
defmodule Domain.Events.Hooks.Policies do
@behaviour Domain.Events.Hooks
alias Domain.{Policies, PubSub, SchemaHelpers}
require Logger
@impl true
def on_insert(data) do
policy = SchemaHelpers.struct_from_params(Policies.Policy, data)
PubSub.Account.broadcast(policy.account_id, {:created, policy})
end
@impl true
# Disable - process as delete
def on_update(%{"disabled_at" => nil}, %{"disabled_at" => disabled_at} = data)
when not is_nil(disabled_at) do
on_delete(data)
end
# Enable - process as insert
def on_update(%{"disabled_at" => disabled_at}, %{"disabled_at" => nil} = data)
when not is_nil(disabled_at) do
on_insert(data)
end
# Soft-delete - process as delete
def on_update(%{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(old_data)
end
# Regular update
def on_update(old_data, data) do
old_policy = SchemaHelpers.struct_from_params(Policies.Policy, old_data)
policy = SchemaHelpers.struct_from_params(Policies.Policy, data)
# Breaking updates
# This is a special case - we need to delete related flows because connectivity has changed
# The Gateway PID will receive flow deletion messages and process them to potentially reject
# access. The client PID (if connected) will toggle the resource deleted/created.
if old_policy.conditions != policy.conditions or
old_policy.actor_group_id != policy.actor_group_id or
old_policy.resource_id != policy.resource_id do
Domain.Flows.delete_flows_for(policy)
end
PubSub.Account.broadcast(policy.account_id, {:updated, old_policy, policy})
end
@impl true
def on_delete(old_data) do
policy = SchemaHelpers.struct_from_params(Policies.Policy, old_data)
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Domain.Flows.delete_flows_for(policy)
PubSub.Account.broadcast(policy.account_id, {:deleted, policy})
end
end

View File

@@ -1,19 +0,0 @@
defmodule Domain.Events.Hooks.ResourceConnections do
@behaviour Domain.Events.Hooks
alias Domain.{SchemaHelpers, Resources, PubSub}
@impl true
def on_insert(data) do
connection = SchemaHelpers.struct_from_params(Resources.Connection, data)
PubSub.Account.broadcast(connection.account_id, {:created, connection})
end
@impl true
def on_update(_old_data, _data), do: :ok
@impl true
def on_delete(old_data) do
connection = SchemaHelpers.struct_from_params(Resources.Connection, old_data)
PubSub.Account.broadcast(connection.account_id, {:deleted, connection})
end
end

View File

@@ -1,52 +0,0 @@
defmodule Domain.Events.Hooks.Resources do
@behaviour Domain.Events.Hooks
alias Domain.{SchemaHelpers, PubSub, Resources}
@impl true
def on_insert(data) do
resource = SchemaHelpers.struct_from_params(Resources.Resource, data)
PubSub.Account.broadcast(resource.account_id, {:created, resource})
end
@impl true
# Soft-delete - process as delete
def on_update(%{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(old_data)
end
# Regular update
def on_update(old_data, data) do
old_resource = SchemaHelpers.struct_from_params(Resources.Resource, old_data)
resource = SchemaHelpers.struct_from_params(Resources.Resource, data)
# Breaking updates
# This is a special case - we need to delete related flows because connectivity has changed
# Gateway _does_ handle resource filter changes so we don't need to delete flows
# for those changes - they're processed by the Gateway channel pid.
# The Gateway channel will process these flow deletions and end up sending reject_access for any
# affected flows. If the client is connected at the time of the update, it will handle this
# by toggling the resource deleted then created.
if old_resource.ip_stack != resource.ip_stack or
old_resource.type != resource.type or
old_resource.address != resource.address do
Domain.Flows.delete_flows_for(resource)
end
PubSub.Account.broadcast(resource.account_id, {:updated, old_resource, resource})
end
@impl true
def on_delete(old_data) do
resource = SchemaHelpers.struct_from_params(Resources.Resource, old_data)
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Domain.Flows.delete_flows_for(resource)
PubSub.Account.broadcast(resource.account_id, {:deleted, resource})
end
end

View File

@@ -1,45 +0,0 @@
defmodule Domain.Events.Hooks.Tokens do
@behaviour Domain.Events.Hooks
alias Domain.{PubSub, Tokens, SchemaHelpers}
@impl true
def on_insert(_data), do: :ok
@impl true
# updates for email tokens have no side effects
def on_update(%{"type" => "email"}, _data), do: :ok
def on_update(_old_data, %{"type" => "email"}), do: :ok
# Soft-delete - process as delete
def on_update(%{"deleted_at" => nil} = old_data, %{"deleted_at" => deleted_at})
when not is_nil(deleted_at) do
on_delete(old_data)
end
# Regular update
def on_update(_old_data, _new_data), do: :ok
@impl true
def on_delete(old_data) do
token = SchemaHelpers.struct_from_params(Tokens.Token, old_data)
# Disconnect all sockets using this token
disconnect_socket(token)
# TODO: Hard delete
# This can be removed upon implementation of hard delete
Domain.Flows.delete_flows_for(token)
PubSub.Account.broadcast(token.account_id, {:deleted, token})
end
# This is a special message that disconnects all sockets using this token,
# such as for LiveViews.
defp disconnect_socket(token) do
topic = Domain.Tokens.socket_id(token.id)
payload = %Phoenix.Socket.Broadcast{topic: topic, event: "disconnect"}
Domain.PubSub.broadcast(topic, payload)
end
end

View File

@@ -20,7 +20,7 @@ defmodule Domain.Flows do
account_id: account_id
},
resource_id,
%Policies.Policy{} = policy,
policy_id,
membership_id,
%Auth.Subject{
account: %{id: account_id},
@@ -42,7 +42,7 @@ defmodule Domain.Flows do
flow =
Flow.Changeset.create(%{
token_id: token_id,
policy_id: policy.id,
policy_id: policy_id,
client_id: client_id,
gateway_id: gateway_id,
resource_id: resource_id,
@@ -71,20 +71,25 @@ defmodule Domain.Flows do
# to the Resource beyond its intended expiration time.
#
# So, we use the minimum of either the policy condition or the origin flow's expiration time.
# This will be much smoother once https://github.com/firezone/firezone/issues/10074 is implemented,
# since we won't need to be so careful about reject_access messages to the gateway.
def reauthorize_flow(%Flow{} = flow) do
with {:ok, client} <- Clients.fetch_client_by_id(flow.client_id, preload: :identity),
# TODO: Hard delete
# We need to ensure token and gateway haven't been deleted after the initial flow was created
# This can be removed after hard-delete since we'll get a DB error if these associations no longer exist
{:ok, _token} <- Tokens.fetch_token_by_id(flow.token_id),
{:ok, _gateway} <- Gateways.fetch_gateway_by_id(flow.gateway_id),
{:ok, gateway} <- Gateways.fetch_gateway_by_id(flow.gateway_id),
# We only want to reauthorize the resource for this gateway if the resource is still connected to its
# gateway_group.
policies when policies != [] <-
Policies.all_policies_for_resource_id_and_actor_id!(
Policies.all_policies_in_gateway_group_for_resource_id_and_actor_id!(
flow.account_id,
gateway.group_id,
flow.resource_id,
client.actor_id
),
{:ok, expires_at, policy} <-
{:ok, policy, expires_at} <-
Policies.longest_conforming_policy_for_client(policies, client, flow.expires_at),
{:ok, membership} <-
Actors.fetch_membership_by_actor_id_and_group_id(
@@ -106,14 +111,20 @@ defmodule Domain.Flows do
expires_at: expires_at
})
|> Repo.insert() do
Logger.info("Reauthorized flow",
old_flow: inspect(flow),
new_flow: inspect(new_flow)
)
{:ok, new_flow}
else
reason ->
Logger.info("Failed to reauthorize flow",
old_flow: inspect(flow),
reason: inspect(reason)
)
{:error, :forbidden}
:error
end
end
@@ -226,6 +237,14 @@ defmodule Domain.Flows do
|> Repo.delete_all()
end
def delete_flows_for(%Domain.Resources.Connection{} = connection) do
Flow.Query.all()
|> Flow.Query.by_account_id(connection.account_id)
|> Flow.Query.by_resource_id(connection.resource_id)
|> Flow.Query.by_gateway_group_id(connection.gateway_group_id)
|> Repo.delete_all()
end
def delete_flows_for(%Domain.Tokens.Token{account_id: nil}) do
# Tokens without an account_id are not associated with any flows. I.e. global relay tokens
{0, []}
@@ -238,14 +257,12 @@ defmodule Domain.Flows do
|> Repo.delete_all()
end
def delete_stale_flows_on_connect(%Clients.Client{} = client, resources)
when is_list(resources) do
authorized_resource_ids = Enum.map(resources, & &1.id)
def delete_stale_flows_on_connect(%Clients.Client{} = client, resource_ids)
when is_list(resource_ids) do
Flow.Query.all()
|> Flow.Query.by_account_id(client.account_id)
|> Flow.Query.by_client_id(client.id)
|> Flow.Query.by_not_in_resource_ids(authorized_resource_ids)
|> Flow.Query.by_not_in_resource_ids(resource_ids)
|> Repo.delete_all()
end
end

View File

@@ -1,6 +1,22 @@
defmodule Domain.Flows.Flow do
use Domain, :schema
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
policy_id: Ecto.UUID.t(),
client_id: Ecto.UUID.t(),
gateway_id: Ecto.UUID.t(),
resource_id: Ecto.UUID.t(),
token_id: Ecto.UUID.t(),
actor_group_membership_id: Ecto.UUID.t(),
account_id: Ecto.UUID.t(),
client_remote_ip: Domain.Types.IP.t(),
client_user_agent: String.t(),
gateway_remote_ip: Domain.Types.IP.t(),
expires_at: DateTime.t(),
inserted_at: DateTime.t()
}
schema "flows" do
belongs_to :policy, Domain.Policies.Policy
belongs_to :client, Domain.Clients.Client

View File

@@ -49,6 +49,12 @@ defmodule Domain.Flows.Flow.Query do
where(queryable, [flows: flows], flows.actor_group_membership_id == ^membership_id)
end
def by_gateway_group_id(queryable, gateway_group_id) do
queryable
|> with_joined_gateway_group()
|> where([gateway_group: gateway_group], gateway_group.id == ^gateway_group_id)
end
def by_identity_id(queryable, identity_id) do
queryable
|> with_joined_client()
@@ -103,6 +109,22 @@ defmodule Domain.Flows.Flow.Query do
end)
end
def with_joined_gateway_group(queryable) do
queryable
|> with_joined_gateway()
|> with_named_binding(:gateway_group, fn queryable, binding ->
join(queryable, :inner, [gateway: gateway], gateway_group in assoc(gateway, :group),
as: ^binding
)
end)
end
def with_joined_gateway(queryable) do
with_named_binding(queryable, :gateway, fn queryable, binding ->
join(queryable, :inner, [flows: flows], gateway in assoc(flows, ^binding), as: ^binding)
end)
end
# Pagination
@impl Domain.Repo.Query

View File

@@ -2,9 +2,11 @@ defmodule Domain.Gateways do
use Supervisor
alias Domain.Accounts.Account
alias Domain.{Repo, Auth, Geo}
alias Domain.{Accounts, Resources, Tokens, Billing}
alias Domain.{Accounts, Cache, Clients, Resources, Tokens, Billing}
alias Domain.Gateways.{Authorizer, Gateway, Group, Presence}
require Logger
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
@@ -264,47 +266,30 @@ defmodule Domain.Gateways do
|> Map.keys()
end
def all_connected_gateways_for_resource(
%Resources.Resource{} = resource,
%Auth.Subject{} = subject,
opts \\ []
def all_compatible_gateways_for_client_and_resource(
%Clients.Client{} = client,
%Cache.Cacheable.Resource{} = resource,
%Auth.Subject{} = subject
) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.connect_gateways_permission()) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
resource_id = Ecto.UUID.load!(resource.id)
connected_gateway_ids =
Presence.Account.list(resource.account_id)
Presence.Account.list(subject.account.id)
|> Map.keys()
gateways =
Gateway.Query.not_deleted()
|> Gateway.Query.by_ids(connected_gateway_ids)
|> Gateway.Query.by_account_id(resource.account_id)
|> Gateway.Query.by_resource_id(resource.id)
|> Gateway.Query.by_account_id(subject.account.id)
|> Gateway.Query.by_resource_id(resource_id)
|> Repo.all()
|> Repo.preload(preload)
|> filter_compatible_gateways(resource, client.last_seen_version)
{:ok, gateways}
end
end
def gateway_can_connect_to_resource?(%Gateway{} = gateway, %Resources.Resource{} = resource) do
connected_gateway_ids =
Presence.Account.list(resource.account_id)
|> Map.keys()
cond do
gateway.id not in connected_gateway_ids ->
false
not Resources.connected?(resource, gateway) ->
false
true ->
true
end
end
def change_gateway(%Gateway{} = gateway, attrs \\ %{}) do
Gateway.Changeset.update(gateway, attrs)
end
@@ -419,4 +404,35 @@ defmodule Domain.Gateways do
_ -> false
end
end
# Filters gateways by the resource type, gateway version, and client version.
# We support gateways running in one less minor and one greater minor version than the client.
# So 1.2.x clients are compatible with 1.1.x and 1.3.x gateways, but not with 1.0.x or 1.4.x.
# The internet resource requires gateway 1.3.0 or greater.
defp filter_compatible_gateways(gateways, resource, client_version) do
case Version.parse(client_version) do
{:ok, version} ->
gateways
|> Enum.filter(fn gateway ->
case Version.parse(gateway.last_seen_version) do
{:ok, gateway_version} ->
Version.match?(gateway_version, ">= #{version.major}.#{version.minor - 1}.0") and
Version.match?(gateway_version, "< #{version.major}.#{version.minor + 2}.0") and
not is_nil(
Resources.adapt_resource_for_version(resource, gateway.last_seen_version)
)
_ ->
Logger.warning("Unable to parse gateway version: #{gateway.last_seen_version}")
false
end
end)
:error ->
Logger.warning("Unable to parse client version: #{client_version}")
[]
end
end
end

View File

@@ -1,6 +1,30 @@
defmodule Domain.Gateways.Gateway do
use Domain, :schema
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
external_id: String.t(),
name: String.t(),
public_key: String.t(),
psk_base: binary(),
ipv4: Domain.Types.IP.t(),
ipv6: Domain.Types.IP.t(),
last_seen_user_agent: String.t(),
last_seen_remote_ip: Domain.Types.IP.t(),
last_seen_remote_ip_location_region: String.t(),
last_seen_remote_ip_location_city: String.t(),
last_seen_remote_ip_location_lat: float(),
last_seen_remote_ip_location_lon: float(),
last_seen_version: String.t(),
last_seen_at: DateTime.t(),
online?: boolean(),
account_id: Ecto.UUID.t(),
group_id: Ecto.UUID.t(),
deleted_at: DateTime.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
schema "gateways" do
field :external_id, :string

View File

@@ -1,6 +1,18 @@
defmodule Domain.Gateways.Group do
use Domain, :schema
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
name: String.t(),
managed_by: :account | :system,
account_id: Ecto.UUID.t(),
created_by: :actor | :identity | :system,
created_by_subject: map(),
deleted_at: DateTime.t() | nil,
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
schema "gateway_groups" do
field :name, :string

View File

@@ -1,6 +1,6 @@
defmodule Domain.Policies do
alias Domain.Repo
alias Domain.{Auth, Actors, Clients, Resources}
alias Domain.{Auth, Actors, Cache.Cacheable, Clients, Resources}
alias Domain.Policies.{Authorizer, Policy, Condition}
def fetch_policy_by_id(id, %Auth.Subject{} = subject, opts \\ []) do
@@ -58,10 +58,9 @@ defmodule Domain.Policies do
end
end
def all_policies_for_actor!(%Actors.Actor{} = actor) do
def all_policies_for_actor_id!(actor_id) do
Policy.Query.not_disabled()
|> Policy.Query.by_account_id(actor.account_id)
|> Policy.Query.by_actor_id(actor.id)
|> Policy.Query.by_actor_id(actor_id)
|> Policy.Query.with_preloaded_resource_gateway_groups()
|> Repo.all()
end
@@ -74,10 +73,16 @@ defmodule Domain.Policies do
|> Repo.all()
end
def all_policies_for_resource_id_and_actor_id!(account_id, resource_id, actor_id) do
def all_policies_in_gateway_group_for_resource_id_and_actor_id!(
account_id,
gateway_group_id,
resource_id,
actor_id
) do
Policy.Query.not_disabled()
|> Policy.Query.by_account_id(account_id)
|> Policy.Query.by_resource_id(resource_id)
|> Policy.Query.by_gateway_group_id(gateway_group_id)
|> Policy.Query.by_actor_id(actor_id)
|> Repo.all()
end
@@ -218,17 +223,36 @@ defmodule Domain.Policies do
succeeded
|> Enum.max_by(fn {expires_at, _policy} -> expires_at || @infinity end)
{:ok, min_expires_at(expires_at, token_expires_at), policy}
{:ok, policy, min_expires_at(expires_at, token_expires_at)}
end
end
# All client tokens have *some* expiration
def min_expires_at(nil, nil),
def ensure_client_conforms_policy_conditions(
%Clients.Client{} = client,
%__MODULE__.Policy{} = policy
) do
ensure_client_conforms_policy_conditions(client, Cacheable.to_cache(policy))
end
def ensure_client_conforms_policy_conditions(
%Clients.Client{} = client,
%Cacheable.Policy{} = policy
) do
case Condition.Evaluator.ensure_conforms(policy.conditions, client) do
{:ok, expires_at} ->
{:ok, expires_at}
{:error, violated_properties} ->
{:error, {:forbidden, violated_properties: violated_properties}}
end
end
defp min_expires_at(nil, nil),
do: raise("Both policy_expires_at and token_expires_at cannot be nil")
def min_expires_at(nil, token_expires_at), do: token_expires_at
defp min_expires_at(nil, token_expires_at), do: token_expires_at
def min_expires_at(%DateTime{} = policy_expires_at, %DateTime{} = token_expires_at) do
defp min_expires_at(%DateTime{} = policy_expires_at, %DateTime{} = token_expires_at) do
if DateTime.compare(policy_expires_at, token_expires_at) == :lt do
policy_expires_at
else
@@ -243,14 +267,4 @@ defmodule Domain.Policies do
{:error, :unauthorized}
end
end
defp ensure_client_conforms_policy_conditions(%Clients.Client{} = client, %Policy{} = policy) do
case Condition.Evaluator.ensure_conforms(policy.conditions, client) do
{:ok, expires_at} ->
{:ok, expires_at}
{:error, violated_properties} ->
{:error, {:forbidden, violated_properties: violated_properties}}
end
end
end

View File

@@ -1,6 +1,25 @@
defmodule Domain.Policies.Condition do
use Domain, :schema
@type t :: %__MODULE__{
property:
:remote_ip_location_region
| :remote_ip
| :provider_id
| :current_utc_datetime
| :client_verified,
operator:
:contains
| :does_not_contain
| :is_in
| :is_not_in
| :is_in_day_of_week_time_ranges
| :is_in_cidr
| :is_not_in_cidr
| :is,
values: [String.t()]
}
@primary_key false
embedded_schema do
field :property, Ecto.Enum, values: ~w[

View File

@@ -1,6 +1,5 @@
defmodule Domain.Policies.Condition.Evaluator do
alias Domain.Clients
alias Domain.Policies.Condition
@days_of_week ~w[M T W R F S U]
@@ -35,7 +34,7 @@ defmodule Domain.Policies.Condition.Evaluator do
do: Enum.min([expires_at, min_expires_at], DateTime)
def fetch_conformation_expiration(
%Condition{property: :remote_ip_location_region, operator: :is_in, values: values},
%{property: :remote_ip_location_region, operator: :is_in, values: values},
%Clients.Client{} = client
) do
if client.last_seen_remote_ip_location_region in values do
@@ -46,7 +45,7 @@ defmodule Domain.Policies.Condition.Evaluator do
end
def fetch_conformation_expiration(
%Condition{property: :remote_ip_location_region, operator: :is_not_in, values: values},
%{property: :remote_ip_location_region, operator: :is_not_in, values: values},
%Clients.Client{} = client
) do
if client.last_seen_remote_ip_location_region in values do
@@ -57,7 +56,7 @@ defmodule Domain.Policies.Condition.Evaluator do
end
def fetch_conformation_expiration(
%Condition{property: :remote_ip, operator: :is_in_cidr, values: values},
%{property: :remote_ip, operator: :is_in_cidr, values: values},
%Clients.Client{} = client
) do
Enum.reduce_while(values, :error, fn cidr, :error ->
@@ -73,7 +72,7 @@ defmodule Domain.Policies.Condition.Evaluator do
end
def fetch_conformation_expiration(
%Condition{property: :remote_ip, operator: :is_not_in_cidr, values: values},
%{property: :remote_ip, operator: :is_not_in_cidr, values: values},
%Clients.Client{} = client
) do
Enum.reduce_while(values, {:ok, nil}, fn cidr, {:ok, nil} ->
@@ -89,7 +88,7 @@ defmodule Domain.Policies.Condition.Evaluator do
end
def fetch_conformation_expiration(
%Condition{property: :provider_id, operator: :is_in, values: values},
%{property: :provider_id, operator: :is_in, values: values},
%Clients.Client{} = client
) do
if client.identity.provider_id in values do
@@ -100,7 +99,7 @@ defmodule Domain.Policies.Condition.Evaluator do
end
def fetch_conformation_expiration(
%Condition{property: :provider_id, operator: :is_not_in, values: values},
%{property: :provider_id, operator: :is_not_in, values: values},
%Clients.Client{} = client
) do
if client.identity.provider_id in values do
@@ -111,7 +110,7 @@ defmodule Domain.Policies.Condition.Evaluator do
end
def fetch_conformation_expiration(
%Condition{
%{
property: :client_verified,
operator: :is,
values: ["true"]
@@ -126,7 +125,7 @@ defmodule Domain.Policies.Condition.Evaluator do
end
def fetch_conformation_expiration(
%Condition{
%{
property: :client_verified,
operator: :is,
values: _other
@@ -137,7 +136,7 @@ defmodule Domain.Policies.Condition.Evaluator do
end
def fetch_conformation_expiration(
%Condition{
%{
property: :current_utc_datetime,
operator: :is_in_day_of_week_time_ranges,
values: values

View File

@@ -1,6 +1,23 @@
defmodule Domain.Policies.Policy do
use Domain, :schema
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
persistent_id: Ecto.UUID.t(),
description: String.t() | nil,
conditions: [Domain.Policies.Condition.t()],
actor_group_id: Ecto.UUID.t(),
resource_id: Ecto.UUID.t(),
account_id: Ecto.UUID.t(),
created_by: :actor | :identity,
created_by_subject: map(),
replaced_by_policy_id: Ecto.UUID.t() | nil,
disabled_at: DateTime.t() | nil,
deleted_at: DateTime.t() | nil,
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
schema "policies" do
field :persistent_id, Ecto.UUID

View File

@@ -65,6 +65,16 @@ defmodule Domain.Policies.Policy.Query do
|> where([memberships: memberships], memberships.actor_id == ^actor_id)
end
def by_gateway_group_id(queryable, gateway_group_id) do
queryable
|> with_joined_resource()
|> with_joined_resource_connections()
|> where(
[resource_connections: resource_connections],
resource_connections.gateway_group_id == ^gateway_group_id
)
end
def count_by_resource_id(queryable) do
queryable
|> group_by([policies: policies], policies.resource_id)
@@ -100,6 +110,15 @@ defmodule Domain.Policies.Policy.Query do
end)
end
def with_joined_resource_connections(queryable) do
with_named_binding(queryable, :resource_connections, fn queryable, binding ->
join(queryable, :inner, [resource: resource], rc in Domain.Resources.Connection,
on: rc.resource_id == resource.id,
as: ^binding
)
end)
end
def with_joined_memberships(queryable) do
queryable
|> with_joined_actor_group()

View File

@@ -51,12 +51,6 @@ defmodule Domain.PubSub do
|> Domain.PubSub.subscribe()
end
def broadcast(nil, payload) do
Logger.warning("Broadcasting to nil account_id is not allowed",
payload: inspect(payload)
)
end
def broadcast(account_id, payload) do
account_id
|> topic()

View File

@@ -104,7 +104,7 @@ defmodule Domain.Relays do
Group.Changeset.delete(group)
end,
# TODO: WAL
# TODO: Remove self-hosted relays
after_commit: &disconnect_relays_in_group/1
)
end
@@ -309,7 +309,7 @@ defmodule Domain.Relays do
|> Authorizer.for_subject(subject)
|> Repo.fetch_and_update(Relay.Query,
with: &Relay.Changeset.delete/1,
# TODO: WAL
# TODO: Remove self-hosted relays
after_commit: &disconnect_relay/1
)
end
@@ -344,8 +344,7 @@ defmodule Domain.Relays do
|> Enum.map(&Enum.random(elem(&1, 1)))
end
# TODO: WAL
# Refactor to use new conventions
# TODO: Refactor to use new conventions
def connect_relay(%Relay{} = relay, secret) do
with {:ok, _} <-
Presence.track(self(), group_presence_topic(relay.group_id), relay.id, %{}),
@@ -364,8 +363,7 @@ defmodule Domain.Relays do
### Presence
# TODO: WAL
# Move these to Presence module
# TODO: Move these to Presence module
defp presence_topic(relay_or_id),
do: "presences:#{relay_topic(relay_or_id)}"
@@ -387,8 +385,7 @@ defmodule Domain.Relays do
### PubSub
# TODO: WAL
# Move these to PubSub module
# TODO: Move these to PubSub module
defp relay_topic(%Relay{} = relay), do: relay_topic(relay.id)
defp relay_topic(relay_id), do: "relays:#{relay_id}"

View File

@@ -249,8 +249,6 @@ defmodule Domain.Replication.Decoder do
}
end
# TODO: WAL
# Verify this is correct with real data from Postgres
defp decode_message_impl(<<"O", lsn::binary-8, name::binary>>) do
%Origin{
origin_commit_lsn: decode_lsn(lsn),
@@ -264,8 +262,6 @@ defmodule Domain.Replication.Decoder do
| [name | [<<replica_identity::binary-1, _number_of_columns::integer-16, columns::binary>>]]
] = String.split(rest, <<0>>, parts: 3)
# TODO: WAL
# Handle case where pg_catalog is blank, we should still return the schema as pg_catalog
friendly_replica_identity =
case replica_identity do
"d" -> :default

View File

@@ -90,6 +90,13 @@ defmodule Domain.Resources do
end
end
def fetch_all_resources_by_ids(ids) do
Resource.Query.not_deleted()
|> Resource.Query.by_id({:in, ids})
|> Repo.all()
|> Repo.preload(:gateway_groups)
end
def fetch_resource_by_id_or_persistent_id!(id) do
if Repo.valid_uuid?(id) do
Resource.Query.not_deleted()
@@ -305,37 +312,61 @@ defmodule Domain.Resources do
end
def connected?(
%Resource{account_id: account_id} = resource,
%Gateways.Gateway{account_id: account_id} = gateway
resource_id,
%Gateways.Gateway{} = gateway
) do
Connection.Query.by_resource_id(resource.id)
Connection.Query.by_resource_id(resource_id)
|> Connection.Query.by_gateway_group_id(gateway.group_id)
|> Repo.exists?()
end
@doc false
# This is the code that will be removed in future version of Firezone (in 1.3-1.4)
# and is reused to prevent breaking changes
def map_resource_address(address, acc \\ "")
@doc """
This does two things:
1. Filters out resources that are not compatible with the given client or gateway version.
2. Converts DNS resource addresses back to the pre-1.2.0 format if the client or gateway version is < 1.2.0.
"""
def adapt_resource_for_version(resource, client_or_gateway_version) do
cond do
# internet resources require client and gateway >= 1.3.0
resource.type == :internet and Version.match?(client_or_gateway_version, "< 1.3.0") ->
nil
def map_resource_address(["*", "*" | rest], ""),
# non-internet resource, pass as-is
Version.match?(client_or_gateway_version, ">= 1.2.0") ->
resource
# we need convert dns resource addresses back to pre-1.2.0 format
true ->
resource.address
|> String.codepoints()
|> map_resource_address()
|> case do
{:cont, address} -> %{resource | address: address}
:drop -> nil
end
end
end
defp map_resource_address(address, acc \\ "")
defp map_resource_address(["*", "*" | rest], ""),
do: map_resource_address(rest, "*")
def map_resource_address(["*", "*" | _rest], _acc),
defp map_resource_address(["*", "*" | _rest], _acc),
do: :drop
def map_resource_address(["*" | rest], ""),
defp map_resource_address(["*" | rest], ""),
do: map_resource_address(rest, "?")
def map_resource_address(["*" | _rest], _acc),
defp map_resource_address(["*" | _rest], _acc),
do: :drop
def map_resource_address(["?" | _rest], _acc),
defp map_resource_address(["?" | _rest], _acc),
do: :drop
def map_resource_address([char | rest], acc),
defp map_resource_address([char | rest], acc),
do: map_resource_address(rest, acc <> char)
def map_resource_address([], acc),
defp map_resource_address([], acc),
do: {:cont, acc}
end

View File

@@ -1,6 +1,14 @@
defmodule Domain.Resources.Connection do
use Domain, :schema
@type t :: %__MODULE__{
resource_id: Ecto.UUID.t(),
gateway_group_id: Ecto.UUID.t(),
created_by: :actor | :identity | :system,
created_by_subject: map(),
account_id: Ecto.UUID.t()
}
@primary_key false
schema "resource_connections" do
belongs_to :resource, Domain.Resources.Resource, primary_key: true

View File

@@ -1,6 +1,29 @@
defmodule Domain.Resources.Resource do
use Domain, :schema
@type filter :: %{
protocol: :tcp | :udp | :icmp,
ports: [Domain.Types.Int4Range.t()]
}
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
persistent_id: Ecto.UUID.t() | nil,
address: String.t(),
address_description: String.t() | nil,
name: String.t(),
type: :cidr | :ip | :dns | :internet,
ip_stack: :ipv4_only | :ipv6_only | :dual,
filters: [filter()],
account_id: Ecto.UUID.t(),
created_by: String.t(),
created_by_subject: map(),
replaced_by_resource_id: Ecto.UUID.t() | nil,
deleted_at: DateTime.t() | nil,
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
schema "resources" do
field :persistent_id, Ecto.UUID

View File

@@ -9,6 +9,8 @@ defmodule Domain.Types.Int4Range do
@format_error "bad format"
@cast_error "lower value cannot be higher than upper value"
@type t :: [String.t()]
def type, do: :int4range
def cast(str) when is_binary(str) do

View File

@@ -5,6 +5,11 @@ defmodule Domain.Types.IP do
"""
@behaviour Ecto.Type
@type t :: %Postgrex.INET{
address: tuple(),
netmask: nil | integer()
}
def type, do: :inet
def embed_as(_), do: :self

View File

@@ -1210,7 +1210,7 @@ defmodule Domain.Repo.Seeds do
user_iphone,
gateway1,
cidr_resource.id,
policy,
policy.id,
membership.id,
unprivileged_subject,
unprivileged_subject.expires_at

View File

@@ -1,11 +1,11 @@
defmodule Domain.Events.Hooks.AccountsTest do
defmodule Domain.Changes.Hooks.AccountsTest do
use Domain.DataCase, async: true
alias Domain.Accounts
import Domain.Events.Hooks.Accounts
alias Domain.{Accounts, Changes.Change, PubSub}
import Domain.Changes.Hooks.Accounts
describe "insert/1" do
test "returns :ok" do
assert :ok == on_insert(%{})
test "returns :ok for empty data" do
assert :ok == on_insert(0, %{})
end
end
@@ -13,7 +13,7 @@ defmodule Domain.Events.Hooks.AccountsTest do
test "sends delete when account is disabled" do
account_id = "00000000-0000-0000-0000-000000000001"
:ok = Domain.PubSub.Account.subscribe(account_id)
:ok = PubSub.Account.subscribe(account_id)
old_data = %{
"id" => account_id,
@@ -25,15 +25,15 @@ defmodule Domain.Events.Hooks.AccountsTest do
"disabled_at" => "2023-10-01T00:00:00Z"
}
assert :ok == on_update(old_data, data)
assert_receive {:deleted, %Accounts.Account{} = account}
assert :ok == on_update(0, old_data, data)
assert_receive %Change{op: :delete, old_struct: %Accounts.Account{} = account, lsn: 0}
assert account.id == account_id
end
test "sends delete when soft-deleted" do
account_id = "00000000-0000-0000-0000-000000000002"
:ok = Domain.PubSub.Account.subscribe(account_id)
:ok = PubSub.Account.subscribe(account_id)
old_data = %{
"id" => account_id,
@@ -45,8 +45,8 @@ defmodule Domain.Events.Hooks.AccountsTest do
"deleted_at" => "2023-10-01T00:00:00Z"
}
assert :ok == on_update(old_data, data)
assert_receive {:deleted, %Accounts.Account{} = account}
assert :ok == on_update(0, old_data, data)
assert_receive %Change{op: :delete, old_struct: %Accounts.Account{} = account, lsn: 0}
assert account.id == account_id
end
@@ -55,15 +55,15 @@ defmodule Domain.Events.Hooks.AccountsTest do
describe "delete/1" do
test "delete broadcasts deleted account" do
account_id = "00000000-0000-0000-0000-000000000003"
:ok = Domain.PubSub.Account.subscribe(account_id)
:ok = PubSub.Account.subscribe(account_id)
old_data = %{
"id" => account_id,
"deleted_at" => "2023-10-01T00:00:00Z"
}
assert :ok == on_delete(old_data)
assert_receive {:deleted, %Accounts.Account{} = account}
assert :ok == on_delete(0, old_data)
assert_receive %Change{op: :delete, old_struct: %Accounts.Account{} = account, lsn: 0}
assert account.id == account_id
assert account.deleted_at == ~U[2023-10-01 00:00:00.000000Z]
end
@@ -77,7 +77,7 @@ defmodule Domain.Events.Hooks.AccountsTest do
"deleted_at" => "2023-10-01T00:00:00Z"
}
assert :ok == on_delete(old_data)
assert :ok == on_delete(0, old_data)
assert Repo.get_by(Domain.Flows.Flow, id: flow.id) == nil
end
end

View File

@@ -1,8 +1,7 @@
defmodule Domain.Events.Hooks.ActorGroupMembershipsTest do
defmodule Domain.Changes.Hooks.ActorGroupMembershipsTest do
use API.ChannelCase, async: true
import Domain.Events.Hooks.ActorGroupMemberships
alias Domain.Actors
alias Domain.PubSub
import Domain.Changes.Hooks.ActorGroupMemberships
alias Domain.{Actors, Changes.Change, Flows, PubSub}
describe "insert/1" do
test "broadcasts membership" do
@@ -18,8 +17,8 @@ defmodule Domain.Events.Hooks.ActorGroupMembershipsTest do
"group_id" => group_id
}
assert :ok == on_insert(data)
assert_receive {:created, %Actors.Membership{} = membership}
assert :ok == on_insert(0, data)
assert_receive %Change{op: :insert, struct: %Actors.Membership{} = membership, lsn: 0}
assert membership.account_id == account_id
assert membership.actor_id == actor_id
assert membership.group_id == group_id
@@ -28,7 +27,7 @@ defmodule Domain.Events.Hooks.ActorGroupMembershipsTest do
describe "update/2" do
test "returns :ok" do
assert :ok == on_update(%{}, %{})
assert :ok == on_update(0, %{}, %{})
end
end
@@ -60,9 +59,9 @@ defmodule Domain.Events.Hooks.ActorGroupMembershipsTest do
"group_id" => "00000000-0000-0000-0000-000000000003"
}
assert :ok == on_delete(old_data)
assert :ok == on_delete(0, old_data)
assert_receive {:deleted, %Actors.Membership{} = membership}
assert_receive %Change{op: :delete, old_struct: %Actors.Membership{} = membership, lsn: 0}
assert membership.id == "00000000-0000-0000-0000-000000000000"
assert membership.account_id == "00000000-0000-0000-0000-000000000001"
assert membership.actor_id == "00000000-0000-0000-0000-000000000002"
@@ -80,10 +79,10 @@ defmodule Domain.Events.Hooks.ActorGroupMembershipsTest do
"group_id" => membership.group_id
}
assert ^flow = Repo.get_by(Domain.Flows.Flow, actor_group_membership_id: membership.id)
assert :ok == on_delete(old_data)
assert nil == Repo.get_by(Domain.Flows.Flow, actor_group_membership_id: membership.id)
assert ^unrelated_flow = Repo.get_by(Domain.Flows.Flow, id: unrelated_flow.id)
assert ^flow = Repo.get_by(Flows.Flow, actor_group_membership_id: membership.id)
assert :ok == on_delete(0, old_data)
assert nil == Repo.get_by(Flows.Flow, actor_group_membership_id: membership.id)
assert ^unrelated_flow = Repo.get_by(Flows.Flow, id: unrelated_flow.id)
end
end
end

View File

@@ -1,11 +1,11 @@
defmodule Domain.Events.Hooks.ClientsTest do
defmodule Domain.Changes.Hooks.ClientsTest do
use Domain.DataCase, async: true
import Domain.Events.Hooks.Clients
alias Domain.{Clients, PubSub}
import Domain.Changes.Hooks.Clients
alias Domain.{Changes.Change, Clients, Flows, PubSub}
describe "insert/1" do
test "returns :ok" do
assert :ok == on_insert(%{})
assert :ok == on_insert(0, %{})
end
end
@@ -22,8 +22,8 @@ defmodule Domain.Events.Hooks.ClientsTest do
"account_id" => client.account_id
}
assert :ok == on_update(old_data, data)
assert_receive {:deleted, %Clients.Client{} = deleted_client}
assert :ok == on_update(0, old_data, data)
assert_receive %Change{op: :delete, old_struct: %Clients.Client{} = deleted_client, lsn: 0}
assert deleted_client.id == client.id
end
@@ -35,9 +35,15 @@ defmodule Domain.Events.Hooks.ClientsTest do
old_data = %{"id" => client.id, "name" => "Old Name", "account_id" => client.account_id}
data = %{"id" => client.id, "name" => "New Name", "account_id" => client.account_id}
assert :ok == on_update(old_data, data)
assert :ok == on_update(0, old_data, data)
assert_receive %Change{
op: :update,
old_struct: %Clients.Client{} = old_client,
struct: %Clients.Client{} = new_client,
lsn: 0
}
assert_receive {:updated, %Clients.Client{} = old_client, %Clients.Client{} = new_client}
assert old_client.name == "Old Name"
assert new_client.name == "New Name"
assert new_client.id == client.id
@@ -57,11 +63,18 @@ defmodule Domain.Events.Hooks.ClientsTest do
data = %{"id" => client.id, "verified_at" => nil, "account_id" => client.account_id}
assert flow = Fixtures.Flows.create_flow(client: client, account: account)
assert :ok == on_update(old_data, data)
assert_receive {:updated, %Clients.Client{}, %Clients.Client{} = new_client}
assert :ok == on_update(0, old_data, data)
assert_receive %Change{
op: :update,
old_struct: %Clients.Client{},
struct: %Clients.Client{} = new_client,
lsn: 0
}
assert is_nil(new_client.verified_at)
assert new_client.id == client.id
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
refute Repo.get_by(Flows.Flow, id: flow.id)
end
end
@@ -73,8 +86,8 @@ defmodule Domain.Events.Hooks.ClientsTest do
old_data = %{"id" => client.id, "account_id" => client.account_id}
assert :ok == on_delete(old_data)
assert_receive {:deleted, %Clients.Client{} = deleted_client}
assert :ok == on_delete(0, old_data)
assert_receive %Change{op: :delete, old_struct: %Clients.Client{} = deleted_client, lsn: 0}
assert deleted_client.id == client.id
end
@@ -85,8 +98,8 @@ defmodule Domain.Events.Hooks.ClientsTest do
old_data = %{"id" => client.id, "account_id" => client.account_id}
assert flow = Fixtures.Flows.create_flow(client: client, account: account)
assert :ok == on_delete(old_data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
assert :ok == on_delete(0, old_data)
refute Repo.get_by(Flows.Flow, id: flow.id)
end
end
end

View File

@@ -1,23 +1,23 @@
defmodule Domain.Events.Hooks.FlowsTest do
defmodule Domain.Changes.Hooks.FlowsTest do
use Domain.DataCase, async: true
import Domain.Events.Hooks.Flows
alias Domain.Flows
import Domain.Changes.Hooks.Flows
alias Domain.{Changes.Change, Flows, PubSub}
describe "insert/1" do
test "returns :ok" do
assert :ok == on_insert(%{})
assert :ok == on_insert(0, %{})
end
end
describe "update/2" do
test "returns :ok" do
assert :ok == on_update(%{}, %{})
assert :ok == on_update(0, %{}, %{})
end
end
describe "delete/1" do
test "delete broadcasts deleted flow" do
:ok = Domain.PubSub.Account.subscribe("00000000-0000-0000-0000-000000000000")
:ok = PubSub.Account.subscribe("00000000-0000-0000-0000-000000000000")
old_data = %{
"id" => "00000000-0000-0000-0000-000000000001",
@@ -31,9 +31,9 @@ defmodule Domain.Events.Hooks.FlowsTest do
"inserted_at" => "2023-01-01T00:00:00Z"
}
assert :ok == on_delete(old_data)
assert :ok == on_delete(0, old_data)
assert_receive {:deleted, %Flows.Flow{} = flow}
assert_receive %Change{op: :delete, old_struct: %Flows.Flow{} = flow, lsn: 0}
assert flow.id == "00000000-0000-0000-0000-000000000001"
assert flow.account_id == "00000000-0000-0000-0000-000000000000"

View File

@@ -1,11 +1,11 @@
defmodule Domain.Events.Hooks.GatewayGroupsTest do
defmodule Domain.Changes.Hooks.GatewayGroupsTest do
use ExUnit.Case, async: true
import Domain.Events.Hooks.GatewayGroups
alias Domain.Gateways
import Domain.Changes.Hooks.GatewayGroups
alias Domain.{Changes.Change, Gateways, PubSub}
describe "insert/1" do
test "returns :ok" do
assert :ok == on_insert(%{})
assert :ok == on_insert(0, %{})
end
end
@@ -14,13 +14,13 @@ defmodule Domain.Events.Hooks.GatewayGroupsTest do
# Deleting a gateway group will delete the associated gateways which
# handles all side effects we need to handle, including removing any
# resources from the client's resource list.
assert :ok = on_delete(%{})
assert :ok = on_delete(0, %{})
end
test "broadcasts updated gateway group" do
account_id = "00000000-0000-0000-0000-000000000000"
:ok = Domain.PubSub.Account.subscribe(account_id)
:ok = PubSub.Account.subscribe(account_id)
old_data = %{
"id" => "00000000-0000-0000-0000-000000000001",
@@ -31,9 +31,15 @@ defmodule Domain.Events.Hooks.GatewayGroupsTest do
data = Map.put(old_data, "name", "Updated Gateway Group")
assert :ok == on_update(old_data, data)
assert :ok == on_update(0, old_data, data)
assert_receive %Change{
op: :update,
old_struct: %Gateways.Group{} = old_group,
struct: %Gateways.Group{} = new_group,
lsn: 0
}
assert_receive {:updated, %Gateways.Group{} = old_group, %Gateways.Group{} = new_group}
assert old_group.id == old_data["id"]
assert new_group.name == data["name"]
end
@@ -44,7 +50,7 @@ defmodule Domain.Events.Hooks.GatewayGroupsTest do
# Deleting a gateway group will delete the associated gateways which
# handles all side effects we need to handle, including removing any
# resources from the client's resource list.
assert :ok = on_delete(%{})
assert :ok = on_delete(0, %{})
end
end
end

View File

@@ -1,10 +1,11 @@
defmodule Domain.Events.Hooks.GatewaysTest do
defmodule Domain.Changes.Hooks.GatewaysTest do
use Domain.DataCase, async: true
import Domain.Events.Hooks.Gateways
import Domain.Changes.Hooks.Gateways
alias Domain.{Changes.Change, Gateways, PubSub}
describe "insert/1" do
test "returns :ok" do
assert :ok == on_insert(%{})
assert :ok == on_insert(0, %{})
end
end
@@ -13,14 +14,19 @@ defmodule Domain.Events.Hooks.GatewaysTest do
account = Fixtures.Accounts.create_account()
gateway = Fixtures.Gateways.create_gateway(account: account)
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = PubSub.Account.subscribe(account.id)
old_data = %{"id" => gateway.id, "deleted_at" => nil, "account_id" => account.id}
data = Map.put(old_data, "deleted_at", "2023-01-01T00:00:00Z")
assert :ok = on_update(old_data, data)
assert :ok = on_update(0, old_data, data)
assert_receive %Change{
op: :delete,
old_struct: %Gateways.Gateway{} = deleted_gateway,
lsn: 0
}
assert_receive {:deleted, %Domain.Gateways.Gateway{} = deleted_gateway}
assert deleted_gateway.id == gateway.id
end
@@ -32,12 +38,12 @@ defmodule Domain.Events.Hooks.GatewaysTest do
data = Map.put(old_data, "deleted_at", "2023-01-01T00:00:00Z")
assert flow = Fixtures.Flows.create_flow(gateway: gateway, account: account)
assert :ok = on_update(old_data, data)
assert :ok = on_update(0, old_data, data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
end
test "update returns :ok" do
assert :ok = on_update(%{}, %{})
assert :ok = on_update(0, %{}, %{})
end
end
@@ -46,7 +52,7 @@ defmodule Domain.Events.Hooks.GatewaysTest do
account = Fixtures.Accounts.create_account()
gateway = Fixtures.Gateways.create_gateway(account: account)
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = PubSub.Account.subscribe(account.id)
old_data = %{
"id" => gateway.id,
@@ -55,9 +61,14 @@ defmodule Domain.Events.Hooks.GatewaysTest do
"deleted_at" => nil
}
assert :ok = on_delete(old_data)
assert :ok = on_delete(0, old_data)
assert_receive %Change{
op: :delete,
old_struct: %Gateways.Gateway{} = deleted_gateway,
lsn: 0
}
assert_receive {:deleted, %Domain.Gateways.Gateway{} = deleted_gateway}
assert deleted_gateway.id == gateway.id
end
@@ -68,7 +79,7 @@ defmodule Domain.Events.Hooks.GatewaysTest do
old_data = %{"id" => gateway.id, "account_id" => account.id, "deleted_at" => nil}
assert flow = Fixtures.Flows.create_flow(gateway: gateway, account: account)
assert :ok = on_delete(old_data)
assert :ok = on_delete(0, old_data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
end
end

View File

@@ -1,7 +1,7 @@
defmodule Domain.Events.Hooks.PoliciesTest do
defmodule Domain.Changes.Hooks.PoliciesTest do
use Domain.DataCase, async: true
import Domain.Events.Hooks.Policies
alias Domain.{Policies, PubSub}
import Domain.Changes.Hooks.Policies
alias Domain.{Changes.Change, Policies, PubSub}
describe "insert/1" do
test "broadcasts created policy" do
@@ -18,8 +18,8 @@ defmodule Domain.Events.Hooks.PoliciesTest do
"deleted_at" => nil
}
assert :ok == on_insert(data)
assert_receive {:created, %Policies.Policy{} = policy}
assert :ok == on_insert(0, data)
assert_receive %Change{op: :insert, struct: %Policies.Policy{} = policy, lsn: 0}
assert policy.id == data["id"]
assert policy.account_id == data["account_id"]
@@ -49,8 +49,13 @@ defmodule Domain.Events.Hooks.PoliciesTest do
# Create a flow that should be deleted
flow = Fixtures.Flows.create_flow(policy: policy, resource: resource, account: account)
assert :ok == on_update(old_data, data)
assert_receive {:deleted, %Policies.Policy{} = broadcasted_policy}
assert :ok == on_update(0, old_data, data)
assert_receive %Change{
op: :delete,
old_struct: %Policies.Policy{} = broadcasted_policy,
lsn: 0
}
assert broadcasted_policy.id == data["id"]
assert broadcasted_policy.account_id == data["account_id"]
@@ -75,8 +80,8 @@ defmodule Domain.Events.Hooks.PoliciesTest do
data = Map.put(old_data, "disabled_at", nil)
assert :ok == on_update(old_data, data)
assert_receive {:created, %Policies.Policy{} = policy}
assert :ok == on_update(0, old_data, data)
assert_receive %Change{op: :insert, struct: %Policies.Policy{} = policy, lsn: 0}
assert policy.id == data["id"]
assert policy.account_id == data["account_id"]
@@ -100,8 +105,8 @@ defmodule Domain.Events.Hooks.PoliciesTest do
data = Map.put(old_data, "deleted_at", "2023-10-01T00:00:00Z")
assert :ok == on_update(old_data, data)
assert_receive {:deleted, %Policies.Policy{} = policy}
assert :ok == on_update(0, old_data, data)
assert_receive %Change{op: :delete, old_struct: %Policies.Policy{} = policy, lsn: 0}
assert policy.id == old_data["id"]
assert policy.account_id == old_data["account_id"]
@@ -136,7 +141,7 @@ defmodule Domain.Events.Hooks.PoliciesTest do
account: account
)
assert :ok = on_update(old_data, data)
assert :ok = on_update(0, old_data, data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
end
@@ -157,8 +162,15 @@ defmodule Domain.Events.Hooks.PoliciesTest do
data = Map.put(old_data, "description", "Updated description")
assert :ok == on_update(old_data, data)
assert_receive {:updated, %Policies.Policy{} = old_policy, %Policies.Policy{} = new_policy}
assert :ok == on_update(0, old_data, data)
assert_receive %Change{
op: :update,
old_struct: %Policies.Policy{} = old_policy,
struct: %Policies.Policy{} = new_policy,
lsn: 0
}
assert old_policy.id == old_data["id"]
assert new_policy.description == data["description"]
assert new_policy.account_id == old_data["account_id"]
@@ -186,7 +198,7 @@ defmodule Domain.Events.Hooks.PoliciesTest do
account: account
)
assert :ok = on_update(old_data, data)
assert :ok = on_update(0, old_data, data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
end
@@ -206,7 +218,7 @@ defmodule Domain.Events.Hooks.PoliciesTest do
assert flow = Fixtures.Flows.create_flow(policy: policy, account: account)
assert :ok = on_update(old_data, data)
assert :ok = on_update(0, old_data, data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
end
@@ -232,7 +244,7 @@ defmodule Domain.Events.Hooks.PoliciesTest do
assert flow = Fixtures.Flows.create_flow(policy: policy, account: account)
assert :ok = on_update(old_data, data)
assert :ok = on_update(0, old_data, data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
end
end
@@ -250,8 +262,13 @@ defmodule Domain.Events.Hooks.PoliciesTest do
"resource_id" => policy.resource_id
}
assert :ok == on_delete(old_data)
assert_receive {:deleted, %Policies.Policy{} = policy}
assert :ok == on_delete(0, old_data)
assert_receive %Change{
op: :delete,
old_struct: %Policies.Policy{} = policy,
lsn: 0
}
assert policy.id == old_data["id"]
assert policy.account_id == old_data["account_id"]
@@ -273,7 +290,7 @@ defmodule Domain.Events.Hooks.PoliciesTest do
assert flow = Fixtures.Flows.create_flow(policy: policy, account: account)
assert :ok = on_delete(old_data)
assert :ok = on_delete(0, old_data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
end
end

View File

@@ -1,6 +1,7 @@
defmodule Domain.Events.Hooks.ResourceConnectionsTest do
defmodule Domain.Changes.Hooks.ResourceConnectionsTest do
use Domain.DataCase, async: true
import Domain.Events.Hooks.ResourceConnections
import Domain.Changes.Hooks.ResourceConnections
alias Domain.{Changes.Change, Resources, PubSub}
describe "insert/1" do
test "broadcasts created resource connection" do
@@ -8,18 +9,22 @@ defmodule Domain.Events.Hooks.ResourceConnectionsTest do
resource = Fixtures.Resources.create_resource(account: account)
gateway_group = Fixtures.Gateways.create_group(account: account)
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = PubSub.Account.subscribe(account.id)
data = %{
"account_id" => account.id,
"resource_id" => resource.id,
"gateway_group_id" => gateway_group.id,
"deleted_at" => nil
"gateway_group_id" => gateway_group.id
}
assert :ok == on_insert(data)
assert :ok == on_insert(0, data)
assert_receive %Change{
op: :insert,
struct: %Resources.Connection{} = connection,
lsn: 0
}
assert_receive {:created, %Domain.Resources.Connection{} = connection}
assert connection.account_id == data["account_id"]
assert connection.resource_id == data["resource_id"]
assert connection.gateway_group_id == data["gateway_group_id"]
@@ -28,28 +33,35 @@ defmodule Domain.Events.Hooks.ResourceConnectionsTest do
describe "update/2" do
test "returns :ok" do
assert :ok = on_update(%{}, %{})
assert :ok = on_update(0, %{}, %{})
end
end
describe "delete/1" do
test "deletes flows for resource and gateway group" do
end
test "broadcasts deleted connection" do
account = Fixtures.Accounts.create_account()
resource = Fixtures.Resources.create_resource(account: account)
gateway_group = Fixtures.Gateways.create_group(account: account)
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = PubSub.Account.subscribe(account.id)
old_data = %{
"account_id" => account.id,
"resource_id" => resource.id,
"gateway_group_id" => gateway_group.id,
"deleted_at" => nil
"gateway_group_id" => gateway_group.id
}
assert :ok == on_delete(old_data)
assert :ok == on_delete(0, old_data)
assert_receive %Change{
op: :delete,
old_struct: %Resources.Connection{} = deleted_connection,
lsn: 0
}
assert_receive {:deleted, %Domain.Resources.Connection{} = deleted_connection}
assert deleted_connection.account_id == old_data["account_id"]
assert deleted_connection.resource_id == old_data["resource_id"]
assert deleted_connection.gateway_group_id == old_data["gateway_group_id"]

View File

@@ -1,7 +1,7 @@
defmodule Domain.Events.Hooks.ResourcesTest do
defmodule Domain.Changes.Hooks.ResourcesTest do
use Domain.DataCase, async: true
import Domain.Events.Hooks.Resources
alias Domain.PubSub
import Domain.Changes.Hooks.Resources
alias Domain.{Changes.Change, Flows, Resources, PubSub}
describe "insert/1" do
test "broadcasts created resource" do
@@ -22,8 +22,14 @@ defmodule Domain.Events.Hooks.ResourcesTest do
"deleted_at" => nil
}
assert :ok == on_insert(data)
assert_receive {:created, %Domain.Resources.Resource{} = created_resource}
assert :ok == on_insert(0, data)
assert_receive %Change{
op: :insert,
struct: %Resources.Resource{} = created_resource,
lsn: 0
}
assert created_resource.id == resource.id
assert created_resource.account_id == resource.account_id
assert created_resource.type == resource.type
@@ -55,9 +61,13 @@ defmodule Domain.Events.Hooks.ResourcesTest do
data = Map.put(old_data, "deleted_at", "2023-10-01T00:00:00Z")
assert :ok == on_update(old_data, data)
assert :ok == on_update(0, old_data, data)
assert_receive {:deleted, %Domain.Resources.Resource{} = deleted_resource}
assert_receive %Change{
op: :delete,
old_struct: %Resources.Resource{} = deleted_resource,
lsn: 0
}
assert deleted_resource.id == resource.id
assert deleted_resource.account_id == resource.account_id
@@ -87,8 +97,8 @@ defmodule Domain.Events.Hooks.ResourcesTest do
data = Map.put(old_data, "deleted_at", "2023-10-01T00:00:00Z")
assert flow = Fixtures.Flows.create_flow(resource: resource, account: account)
assert :ok = on_update(old_data, data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
assert :ok = on_update(0, old_data, data)
refute Repo.get_by(Flows.Flow, id: flow.id)
end
test "regular update broadcasts updated resource" do
@@ -111,10 +121,14 @@ defmodule Domain.Events.Hooks.ResourcesTest do
data = Map.put(old_data, "address", "new-address.example.com")
assert :ok == on_update(old_data, data)
assert :ok == on_update(0, old_data, data)
assert_receive {:updated, %Domain.Resources.Resource{},
%Domain.Resources.Resource{} = updated_resource}
assert_receive %Change{
op: :update,
old_struct: %Resources.Resource{},
struct: %Resources.Resource{} = updated_resource,
lsn: 0
}
assert updated_resource.id == resource.id
assert updated_resource.account_id == resource.account_id
@@ -144,8 +158,8 @@ defmodule Domain.Events.Hooks.ResourcesTest do
data = Map.put(old_data, "type", "cidr")
assert flow = Fixtures.Flows.create_flow(resource: resource, account: account)
assert :ok = on_update(old_data, data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
assert :ok = on_update(0, old_data, data)
refute Repo.get_by(Flows.Flow, id: flow.id)
end
end
@@ -168,9 +182,13 @@ defmodule Domain.Events.Hooks.ResourcesTest do
"deleted_at" => nil
}
assert :ok == on_delete(old_data)
assert :ok == on_delete(0, old_data)
assert_receive {:deleted, %Domain.Resources.Resource{} = deleted_resource}
assert_receive %Change{
op: :delete,
old_struct: %Resources.Resource{} = deleted_resource,
lsn: 0
}
assert deleted_resource.id == resource.id
assert deleted_resource.account_id == resource.account_id
@@ -198,8 +216,8 @@ defmodule Domain.Events.Hooks.ResourcesTest do
}
assert flow = Fixtures.Flows.create_flow(resource: resource, account: account)
assert :ok = on_delete(old_data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
assert :ok = on_delete(0, old_data)
refute Repo.get_by(Flows.Flow, id: flow.id)
end
end
end

View File

@@ -1,22 +1,24 @@
defmodule Domain.Events.Hooks.TokensTest do
defmodule Domain.Changes.Hooks.TokensTest do
use Domain.DataCase, async: true
import Domain.Events.Hooks.Tokens
import Domain.Changes.Hooks.Tokens
alias Domain.{Flows, PubSub}
describe "insert/1" do
test "returns :ok" do
assert :ok == on_insert(%{})
assert :ok == on_insert(0, %{})
end
end
describe "update/2" do
test "returns :ok for email token updates" do
assert :ok = on_update(%{"type" => "email"}, %{"type" => "email"})
assert :ok = on_update(0, %{"type" => "email"}, %{"type" => "email"})
end
test "soft-delete broadcasts deleted token" do
test "soft-delete broadcasts disconnect" do
account = Fixtures.Accounts.create_account()
token = Fixtures.Tokens.create_token(account: account)
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = PubSub.subscribe("sessions:#{token.id}")
old_data = %{
"id" => token.id,
@@ -25,13 +27,14 @@ defmodule Domain.Events.Hooks.TokensTest do
"deleted_at" => nil
}
data = Map.put(old_data, "deleted_at", "2023-10-01T00:00:00Z")
assert :ok == on_delete(0, old_data)
assert :ok == on_update(old_data, data)
assert_receive {:deleted, %Domain.Tokens.Token{} = deleted_token}
assert deleted_token.id == old_data["id"]
assert deleted_token.account_id == old_data["account_id"]
assert deleted_token.type == old_data["type"]
assert_receive %Phoenix.Socket.Broadcast{
topic: topic,
event: "disconnect"
}
assert topic == "sessions:#{token.id}"
end
test "soft-delete deletes flows" do
@@ -49,20 +52,21 @@ defmodule Domain.Events.Hooks.TokensTest do
assert flow = Fixtures.Flows.create_flow(account: account, token: token)
assert flow.token_id == token.id
assert :ok = on_update(old_data, data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
assert :ok = on_update(0, old_data, data)
refute Repo.get_by(Flows.Flow, id: flow.id)
end
test "regular update returns :ok" do
assert :ok = on_update(%{}, %{})
assert :ok = on_update(0, %{}, %{})
end
end
describe "delete/1" do
test "broadcasts deleted token" do
test "broadcasts disconnect message" do
account = Fixtures.Accounts.create_account()
token = Fixtures.Tokens.create_token(account: account)
:ok = Domain.PubSub.Account.subscribe(account.id)
:ok = PubSub.subscribe("sessions:#{token.id}")
old_data = %{
"id" => token.id,
@@ -71,12 +75,14 @@ defmodule Domain.Events.Hooks.TokensTest do
"deleted_at" => nil
}
assert :ok == on_delete(old_data)
assert :ok == on_delete(0, old_data)
assert_receive {:deleted, %Domain.Tokens.Token{} = deleted_token}
assert deleted_token.id == old_data["id"]
assert deleted_token.account_id == old_data["account_id"]
assert deleted_token.type == old_data["type"]
assert_receive %Phoenix.Socket.Broadcast{
topic: topic,
event: "disconnect"
}
assert topic == "sessions:#{token.id}"
end
test "deletes flows" do
@@ -91,8 +97,8 @@ defmodule Domain.Events.Hooks.TokensTest do
}
assert flow = Fixtures.Flows.create_flow(account: account, token: token)
assert :ok = on_delete(old_data)
refute Repo.get_by(Domain.Flows.Flow, id: flow.id)
assert :ok = on_delete(0, old_data)
refute Repo.get_by(Flows.Flow, id: flow.id)
end
end
end

View File

@@ -1,12 +1,12 @@
defmodule Domain.Events.ReplicationConnectionTest do
defmodule Domain.Changes.ReplicationConnectionTest do
use Domain.DataCase, async: true
import ExUnit.CaptureLog
alias Domain.Events.ReplicationConnection
alias Domain.Changes.ReplicationConnection
setup do
tables =
Application.fetch_env!(:domain, Domain.Events.ReplicationConnection)
Application.fetch_env!(:domain, Domain.Changes.ReplicationConnection)
|> Keyword.fetch!(:table_subscriptions)
%{tables: tables}
@@ -29,7 +29,7 @@ defmodule Domain.Events.ReplicationConnectionTest do
end)
assert log_output =~ "No hook defined for insert on table unknown_table"
assert log_output =~ "Please implement Domain.Events.Hooks for this table"
assert log_output =~ "Please implement Domain.Changes.Hooks for this table"
end
test "handles known tables without errors", %{tables: tables} do
@@ -94,7 +94,7 @@ defmodule Domain.Events.ReplicationConnectionTest do
end)
assert log_output =~ "No hook defined for update on table unknown_table"
assert log_output =~ "Please implement Domain.Events.Hooks for this table"
assert log_output =~ "Please implement Domain.Changes.Hooks for this table"
end
test "handles known tables", %{tables: tables} do
@@ -136,12 +136,15 @@ defmodule Domain.Events.ReplicationConnectionTest do
end)
assert log_output =~ "No hook defined for delete on table unknown_table"
assert log_output =~ "Please implement Domain.Events.Hooks for this table"
assert log_output =~ "Please implement Domain.Changes.Hooks for this table"
end
test "handles known tables", %{tables: tables} do
# Fill in some dummy foreign keys
old_data = %{
"account_id" => Ecto.UUID.generate(),
"resource_id" => Ecto.UUID.generate(),
"gateway_group_id" => Ecto.UUID.generate(),
"id" => Ecto.UUID.generate(),
"name" => "deleted item"
}
@@ -227,7 +230,7 @@ defmodule Domain.Events.ReplicationConnectionTest do
end)
assert log_output =~ "No hook defined for insert on table test_table_insert"
assert log_output =~ "Please implement Domain.Events.Hooks for this table"
assert log_output =~ "Please implement Domain.Changes.Hooks for this table"
# Test update
log_output =
@@ -236,7 +239,7 @@ defmodule Domain.Events.ReplicationConnectionTest do
end)
assert log_output =~ "No hook defined for update on table test_table_update"
assert log_output =~ "Please implement Domain.Events.Hooks for this table"
assert log_output =~ "Please implement Domain.Changes.Hooks for this table"
# Test delete
log_output =
@@ -245,7 +248,7 @@ defmodule Domain.Events.ReplicationConnectionTest do
end)
assert log_output =~ "No hook defined for delete on table test_table_delete"
assert log_output =~ "Please implement Domain.Events.Hooks for this table"
assert log_output =~ "Please implement Domain.Changes.Hooks for this table"
end
end
@@ -271,16 +274,16 @@ defmodule Domain.Events.ReplicationConnectionTest do
test "all configured tables have hook modules" do
# This test ensures our tables_to_hooks map is properly configured
tables_to_hooks = %{
"accounts" => Domain.Events.Hooks.Accounts,
"actor_group_memberships" => Domain.Events.Hooks.ActorGroupMemberships,
"clients" => Domain.Events.Hooks.Clients,
"flows" => Domain.Events.Hooks.Flows,
"gateway_groups" => Domain.Events.Hooks.GatewayGroups,
"gateways" => Domain.Events.Hooks.Gateways,
"policies" => Domain.Events.Hooks.Policies,
"resource_connections" => Domain.Events.Hooks.ResourceConnections,
"resources" => Domain.Events.Hooks.Resources,
"tokens" => Domain.Events.Hooks.Tokens
"accounts" => Domain.Changes.Hooks.Accounts,
"actor_group_memberships" => Domain.Changes.Hooks.ActorGroupMemberships,
"clients" => Domain.Changes.Hooks.Clients,
"flows" => Domain.Changes.Hooks.Flows,
"gateway_groups" => Domain.Changes.Hooks.GatewayGroups,
"gateways" => Domain.Changes.Hooks.Gateways,
"policies" => Domain.Changes.Hooks.Policies,
"resource_connections" => Domain.Changes.Hooks.ResourceConnections,
"resources" => Domain.Changes.Hooks.Resources,
"tokens" => Domain.Changes.Hooks.Tokens
}
# Verify the mapping includes all expected tables

View File

@@ -1,4 +0,0 @@
defmodule Domain.EventsTest do
# TODO: WAL
# Add integration tests to ensure side effects trigger broadcasts in general
end

View File

@@ -55,198 +55,6 @@ defmodule Domain.FlowsTest do
end
describe "create_flow/7" do
# test "returns error when resource does not exist", %{
# client: client,
# gateway: gateway,
# subject: subject
# } do
# resource_id = Ecto.UUID.generate()
# assert authorize_flow(client, gateway, resource_id, subject) == {:error, :not_found}
# end
#
# test "returns error when UUID is invalid", %{
# client: client,
# gateway: gateway,
# subject: subject
# } do
# assert authorize_flow(client, gateway, "foo", subject) == {:error, :not_found}
# end
#
# test "returns authorized resource", %{
# client: client,
# gateway: gateway,
# resource: resource,
# policy: policy,
# subject: subject
# } do
# assert {:ok, fetched_resource, _flow, _expires_at} =
# authorize_flow(client, gateway, resource.id, subject)
#
# assert fetched_resource.id == resource.id
# assert hd(fetched_resource.authorized_by_policies).id == policy.id
# end
#
# test "returns error when some conditions are not satisfied", %{
# account: account,
# actor_group: actor_group,
# client: client,
# gateway_group: gateway_group,
# gateway: gateway,
# subject: subject
# } do
# resource =
# Fixtures.Resources.create_resource(
# account: account,
# connections: [%{gateway_group_id: gateway_group.id}]
# )
#
# Fixtures.Policies.create_policy(
# account: account,
# actor_group: actor_group,
# resource: resource,
# conditions: [
# %{
# property: :remote_ip_location_region,
# operator: :is_in,
# values: ["AU"]
# },
# %{
# property: :remote_ip_location_region,
# operator: :is_not_in,
# values: [client.last_seen_remote_ip_location_region]
# },
# %{
# property: :remote_ip,
# operator: :is_in_cidr,
# values: ["0.0.0.0/0", "0::/0"]
# }
# ]
# )
#
# assert authorize_flow(client, gateway, resource.id, subject) ==
# {:error, {:forbidden, violated_properties: [:remote_ip_location_region]}}
# end
#
# test "returns error when all conditions are not satisfied", %{
# account: account,
# actor_group: actor_group,
# client: client,
# gateway_group: gateway_group,
# gateway: gateway,
# subject: subject
# } do
# resource =
# Fixtures.Resources.create_resource(
# account: account,
# connections: [%{gateway_group_id: gateway_group.id}]
# )
#
# Fixtures.Policies.create_policy(
# account: account,
# actor_group: actor_group,
# resource: resource,
# conditions: [
# %{
# property: :remote_ip_location_region,
# operator: :is_in,
# values: ["AU"]
# },
# %{
# property: :remote_ip_location_region,
# operator: :is_not_in,
# values: [client.last_seen_remote_ip_location_region]
# }
# ]
# )
#
# assert authorize_flow(client, gateway, resource.id, subject) ==
# {:error, {:forbidden, violated_properties: [:remote_ip_location_region]}}
# end
#
# test "creates a flow when the only policy conditions are satisfied", %{
# account: account,
# actor: actor,
# resource: resource,
# client: client,
# policy: policy,
# gateway: gateway,
# subject: subject
# } do
# actor_group2 = Fixtures.Actors.create_group(account: account)
# Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group2)
#
# time = Time.utc_now()
# midnight = Time.from_iso8601!("23:59:59.999999")
#
# date = Date.utc_today()
# day_of_week = Enum.at(~w[M T W R F S U], Date.day_of_week(date) - 1)
#
# Fixtures.Policies.create_policy(
# account: account,
# actor_group: actor_group2,
# resource: resource,
# conditions: [
# %{
# property: :remote_ip_location_region,
# operator: :is_not_in,
# values: [client.last_seen_remote_ip_location_region]
# },
# %{
# property: :current_utc_datetime,
# operator: :is_in_day_of_week_time_ranges,
# values: [
# "#{day_of_week}/#{time}-#{midnight}/UTC"
# ]
# }
# ]
# )
#
# assert {:ok, _fetched_resource, flow, expires_at} =
# authorize_flow(client, gateway, resource.id, subject)
#
# assert flow.policy_id == policy.id
# assert DateTime.diff(expires_at, DateTime.new!(date, midnight)) < 5
# end
#
# test "creates a flow when all conditions for at least one of the policies are satisfied", %{
# account: account,
# actor_group: actor_group,
# client: client,
# gateway_group: gateway_group,
# gateway: gateway,
# subject: subject
# } do
# resource =
# Fixtures.Resources.create_resource(
# account: account,
# connections: [%{gateway_group_id: gateway_group.id}]
# )
#
# Fixtures.Policies.create_policy(
# account: account,
# actor_group: actor_group,
# resource: resource,
# conditions: [
# %{
# property: :remote_ip_location_region,
# operator: :is_in,
# values: [client.last_seen_remote_ip_location_region]
# },
# %{
# property: :remote_ip,
# operator: :is_in_cidr,
# values: ["0.0.0.0/0", "0::/0"]
# }
# ]
# )
#
# assert {:ok, _fetched_resource, flow, expires_at} =
# authorize_flow(client, gateway, resource.id, subject)
#
# assert flow.resource_id == resource.id
# assert expires_at == subject.expires_at
# end
test "creates a new flow for users", %{
account: account,
gateway: gateway,
@@ -266,7 +74,7 @@ defmodule Domain.FlowsTest do
client,
gateway,
resource.id,
policy,
policy.id,
membership.id,
subject,
subject.expires_at
@@ -306,7 +114,7 @@ defmodule Domain.FlowsTest do
client,
gateway,
resource.id,
policy,
policy.id,
membership.id,
subject,
subject.expires_at
@@ -323,36 +131,6 @@ defmodule Domain.FlowsTest do
assert flow.actor_group_membership_id == membership.id
assert flow.expires_at == subject.expires_at
end
# TODO: Rename Flows
# Authorization a policy authorization ("flow") makes no sense
# test "returns error when subject has no permission to create flows", %{
# client: client,
# gateway: gateway,
# resource: resource,
# policy: policy,
# subject: subject
# } do
# subject = Fixtures.Auth.remove_permissions(subject)
#
# assert create_flow(client, gateway, resource.id, policy, subject) ==
# {:error,
# {:unauthorized,
# reason: :missing_permissions,
# missing_permissions: [
# Flows.Authorizer.create_flows_permission()
# ]}}
#
# subject = Fixtures.Auth.add_permission(subject, Flows.Authorizer.create_flows_permission())
#
# assert create_flow(client, gateway, resource.id, policy, subject) ==
# {:error,
# {:unauthorized,
# reason: :missing_permissions,
# missing_permissions: [
# Domain.Resources.Authorizer.view_available_resources_permission()
# ]}}
# end
end
describe "reauthorize_flow/1" do
@@ -448,7 +226,7 @@ defmodule Domain.FlowsTest do
]
)
assert {:error, :forbidden} = reauthorize_flow(flow)
assert :error = reauthorize_flow(flow)
end
end
@@ -833,9 +611,9 @@ defmodule Domain.FlowsTest do
)
# Only authorize resource1 and resource2, resource3 should be deleted
authorized_resources = [resource1, resource2]
authorized_resource_ids = [resource1.id, resource2.id]
assert {1, nil} = delete_stale_flows_on_connect(client, authorized_resources)
assert {1, nil} = delete_stale_flows_on_connect(client, authorized_resource_ids)
# Verify flow3 was deleted but flow1 and flow2 remain
assert {:ok, ^flow1} = fetch_flow_by_id(flow1.id, subject)
@@ -876,9 +654,9 @@ defmodule Domain.FlowsTest do
)
# Authorize all resources
authorized_resources = [resource1, resource2]
authorized_resource_ids = [resource1.id, resource2.id]
assert {0, nil} = delete_stale_flows_on_connect(client, authorized_resources)
assert {0, nil} = delete_stale_flows_on_connect(client, authorized_resource_ids)
# Verify both flows still exist
assert {:ok, ^flow1} = fetch_flow_by_id(flow1.id, subject)
@@ -918,9 +696,9 @@ defmodule Domain.FlowsTest do
)
# Empty authorized list - all flows should be deleted
authorized_resources = []
authorized_resource_ids = []
assert {2, nil} = delete_stale_flows_on_connect(client, authorized_resources)
assert {2, nil} = delete_stale_flows_on_connect(client, authorized_resource_ids)
# Verify both flows were deleted
assert {:error, :not_found} = fetch_flow_by_id(flow1.id, subject)
@@ -964,9 +742,9 @@ defmodule Domain.FlowsTest do
)
# Only authorize resource2 for the first client (resource1 should be deleted)
authorized_resources = [resource2]
authorized_resource_ids = [resource2.id]
assert {1, nil} = delete_stale_flows_on_connect(client, authorized_resources)
assert {1, nil} = delete_stale_flows_on_connect(client, authorized_resource_ids)
# Verify only the first client's flow was deleted
assert {:error, :not_found} = fetch_flow_by_id(flow1.id, subject)
@@ -998,9 +776,9 @@ defmodule Domain.FlowsTest do
Fixtures.Flows.create_flow()
# Empty authorized list for current account
authorized_resources = []
authorized_resource_ids = []
assert {1, nil} = delete_stale_flows_on_connect(client, authorized_resources)
assert {1, nil} = delete_stale_flows_on_connect(client, authorized_resource_ids)
# Verify only the current account's flow was deleted
assert {:error, :not_found} = fetch_flow_by_id(flow1.id, subject)
@@ -1012,9 +790,9 @@ defmodule Domain.FlowsTest do
client: client
} do
# Try to delete stale flows for a client with no flows
authorized_resources = []
authorized_resource_ids = []
assert {0, nil} = delete_stale_flows_on_connect(client, authorized_resources)
assert {0, nil} = delete_stale_flows_on_connect(client, authorized_resource_ids)
end
test "handles case when client has no flows but resources are provided", %{
@@ -1023,9 +801,10 @@ defmodule Domain.FlowsTest do
} do
# Create a client with no flows
client_with_no_flows = Fixtures.Clients.create_client(account: account)
authorized_resources = [resource]
authorized_resource_ids = [resource.id]
assert {0, nil} = delete_stale_flows_on_connect(client_with_no_flows, authorized_resources)
assert {0, nil} =
delete_stale_flows_on_connect(client_with_no_flows, authorized_resource_ids)
end
end
end

View File

@@ -2,6 +2,7 @@ defmodule Domain.GatewaysTest do
use Domain.DataCase, async: true
import Domain.Gateways
alias Domain.{Gateways, Tokens, Resources}
import Domain.Cache.Cacheable, only: [to_cache: 1]
setup do
account = Fixtures.Accounts.create_account()
@@ -689,22 +690,31 @@ defmodule Domain.GatewaysTest do
end
end
describe "all_connected_gateways_for_resource/3" do
describe "all_compatible_gateways_for_client_and_resource/3" do
setup %{account: account} do
client = Fixtures.Clients.create_client(account: account)
%{client: client}
end
test "returns empty list when there are no online gateways", %{
account: account,
client: client,
subject: subject
} do
resource = Fixtures.Resources.create_resource(account: account)
resource = Fixtures.Resources.create_resource(account: account) |> to_cache()
Fixtures.Gateways.create_gateway(account: account)
Fixtures.Gateways.create_gateway(account: account)
|> Fixtures.Gateways.delete_gateway()
assert all_connected_gateways_for_resource(resource, subject) == {:ok, []}
assert all_compatible_gateways_for_client_and_resource(client, resource, subject) ==
{:ok, []}
end
test "returns list of connected gateways for a given resource", %{
test "returns list of connected gateways for a given client and resource", %{
client: client,
account: account,
subject: subject
} do
@@ -715,54 +725,81 @@ defmodule Domain.GatewaysTest do
account: account,
connections: [%{gateway_group_id: gateway.group_id}]
)
|> to_cache()
assert Gateways.Presence.connect(gateway) == :ok
assert {:ok, [connected_gateway]} = all_connected_gateways_for_resource(resource, subject)
assert {:ok, [connected_gateway]} =
all_compatible_gateways_for_client_and_resource(client, resource, subject)
assert connected_gateway.id == gateway.id
end
test "does not return connected gateways that are not connected to given resource", %{
test "returns empty list when client is more than 1 version ahead", %{
client: client,
account: account,
subject: subject
} do
resource = Fixtures.Resources.create_resource(account: account)
client = %{client | last_seen_version: "1.5.0"}
gateway = Fixtures.Gateways.create_gateway(account: account)
assert Gateways.Presence.connect(gateway) == :ok
assert all_connected_gateways_for_resource(resource, subject) == {:ok, []}
end
end
describe "gateway_can_connect_to_resource?/2" do
test "returns true when gateway can connect to resource", %{account: account} do
gateway = Fixtures.Gateways.create_gateway(account: account)
:ok = Gateways.Presence.connect(gateway)
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway.group_id}]
)
|> to_cache()
assert gateway_can_connect_to_resource?(gateway, resource)
assert Gateways.Presence.connect(gateway) == :ok
assert all_compatible_gateways_for_client_and_resource(client, resource, subject) ==
{:ok, []}
end
test "returns false when gateway cannot connect to resource", %{account: account} do
gateway = Fixtures.Gateways.create_gateway(account: account)
:ok = Gateways.Presence.connect(gateway)
test "returns empty list of resource is not compatible", %{
client: client,
account: account,
subject: subject
} do
gateway =
Fixtures.Gateways.create_gateway(
account: account,
context:
Fixtures.Auth.build_context(
type: :gateway_group,
user_agent: "Linux/1.2.3 connlib/1.0.0"
)
)
resource = Fixtures.Resources.create_resource(account: account)
client = %{client | last_seen_version: "1.3.0"}
refute gateway_can_connect_to_resource?(gateway, resource)
group = Fixtures.Gateways.create_internet_group(account: account)
resource =
Fixtures.Resources.create_internet_resource(
account: account,
connections: [%{gateway_group_id: group.id}]
)
|> to_cache()
assert Gateways.Presence.connect(gateway) == :ok
assert all_compatible_gateways_for_client_and_resource(client, resource, subject) ==
{:ok, []}
end
test "returns false when gateway is offline", %{account: account} do
test "does not return connected gateways that are not connected to given resource", %{
client: client,
account: account,
subject: subject
} do
resource = Fixtures.Resources.create_resource(account: account) |> to_cache()
gateway = Fixtures.Gateways.create_gateway(account: account)
resource = Fixtures.Resources.create_resource(account: account)
refute gateway_can_connect_to_resource?(gateway, resource)
assert Gateways.Presence.connect(gateway) == :ok
assert all_compatible_gateways_for_client_and_resource(client, resource, subject) ==
{:ok, []}
end
end

View File

@@ -234,6 +234,49 @@ defmodule Domain.PoliciesTest do
end
end
describe "all_policies_in_gateway_group_for_resource_id_and_actor_id!/4" do
test "does not return policies for other gateway groups", %{
account: account,
resource: resource,
actor_group: actor_group,
actor: actor
} do
policy =
Fixtures.Policies.create_policy(
account: account,
resource: resource,
actor_group: actor_group
)
connections = resource.connections
original_gateway_group_id = List.first(connections).gateway_group_id
new_gateway_group = Fixtures.Gateways.create_group(account: account)
# Admin moves resource to another site
connections = [%{gateway_group_id: new_gateway_group.id}]
resource = Fixtures.Resources.update_resource(resource, connections: connections)
# Since this function is used to reauthorize flows, we need to ensure we don't find
# policies for the old gateway group
assert [] =
all_policies_in_gateway_group_for_resource_id_and_actor_id!(
account.id,
original_gateway_group_id,
resource.id,
actor.id
)
assert [^policy] =
all_policies_in_gateway_group_for_resource_id_and_actor_id!(
account.id,
new_gateway_group.id,
resource.id,
actor.id
)
end
end
describe "create_policy/2" do
test "returns changeset error on empty params", %{subject: subject} do
assert {:error, changeset} = create_policy(%{}, subject)
@@ -1190,7 +1233,7 @@ defmodule Domain.PoliciesTest do
in_three_days = DateTime.utc_now() |> DateTime.add(3, :day)
assert {:ok, expires_at, ^policy1} =
assert {:ok, ^policy1, expires_at} =
longest_conforming_policy_for_client(
[policy1, policy2],
client,
@@ -1201,7 +1244,7 @@ defmodule Domain.PoliciesTest do
in_one_minute = DateTime.utc_now() |> DateTime.add(1, :minute)
assert {:ok, expires_at, ^policy1} =
assert {:ok, ^policy1, expires_at} =
longest_conforming_policy_for_client(
[policy1, policy2],
client,

View File

@@ -1501,37 +1501,145 @@ defmodule Domain.ResourcesTest do
end
end
describe "connected?/2" do
test "returns true when resource has a connection to a gateway", %{
account: account,
subject: subject
} do
group = Fixtures.Gateways.create_group(account: account, subject: subject)
gateway = Fixtures.Gateways.create_gateway(account: account, group: group)
describe "adapt_resource_for_version/2" do
setup do
account = Fixtures.Accounts.create_account()
resource =
ip_resource =
Fixtures.Resources.create_resource(type: :ip, account: account, address: "1.1.1.1")
cidr_resource =
Fixtures.Resources.create_resource(type: :cidr, account: account, address: "1.1.1.1/32")
dns_resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: group.id}]
type: :dns,
account: account
)
assert connected?(resource, gateway)
internet_group = Fixtures.Gateways.create_internet_group(account: account)
internet_resource =
Fixtures.Resources.create_internet_resource(
connections: [%{gateway_group_id: internet_group.id}],
account: account
)
%{
account: account,
ip_resource: ip_resource,
cidr_resource: cidr_resource,
dns_resource: dns_resource,
internet_resource: internet_resource
}
end
test "raises resource and gateway don't belong to the same account" do
gateway = Fixtures.Gateways.create_gateway()
resource = Fixtures.Resources.create_resource()
test "for ip resource returns the same resource for all versions", %{ip_resource: ip_resource} do
versions = ~w(
1.0.0
1.1.0
1.2.0
1.3.0
1.4.0
1.5.0
1.6.0
)
assert_raise FunctionClauseError, fn ->
connected?(resource, gateway)
for version <- versions do
assert adapt_resource_for_version(ip_resource, version) == ip_resource
end
end
test "returns false when resource has no connection to a gateway", %{account: account} do
gateway = Fixtures.Gateways.create_gateway(account: account)
resource = Fixtures.Resources.create_resource(account: account)
test "for cidr resource returns the same resource for all versions", %{
cidr_resource: cidr_resource
} do
versions = ~w(
1.0.0
1.1.0
1.2.0
1.3.0
1.4.0
1.5.0
1.6.0
)
refute connected?(resource, gateway)
for version <- versions do
assert adapt_resource_for_version(cidr_resource, version) == cidr_resource
end
end
test "for dns resource transforms the address for versions < 1.2.0", %{
dns_resource: dns_resource
} do
addresses = [
{"**.example.com", "*.example.com"},
{"*.example.com", "?.example.com"},
{"foo.bar.example.com", "foo.bar.example.com"}
]
versions = ~w(
1.0.0
1.1.0
)
for version <- versions do
for address <- addresses do
dns_resource = %{dns_resource | address: elem(address, 0)}
adapted = adapt_resource_for_version(dns_resource, version)
assert adapted.address == elem(address, 1)
end
end
end
test "for dns resource returns nil for incompatible addresses", %{
dns_resource: dns_resource
} do
addresses = ~w(
foo.?.example.com
foo.bar*bar.example.com
)
versions = ~w(
1.0.0
1.1.0
)
for version <- versions do
for address <- addresses do
dns_resource = %{dns_resource | address: address}
assert nil == adapt_resource_for_version(dns_resource, version)
end
end
end
test "for internet resource returns nil for versions < 1.3.0", %{
internet_resource: internet_resource
} do
versions = ~w(
1.0.0
1.1.0
1.2.0
)
for version <- versions do
assert nil == adapt_resource_for_version(internet_resource, version)
end
end
test "for internet resource returns resource as-is for versions >= 1.3.0", %{
internet_resource: internet_resource
} do
versions = ~w(
1.3.0
1.4.0
1.5.0
1.6.0
)
for version <- versions do
assert adapt_resource_for_version(internet_resource, version) == internet_resource
end
end
end
end

View File

@@ -69,4 +69,17 @@ defmodule Domain.Fixtures.Policies do
{:ok, policy} = Policies.delete_policy(policy, subject)
policy
end
def update_policy(policy, attrs) do
policy = Repo.preload(policy, :account)
subject =
Fixtures.Auth.create_subject(
account: policy.account,
actor: [type: :account_admin_user]
)
{:ok, policy} = Policies.update_policy(policy, Enum.into(attrs, %{}), subject)
policy
end
end

View File

@@ -84,4 +84,18 @@ defmodule Domain.Fixtures.Resources do
{:ok, resource} = Domain.Resources.delete_resource(resource, subject)
resource
end
def update_resource(resource, attrs) do
attrs = Enum.into(attrs, %{})
resource = Repo.preload(resource, :account)
subject =
Fixtures.Auth.create_subject(
account: resource.account,
actor: [type: :account_admin_user]
)
{:ok, resource} = Domain.Resources.update_resource(resource, attrs, subject)
resource
end
end

View File

@@ -1,6 +1,6 @@
defmodule Web.Policies.Index do
use Web, :live_view
alias Domain.{PubSub, Policies}
alias Domain.{Changes.Change, PubSub, Policies}
def mount(_params, _session, socket) do
if connected?(socket) do
@@ -123,11 +123,11 @@ defmodule Web.Policies.Index do
when event in ["paginate", "order_by", "filter", "reload"],
do: handle_live_table_event(event, params, socket)
def handle_info({_action, %Policies.Policy{}, %Policies.Policy{}}, socket) do
def handle_info(%Change{old_struct: %Policies.Policy{}}, socket) do
{:noreply, assign(socket, stale: true)}
end
def handle_info({_action, %Policies.Policy{}}, socket) do
def handle_info(%Change{struct: %Policies.Policy{}}, socket) do
{:noreply, assign(socket, stale: true)}
end

View File

@@ -1,7 +1,6 @@
defmodule Web.Resources.Index do
use Web, :live_view
alias Domain.PubSub
alias Domain.Resources
alias Domain.{Changes.Change, PubSub, Resources}
def mount(_params, _session, socket) do
if connected?(socket) do
@@ -170,11 +169,11 @@ defmodule Web.Resources.Index do
when event in ["paginate", "order_by", "filter", "reload"],
do: handle_live_table_event(event, params, socket)
def handle_info({_action, %Resources.Resource{}, %Resources.Resource{}}, socket) do
def handle_info(%Change{old_struct: %Resources.Resource{}}, socket) do
{:noreply, assign(socket, stale: true)}
end
def handle_info({_action, %Resources.Resource{}}, socket) do
def handle_info(%Change{struct: %Resources.Resource{}}, socket) do
{:noreply, assign(socket, stale: true)}
end

View File

@@ -93,7 +93,7 @@ defmodule Web.Acceptance.AuthTest do
for token <- tokens do
assert %DateTime{} = token.deleted_at
Domain.Events.Hooks.Tokens.on_delete(%{
Domain.Changes.Hooks.Tokens.on_delete(0, %{
"remaining_attempts" => token.remaining_attempts,
"actor_id" => token.actor_id,
"name" => token.name,

View File

@@ -1,6 +1,6 @@
defmodule Web.Live.Policies.IndexTest do
use Web.ConnCase, async: true
alias Domain.Events
alias Domain.Changes
setup do
account = Fixtures.Accounts.create_account()
@@ -95,7 +95,7 @@ defmodule Web.Live.Policies.IndexTest do
policy = Fixtures.Policies.create_policy(account: account, description: "foo bar")
Events.Hooks.Policies.on_insert(%{
Changes.Hooks.Policies.on_insert(0, %{
"id" => policy.id,
"actor_group_id" => policy.actor_group_id,
"resource_id" => policy.resource_id,
@@ -128,17 +128,13 @@ defmodule Web.Live.Policies.IndexTest do
Domain.Policies.delete_policy(policy, subject)
Events.Hooks.Policies.on_delete(%{
Changes.Hooks.Policies.on_delete(0, %{
"id" => policy.id,
"actor_group_id" => policy.actor_group_id,
"resource_id" => policy.resource_id,
"account_id" => account.id
})
# TODO: WAL
# Remove this after direct broadcast
Process.sleep(100)
reload_btn =
lv
|> element("#policies-reload-btn")

View File

@@ -1,6 +1,6 @@
defmodule Web.Live.Resources.EditTest do
use Web.ConnCase, async: true
alias Domain.{Events, PubSub, Resources}
alias Domain.{Changes, PubSub, Resources}
setup do
account = Fixtures.Accounts.create_account()
@@ -245,7 +245,7 @@ defmodule Web.Live.Resources.EditTest do
}
data = Map.put(old_data, "filters", attrs.filters)
:ok = Events.Hooks.Resources.on_update(old_data, data)
:ok = Changes.Hooks.Resources.on_update(0, old_data, data)
{:ok, _lv, html} =
lv
@@ -253,7 +253,11 @@ defmodule Web.Live.Resources.EditTest do
|> render_submit()
|> follow_redirect(conn, ~p"/#{account}/resources")
assert_receive {:updated, %Resources.Resource{}, %Resources.Resource{} = updated_resource}
assert_receive %Changes.Change{
lsn: 0,
old_struct: %Resources.Resource{},
struct: %Resources.Resource{} = updated_resource
}
assert updated_resource.id == resource.id

View File

@@ -1,6 +1,6 @@
defmodule Web.Live.Resources.IndexTest do
use Web.ConnCase, async: true
alias Domain.Events
alias Domain.Changes
setup do
account = Fixtures.Accounts.create_account()
@@ -271,7 +271,7 @@ defmodule Web.Live.Resources.IndexTest do
resource = Fixtures.Resources.create_resource(account: account)
# Simulate WAL broadcast
Events.Hooks.Resources.on_insert(%{
Changes.Hooks.Resources.on_insert(0, %{
"id" => resource.id,
"account_id" => account.id
})
@@ -302,7 +302,8 @@ defmodule Web.Live.Resources.IndexTest do
Domain.Resources.delete_resource(resource, subject)
Events.Hooks.Resources.on_update(
Changes.Hooks.Resources.on_update(
0,
%{
"id" => resource.id,
"account_id" => account.id,

View File

@@ -78,9 +78,9 @@ config :domain, Domain.ChangeLogs.ReplicationConnection,
# We want to flush at most 500 change logs at a time
flush_buffer_size: 500
config :domain, Domain.Events.ReplicationConnection,
replication_slot_name: "events_slot",
publication_name: "events_publication",
config :domain, Domain.Changes.ReplicationConnection,
replication_slot_name: "changes_slot",
publication_name: "changes_publication",
enabled: true,
connection_opts: [
hostname: "localhost",
@@ -93,8 +93,8 @@ config :domain, Domain.Events.ReplicationConnection,
password: "postgres"
],
# When changing these, make sure to also:
# 1. Make appropriate changes to `Domain.Events.ReplicationConnection`
# 2. Add an appropriate `Domain.Events.Hooks` module
# 1. Make appropriate changes to `Domain.Changes.ReplicationConnection`
# 2. Add an appropriate `Domain.Changes.Hooks` module
# 3. Add tests and test WAL locally
table_subscriptions: ~w[
accounts

View File

@@ -44,10 +44,10 @@ if config_env() == :prod do
database: env_var_to_config!(:database_name)
]
config :domain, Domain.Events.ReplicationConnection,
config :domain, Domain.Changes.ReplicationConnection,
enabled: env_var_to_config!(:background_jobs_enabled),
replication_slot_name: env_var_to_config!(:database_events_replication_slot_name),
publication_name: env_var_to_config!(:database_events_publication_name),
replication_slot_name: env_var_to_config!(:database_changes_replication_slot_name),
publication_name: env_var_to_config!(:database_changes_publication_name),
connection_opts: [
hostname: env_var_to_config!(:database_host),
port: env_var_to_config!(:database_port),

View File

@@ -53,9 +53,9 @@ config :domain, Domain.ChangeLogs.ReplicationConnection,
database: "firezone_test#{partition_suffix}"
]
config :domain, Domain.Events.ReplicationConnection,
replication_slot_name: "test_events_slot",
publication_name: "test_events_publication",
config :domain, Domain.Changes.ReplicationConnection,
replication_slot_name: "test_changes_slot",
publication_name: "test_changes_publication",
enabled: false,
connection_opts: [
database: "firezone_test#{partition_suffix}"