From cafe6554ffef1f29a16e863680e600209415d72b Mon Sep 17 00:00:00 2001 From: Jamil Date: Fri, 22 Aug 2025 17:52:29 -0400 Subject: [PATCH] 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 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- elixir/.gitignore | 4 + elixir/apps/api/lib/api/client/channel.ex | 997 ++-- elixir/apps/api/lib/api/client/socket.ex | 1 + .../api/lib/api/client/views/gateway_group.ex | 6 +- .../apps/api/lib/api/client/views/resource.ex | 19 +- elixir/apps/api/lib/api/gateway/channel.ex | 445 +- elixir/apps/api/lib/api/gateway/views/flow.ex | 19 +- .../api/lib/api/gateway/views/resource.ex | 23 +- .../apps/api/test/api/client/channel_test.exs | 4700 ++++++++++------- .../api/test/api/gateway/channel_test.exs | 512 +- elixir/apps/domain/lib/domain/actors.ex | 13 +- .../domain/lib/domain/actors/membership.ex | 7 + elixir/apps/domain/lib/domain/application.ex | 2 +- .../apps/domain/lib/domain/cache/cacheable.ex | 45 + .../domain/cache/cacheable/gateway_group.ex | 11 + .../lib/domain/cache/cacheable/policy.ex | 34 + .../lib/domain/cache/cacheable/resource.ex | 28 + elixir/apps/domain/lib/domain/cache/client.ex | 561 ++ .../apps/domain/lib/domain/cache/gateway.ex | 206 + .../apps/domain/lib/domain/changes/change.ex | 15 + .../apps/domain/lib/domain/changes/hooks.ex | 10 + .../lib/domain/changes/hooks/accounts.ex | 54 + .../changes/hooks/actor_group_memberships.ex | 28 + .../lib/domain/changes/hooks/clients.ex | 44 + .../domain/lib/domain/changes/hooks/flows.ex | 26 + .../domain/changes/hooks/gateway_groups.ex | 30 + .../lib/domain/changes/hooks/gateways.ex | 30 + .../lib/domain/changes/hooks/policies.ex | 64 + .../changes/hooks/resource_connections.ex | 26 + .../lib/domain/changes/hooks/resources.ex | 57 + .../domain/lib/domain/changes/hooks/tokens.ex | 51 + .../replication_connection.ex | 14 +- .../apps/domain/lib/domain/clients/client.ex | 33 + .../domain/lib/domain/config/definitions.ex | 4 +- elixir/apps/domain/lib/domain/events/hooks.ex | 9 - .../lib/domain/events/hooks/accounts.ex | 48 - .../events/hooks/actor_group_memberships.ex | 24 - .../domain/lib/domain/events/hooks/clients.ex | 41 - .../domain/lib/domain/events/hooks/flows.ex | 24 - .../lib/domain/events/hooks/gateway_groups.ex | 31 - .../lib/domain/events/hooks/gateways.ex | 28 - .../lib/domain/events/hooks/policies.ex | 60 - .../events/hooks/resource_connections.ex | 19 - .../lib/domain/events/hooks/resources.ex | 52 - .../domain/lib/domain/events/hooks/tokens.ex | 45 - elixir/apps/domain/lib/domain/flows.ex | 39 +- elixir/apps/domain/lib/domain/flows/flow.ex | 16 + .../domain/lib/domain/flows/flow/query.ex | 22 + elixir/apps/domain/lib/domain/gateways.ex | 70 +- .../domain/lib/domain/gateways/gateway.ex | 24 + .../apps/domain/lib/domain/gateways/group.ex | 12 + elixir/apps/domain/lib/domain/policies.ex | 54 +- .../domain/lib/domain/policies/condition.ex | 19 + .../domain/policies/condition/evaluator.ex | 19 +- .../apps/domain/lib/domain/policies/policy.ex | 17 + .../lib/domain/policies/policy/query.ex | 19 + elixir/apps/domain/lib/domain/pubsub.ex | 6 - elixir/apps/domain/lib/domain/relays.ex | 13 +- .../domain/lib/domain/replication/decoder.ex | 4 - elixir/apps/domain/lib/domain/resources.ex | 59 +- .../domain/lib/domain/resources/connection.ex | 8 + .../domain/lib/domain/resources/resource.ex | 23 + .../apps/domain/lib/domain/types/int4range.ex | 2 + elixir/apps/domain/lib/domain/types/ip.ex | 5 + elixir/apps/domain/priv/repo/seeds.exs | 2 +- .../hooks/accounts_test.exs | 30 +- .../hooks/actor_group_memberships_test.exs | 25 +- .../hooks/clients_test.exs | 43 +- .../{events => changes}/hooks/flows_test.exs | 16 +- .../hooks/gateway_groups_test.exs | 24 +- .../hooks/gateways_test.exs | 35 +- .../hooks/policies_test.exs | 57 +- .../hooks/resource_connections_test.exs | 38 +- .../hooks/resources_test.exs | 54 +- .../{events => changes}/hooks/tokens_test.exs | 54 +- .../replication_connection_test.exs | 41 +- .../apps/domain/test/domain/events_test.exs | 4 - elixir/apps/domain/test/domain/flows_test.exs | 257 +- .../apps/domain/test/domain/gateways_test.exs | 91 +- .../apps/domain/test/domain/policies_test.exs | 47 +- .../domain/test/domain/resources_test.exs | 148 +- .../domain/test/support/fixtures/policies.ex | 13 + .../domain/test/support/fixtures/resources.ex | 14 + .../apps/web/lib/web/live/policies/index.ex | 6 +- .../apps/web/lib/web/live/resources/index.ex | 7 +- .../web/test/web/acceptance/auth_test.exs | 2 +- .../web/test/web/live/policies/index_test.exs | 10 +- .../web/test/web/live/resources/edit_test.exs | 10 +- .../test/web/live/resources/index_test.exs | 7 +- elixir/config/config.exs | 10 +- elixir/config/runtime.exs | 6 +- elixir/config/test.exs | 6 +- 92 files changed, 6103 insertions(+), 3885 deletions(-) create mode 100644 elixir/apps/domain/lib/domain/cache/cacheable.ex create mode 100644 elixir/apps/domain/lib/domain/cache/cacheable/gateway_group.ex create mode 100644 elixir/apps/domain/lib/domain/cache/cacheable/policy.ex create mode 100644 elixir/apps/domain/lib/domain/cache/cacheable/resource.ex create mode 100644 elixir/apps/domain/lib/domain/cache/client.ex create mode 100644 elixir/apps/domain/lib/domain/cache/gateway.ex create mode 100644 elixir/apps/domain/lib/domain/changes/change.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/accounts.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/actor_group_memberships.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/clients.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/flows.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/gateway_groups.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/gateways.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/policies.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/resource_connections.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/resources.ex create mode 100644 elixir/apps/domain/lib/domain/changes/hooks/tokens.ex rename elixir/apps/domain/lib/domain/{events => changes}/replication_connection.ex (67%) delete mode 100644 elixir/apps/domain/lib/domain/events/hooks.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/accounts.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/actor_group_memberships.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/clients.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/flows.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/gateway_groups.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/gateways.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/policies.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/resource_connections.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/resources.ex delete mode 100644 elixir/apps/domain/lib/domain/events/hooks/tokens.ex rename elixir/apps/domain/test/domain/{events => changes}/hooks/accounts_test.exs (64%) rename elixir/apps/domain/test/domain/{events => changes}/hooks/actor_group_memberships_test.exs (75%) rename elixir/apps/domain/test/domain/{events => changes}/hooks/clients_test.exs (70%) rename elixir/apps/domain/test/domain/{events => changes}/hooks/flows_test.exs (78%) rename elixir/apps/domain/test/domain/{events => changes}/hooks/gateway_groups_test.exs (67%) rename elixir/apps/domain/test/domain/{events => changes}/hooks/gateways_test.exs (70%) rename elixir/apps/domain/test/domain/{events => changes}/hooks/policies_test.exs (85%) rename elixir/apps/domain/test/domain/{events => changes}/hooks/resource_connections_test.exs (61%) rename elixir/apps/domain/test/domain/{events => changes}/hooks/resources_test.exs (84%) rename elixir/apps/domain/test/domain/{events => changes}/hooks/tokens_test.exs (57%) rename elixir/apps/domain/test/domain/{events => changes}/replication_connection_test.exs (85%) delete mode 100644 elixir/apps/domain/test/domain/events_test.exs diff --git a/elixir/.gitignore b/elixir/.gitignore index f0b785e33..7609e3cc0 100644 --- a/elixir/.gitignore +++ b/elixir/.gitignore @@ -32,3 +32,7 @@ apps/*/screenshots # Uploads apps/web/priv/static/uploads + +# ElixirLS +# ElixirLS uses this directory to store its state. +.elixir_ls/ diff --git a/elixir/apps/api/lib/api/client/channel.ex b/elixir/apps/api/lib/api/client/channel.ex index e2b3aed60..ce6cae3bc 100644 --- a/elixir/apps/api/lib/api/client/channel.ex +++ b/elixir/apps/api/lib/api/client/channel.ex @@ -5,6 +5,8 @@ defmodule API.Client.Channel do alias Domain.{ Accounts, Clients, + Cache, + Changes.Change, Actors, PubSub, Resources, @@ -24,29 +26,15 @@ defmodule API.Client.Channel do # connlib state will be cleaned up so it can request a new connection. @recompute_authorized_resources_every :timer.minutes(1) - @gateway_compatibility [ - # We introduced new websocket protocol and the clients of version 1.4+ - # are only compatible with gateways of version 1.4+ - {">= 1.4.0", ">= 1.4.0"}, - # The clients of version of 1.1+ are compatible with gateways of version 1.1+, - # but the clients of versions prior to that can connect to any gateway - {">= 1.1.0", ">= 1.1.0"} - ] - #################################### ##### Channel lifecycle events ##### #################################### @impl true def join("client", _payload, socket) do - with {:ok, gateway_version_requirement} <- - select_gateway_version_requirement(socket.assigns.client) do - socket = assign(socket, gateway_version_requirement: gateway_version_requirement) + send(self(), :after_join) - send(self(), :after_join) - - {:ok, socket} - end + {:ok, socket} end @impl true @@ -60,11 +48,9 @@ defmodule API.Client.Channel do @recompute_authorized_resources_every ) - # Initialize the cache. - socket = - socket - |> hydrate_policies_and_resources() - |> hydrate_memberships() + # Get initial list of authorized resources, hydrating the cache + {:ok, resources, [], cache} = + Cache.Client.recompute_connectable_resources(nil, socket.assigns.client) # Initialize relays {:ok, relays} = select_relays(socket) @@ -80,17 +66,10 @@ defmodule API.Client.Channel do # Subscribe to all account updates :ok = PubSub.Account.subscribe(socket.assigns.client.account_id) - # Initialize resources - resources = authorized_resources(socket) - - # Save list of authorized resources in the socket to check against in - # the recompute_authorized_resources timer - socket = assign(socket, authorized_resource_ids: MapSet.new(Enum.map(resources, & &1.id))) - - # Delete any stale flows for resources we may not have access to anymore + # Delete any stale flows for resources we may not have access to anymore based on policy conditions Flows.delete_stale_flows_on_connect( socket.assigns.client, - resources + Enum.map(resources, &Ecto.UUID.load!(&1.id)) ) push(socket, "init", %{ @@ -108,35 +87,7 @@ defmodule API.Client.Channel do }) }) - {:noreply, socket} - end - - # Needed to keep the client's resource list up to date for time-based policy conditions - def handle_info(:recompute_authorized_resources, socket) do - Process.send_after( - self(), - :recompute_authorized_resources, - @recompute_authorized_resources_every - ) - - old_authorized_resources = - Map.take(socket.assigns.resources, MapSet.to_list(socket.assigns.authorized_resource_ids)) - |> Map.values() - - new_authorized_resources = authorized_resources(socket) - - for resource <- old_authorized_resources -- new_authorized_resources do - push(socket, "resource_deleted", resource.id) - end - - for resource <- new_authorized_resources -- old_authorized_resources do - push(socket, "resource_created_or_updated", Views.Resource.render(resource)) - end - - {:noreply, - assign(socket, - authorized_resource_ids: MapSet.new(Enum.map(new_authorized_resources, & &1.id)) - )} + {:noreply, assign(socket, cache: cache)} end # Called to actually push relays_presence with a disconnected relay to the client @@ -148,383 +99,48 @@ defmodule API.Client.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) - def handle_info( - {:updated, %Accounts.Account{} = old_account, %Accounts.Account{} = account}, - socket - ) do - # update our cached subject's account - socket = assign(socket, subject: %{socket.assigns.subject | account: account}) + if lsn <= last_lsn do + Logger.warning("Out of order or duplicate change received; ignoring", + change: change, + last_lsn: last_lsn + ) - if old_account.config != account.config do - payload = %{interface: Views.Interface.render(%{socket.assigns.client | account: account})} - :ok = push(socket, "config_changed", payload) + {:noreply, socket} + else + socket = assign(socket, last_lsn: lsn) + + handle_change(change, socket) end - - {:noreply, socket} end - # ACTOR_GROUP_MEMBERSHIPS + #################################### + ##### Reacting to timed events ##### + #################################### - def handle_info( - {:created, %Actors.Membership{actor_id: actor_id, group_id: group_id} = membership}, - %{assigns: %{client: %{actor_id: id}}} = socket - ) - when id == actor_id do - # 1. Get existing authorized resources - old_authorized_resources = - Map.take(socket.assigns.resources, MapSet.to_list(socket.assigns.authorized_resource_ids)) - |> Map.values() + # This is needed to keep the client's resource list up to date for time-based policy conditions + # since we will not receive any change messages to react to when time-based policies expire. + def handle_info(:recompute_authorized_resources, socket) do + Process.send_after( + self(), + :recompute_authorized_resources, + @recompute_authorized_resources_every + ) - # 2. Re-hydrate our policies and resources - # It's not ideal we're hitting the DB here, but in practice it shouldn't be an issue because - # periods of bursty membership creation typically only happen for new accounts or new directory - # syncs, which won't have any policies associated. - socket = hydrate_policies_and_resources(socket) + {:ok, added_resources, removed_ids, cache} = + Cache.Client.recompute_connectable_resources(socket.assigns.cache, socket.assigns.client) - # 3. Update our membership group IDs - memberships = Map.put(socket.assigns.memberships, group_id, membership) - socket = assign(socket, memberships: memberships) - - # 3. Compute new authorized resources - new_authorized_resources = authorized_resources(socket) - - # 4. Push new resources to the client - for resource <- new_authorized_resources -- old_authorized_resources do - push(socket, "resource_created_or_updated", Views.Resource.render(resource)) - end - - socket = - assign(socket, - authorized_resource_ids: MapSet.new(Enum.map(new_authorized_resources, & &1.id)) - ) - - {:noreply, socket} - end - - def handle_info( - {:deleted, %Actors.Membership{actor_id: actor_id, group_id: group_id}}, - %{assigns: %{client: %{actor_id: id}}} = socket - ) - when id == actor_id do - # 1. Take a snapshot of all resource_ids we no longer have access to - deleted_resource_ids = - socket.assigns.policies - |> Enum.flat_map(fn {_id, policy} -> - if policy.actor_group_id == group_id, do: [policy.resource_id], else: [] - end) - |> Enum.uniq() - - # 2. Push deleted resources to the client - for resource_id <- deleted_resource_ids do + for resource_id <- removed_ids do push(socket, "resource_deleted", resource_id) end - # 3. Update our state - policies = - socket.assigns.policies - |> Enum.filter(fn {_id, policy} -> policy.actor_group_id != group_id end) - |> Enum.into(%{}) - - r_ids = Enum.map(policies, fn {_id, policy} -> policy.resource_id end) |> Enum.uniq() - resources = Map.take(socket.assigns.resources, r_ids) - memberships = Map.delete(socket.assigns.memberships, group_id) - - socket = - socket - |> assign(policies: policies) - |> assign(resources: resources) - |> assign(memberships: memberships) - - {:noreply, socket} - end - - # CLIENTS - - # Changes in client verification can affect the list of allowed resources - send diff of resources - def handle_info( - {:updated, %Clients.Client{} = old_client, %Clients.Client{id: client_id} = client}, - %{assigns: %{client: %{id: id}}} = socket - ) - when id == client_id do - # 1. Snapshot existing authorized resources - old_authorized_resources = - Map.take(socket.assigns.resources, MapSet.to_list(socket.assigns.authorized_resource_ids)) - |> Map.values() - - # 2. Update our state - maintain preloaded identity - client = %{client | identity: socket.assigns.client.identity} - socket = assign(socket, client: client) - - # 3. If client's verification status changed, send diff of resources - socket = - if old_client.verified_at != client.verified_at do - new_authorized_resources = authorized_resources(socket) - - for resource <- new_authorized_resources -- old_authorized_resources do - push(socket, "resource_created_or_updated", Views.Resource.render(resource)) - end - - for resource <- old_authorized_resources -- new_authorized_resources do - push(socket, "resource_deleted", resource.id) - end - - assign(socket, - authorized_resource_ids: MapSet.new(Enum.map(new_authorized_resources, & &1.id)) - ) - else - socket - end - - {:noreply, socket} - end - - def handle_info( - {:deleted, %Clients.Client{id: id}}, - %{assigns: %{client: %{id: client_id}}} = socket - ) - when id == client_id do - disconnect(socket) - end - - # GATEWAY_GROUPS - - def handle_info( - {:updated, %Gateways.Group{} = old_group, %Gateways.Group{} = group}, - socket - ) do - resources = - socket.assigns.resources - |> Enum.map(fn {id, resource} -> - gateway_groups = - resource.gateway_groups - |> Enum.map(fn gg -> if gg.id == group.id, do: Map.merge(gg, group), else: gg end) - - # Send resource_created_or_updated for all resources that have this group if name has changed - if Enum.any?(gateway_groups, fn gg -> gg.name != old_group.name and gg.id == group.id end) do - push(socket, "resource_created_or_updated", Views.Resource.render(resource)) - end - - {id, %{resource | gateway_groups: gateway_groups}} - end) - |> Enum.into(%{}) - - socket = assign(socket, resources: resources) - - {:noreply, socket} - end - - # POLICIES - - def handle_info({:created, %Policies.Policy{} = policy}, socket) do - # 1. Check if this policy is for us - if Map.has_key?(socket.assigns.memberships, policy.actor_group_id) do - # 2. Snapshot existing resources - old_authorized_resources = - Map.take(socket.assigns.resources, MapSet.to_list(socket.assigns.authorized_resource_ids)) - |> Map.values() - - # 3. Hydrate a new resource if we aren't already tracking it - socket = - if Map.has_key?(socket.assigns.resources, policy.resource_id) do - # Resource already exists due to another policy - socket - else - {:ok, resource} = - Resources.fetch_resource_by_id(policy.resource_id, socket.assigns.subject, - preload: :gateway_groups - ) - - socket - |> assign(resources: Map.put(socket.assigns.resources, resource.id, resource)) - |> assign( - authorized_resource_ids: - MapSet.put(socket.assigns.authorized_resource_ids, resource.id) - ) - end - - # 4. Hydrate the new policy - socket = assign(socket, policies: Map.put(socket.assigns.policies, policy.id, policy)) - - # 5. Maybe send new resource - if resource = (authorized_resources(socket) -- old_authorized_resources) |> List.first() do - push(socket, "resource_created_or_updated", Views.Resource.render(resource)) - end - - {:noreply, socket} - else - {:noreply, socket} + for resource <- added_resources do + push(socket, "resource_created_or_updated", Views.Resource.render(resource)) end - end - def handle_info( - {:updated, - %Policies.Policy{ - resource_id: old_resource_id, - actor_group_id: old_actor_group_id, - conditions: old_conditions - } = old_policy, - %Policies.Policy{ - resource_id: resource_id, - actor_group_id: actor_group_id, - conditions: conditions, - disabled_at: disabled_at - } = policy}, - socket - ) - when old_resource_id != resource_id or old_actor_group_id != actor_group_id or - old_conditions != conditions do - # Breaking update - process this as a delete and then create - {:noreply, socket} = handle_info({:deleted, old_policy}, socket) - - if is_nil(disabled_at) do - handle_info({:created, policy}, socket) - else - {:noreply, socket} - end - end - - # Other update - just update our state if the policy is for us - def handle_info({:updated, %Policies.Policy{}, %Policies.Policy{} = policy}, socket) do - socket = - if Map.has_key?(socket.assigns.policies, policy.id) do - assign(socket, policies: Map.put(socket.assigns.policies, policy.id, policy)) - else - socket - end - - {:noreply, socket} - end - - def handle_info({:deleted, %Policies.Policy{} = policy}, socket) do - # 1. Check if this policy is for us - if Map.has_key?(socket.assigns.policies, policy.id) do - # 2. Snapshot existing resources - old_authorized_resources = - Map.take(socket.assigns.resources, MapSet.to_list(socket.assigns.authorized_resource_ids)) - |> Map.values() - - # 3. Update our state - socket = assign(socket, policies: Map.delete(socket.assigns.policies, policy.id)) - r_ids = Enum.map(socket.assigns.policies, fn {_id, p} -> p.resource_id end) |> Enum.uniq() - socket = assign(socket, resources: Map.take(socket.assigns.resources, r_ids)) - - authorized_resources = authorized_resources(socket) - - socket = - assign(socket, - authorized_resource_ids: MapSet.new(Enum.map(authorized_resources, & &1.id)) - ) - - # 4. Push deleted resource to the client if we lost access to it - if resource = (old_authorized_resources -- authorized_resources) |> List.first() do - push(socket, "resource_deleted", resource.id) - end - - {:noreply, socket} - else - {:noreply, socket} - end - end - - # RESOURCE_CONNECTIONS - - def handle_info( - {:created, - %Resources.Connection{resource_id: resource_id, gateway_group_id: gateway_group_id}}, - socket - ) do - # 1. Check if this affects us - if resource = socket.assigns.resources[resource_id] do - # 2. Fetch the gateway_group to hydrate the site name - {:ok, gateway_group} = Gateways.fetch_group_by_id(gateway_group_id, socket.assigns.subject) - - # 3. Update our state - resource = %{resource | gateway_groups: resource.gateway_groups ++ [gateway_group]} - socket = assign(socket, resources: Map.put(socket.assigns.resources, resource_id, resource)) - - # 4. If resource is allowed, push - if MapSet.member?(socket.assigns.authorized_resource_ids, resource.id) do - # Connlib doesn't handle resources changing sites, so we need to delete then create - # See https://github.com/firezone/firezone/issues/9881 - push(socket, "resource_deleted", resource.id) - push(socket, "resource_created_or_updated", Views.Resource.render(resource)) - end - - {:noreply, socket} - else - {:noreply, socket} - end - end - - # Resource connection is a required field on resources, so a delete will always be followed by a create. - # This means connlib *should* never see a resource with no sites (gateway_groups). - - def handle_info( - {:deleted, - %Resources.Connection{resource_id: resource_id, gateway_group_id: gateway_group_id}}, - socket - ) do - # 1. Check if this affects us - if resource = socket.assigns.resources[resource_id] do - # 2. Update our state - gateway_groups = Enum.reject(resource.gateway_groups, &(&1.id == gateway_group_id)) - resource = %{resource | gateway_groups: gateway_groups} - socket = assign(socket, resources: Map.put(socket.assigns.resources, resource_id, resource)) - - # 3. Tell connlib - push(socket, "resource_deleted", resource.id) - - # 4. If resource is allowed, and has at least one site connected, push - if MapSet.member?(socket.assigns.authorized_resource_ids, resource.id) and - length(resource.gateway_groups) > 0 do - # Connlib doesn't handle resources changing sites, so we need to delete then create - # See https://github.com/firezone/firezone/issues/9881 - push(socket, "resource_created_or_updated", Views.Resource.render(resource)) - end - - {:noreply, socket} - else - {:noreply, socket} - end - end - - # RESOURCES - - def handle_info( - {:updated, %Resources.Resource{} = old_resource, %Resources.Resource{id: id} = resource}, - socket - ) do - # 1. Check if this affects us - if existing_resource = socket.assigns.resources[id] do - # 2. Update our state - take gateway_groups from existing resource - resource = %{resource | gateway_groups: existing_resource.gateway_groups} - - socket = - assign(socket, - resources: Map.put(socket.assigns.resources, id, resource) - ) - - # 3. If resource is allowed and had meaningful changes, push - # GatewayGroup changes are handled in the Resources.Connection handler - resource_changed? = - old_resource.ip_stack != resource.ip_stack or - old_resource.type != resource.type or - old_resource.filters != resource.filters or - old_resource.address != resource.address or - old_resource.address_description != resource.address_description or - old_resource.name != resource.name - - if MapSet.member?(socket.assigns.authorized_resource_ids, resource.id) and - resource_changed? do - push(socket, "resource_created_or_updated", Views.Resource.render(resource)) - end - - {:noreply, socket} - else - {:noreply, socket} - end + {:noreply, assign(socket, cache: cache)} end #################################### @@ -547,8 +163,7 @@ defmodule API.Client.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) @@ -585,8 +200,7 @@ defmodule API.Client.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) @@ -659,14 +273,14 @@ defmodule API.Client.Channel do # DEPRECATED IN 1.4 # This message is sent by the gateway when it is ready to accept the connection from the client def handle_info( - {:connect, socket_ref, resource_id, gateway_public_key, payload}, + {:connect, socket_ref, rid_bytes, gateway_public_key, payload}, socket ) do reply( socket_ref, {:ok, %{ - resource_id: resource_id, + resource_id: Ecto.UUID.load!(rid_bytes), persistent_keepalive: 25, gateway_public_key: gateway_public_key, gateway_payload: payload @@ -677,12 +291,12 @@ defmodule API.Client.Channel do end def handle_info( - {:connect, _socket_ref, resource_id, gateway_group_id, gateway_id, gateway_public_key, + {:connect, _socket_ref, rid_bytes, gateway_group_id, gateway_id, gateway_public_key, gateway_ipv4, gateway_ipv6, preshared_key, ice_credentials}, socket ) do reply_payload = %{ - resource_id: resource_id, + resource_id: Ecto.UUID.load!(rid_bytes), preshared_key: preshared_key, client_ice_credentials: ice_credentials.client, gateway_group_id: gateway_group_id, @@ -717,19 +331,24 @@ defmodule API.Client.Channel do }, socket ) do - location = { - socket.assigns.client.last_seen_remote_ip_location_lat, - socket.assigns.client.last_seen_remote_ip_location_lon - } - - with {:ok, resource} <- Map.fetch(socket.assigns.resources, resource_id), - {:ok, expires_at, policy} <- authorize_resource(socket, resource_id), - {:ok, gateways} when gateways != [] <- - Gateways.all_connected_gateways_for_resource(resource, socket.assigns.subject, - preload: :group + with {:ok, resource, membership_id, policy_id, expires_at} <- + Cache.Client.authorize_resource( + socket.assigns.cache, + socket.assigns.client, + resource_id, + socket.assigns.subject ), - {:ok, gateways} <- - filter_compatible_gateways(gateways, socket.assigns.gateway_version_requirement) do + {:ok, gateways} when gateways != [] <- + Gateways.all_compatible_gateways_for_client_and_resource( + socket.assigns.client, + resource, + socket.assigns.subject + ) do + location = { + socket.assigns.client.last_seen_remote_ip_location_lat, + socket.assigns.client.last_seen_remote_ip_location_lon + } + gateway = Gateways.load_balance_gateways(location, gateways, connected_gateway_ids) # TODO: Optimization @@ -739,8 +358,8 @@ defmodule API.Client.Channel do socket.assigns.client, gateway, resource_id, - policy, - Map.fetch!(socket.assigns.memberships, policy.actor_group_id).id, + policy_id, + membership_id, socket.assigns.subject, expires_at ) @@ -772,26 +391,11 @@ defmodule API.Client.Channel do {:noreply, socket} - {:error, :offline} -> + {:error, {:forbidden, violated_properties: violated_properties}} -> push(socket, "flow_creation_failed", %{ resource_id: resource_id, - reason: :offline - }) - - {:noreply, socket} - - {:error, :forbidden} -> - push(socket, "flow_creation_failed", %{ - resource_id: resource_id, - reason: :forbidden - }) - - {:noreply, socket} - - :error -> - push(socket, "flow_creation_failed", %{ - resource_id: resource_id, - reason: :not_found + reason: :forbidden, + violated_properties: violated_properties }) {:noreply, socket} @@ -802,15 +406,6 @@ defmodule API.Client.Channel do reason: :offline }) - {:noreply, socket} - - {:error, {:forbidden, violated_properties: violated_properties}} -> - push(socket, "flow_creation_failed", %{ - resource_id: resource_id, - reason: :forbidden, - violated_properties: violated_properties - }) - {:noreply, socket} end end @@ -827,18 +422,19 @@ defmodule API.Client.Channel do # TODO: Optimization # Gateway selection and flow authorization shouldn't need to hit the DB - with {:ok, resource} <- Map.fetch(socket.assigns.resources, resource_id), - {:ok, _expires_at, _policy} <- authorize_resource(socket, resource_id), - {:ok, [_ | _] = gateways} <- - Gateways.all_connected_gateways_for_resource(resource, socket.assigns.subject, - preload: :group + with {:ok, resource, _membership_id, _policy_id, _expires_at} <- + Cache.Client.authorize_resource( + socket.assigns.cache, + socket.assigns.client, + resource_id, + socket.assigns.subject ), - gateway_version_requirement = - maybe_update_gateway_version_requirement( + {:ok, gateways} when gateways != [] <- + Gateways.all_compatible_gateways_for_client_and_resource( + socket.assigns.client, resource, - socket.assigns.gateway_version_requirement - ), - {:ok, gateways} <- filter_compatible_gateways(gateways, gateway_version_requirement) do + socket.assigns.subject + ) do location = { socket.assigns.client.last_seen_remote_ip_location_lat, socket.assigns.client.last_seen_remote_ip_location_lon @@ -857,14 +453,11 @@ defmodule API.Client.Channel do {:reply, reply, socket} else - {:ok, []} -> - {:reply, {:error, %{reason: :offline}}, socket} - {:error, :not_found} -> {:reply, {:error, %{reason: :not_found}}, socket} - :error -> - {:reply, {:error, %{reason: :not_found}}, socket} + {:ok, []} -> + {:reply, {:error, %{reason: :offline}}, socket} {:error, {:forbidden, violated_properties: violated_properties}} -> {:reply, {:error, %{reason: :forbidden, violated_properties: violated_properties}}, @@ -884,18 +477,28 @@ defmodule API.Client.Channel do }, socket ) do - with {:ok, resource} <- Map.fetch(socket.assigns.resources, resource_id), - {:ok, expires_at, policy} <- authorize_resource(socket, resource_id), - {:ok, gateway} <- Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject), - true <- Gateways.gateway_can_connect_to_resource?(gateway, resource) do + with {:ok, resource, membership_id, policy_id, expires_at} <- + Cache.Client.authorize_resource( + socket.assigns.cache, + socket.assigns.client, + resource_id, + socket.assigns.subject + ), + {:ok, gateway} <- + Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject, preload: :online?), + %Cache.Cacheable.GatewayGroup{} <- + Enum.find(resource.gateway_groups, {:error, :not_found}, fn g -> + g.id == Ecto.UUID.dump!(gateway.group_id) + end), + true <- gateway.online? do # TODO: Optimization {:ok, flow} = Flows.create_flow( socket.assigns.client, gateway, resource_id, - policy, - Map.fetch!(socket.assigns.memberships, policy.actor_group_id).id, + policy_id, + membership_id, socket.assigns.subject, expires_at ) @@ -918,9 +521,6 @@ defmodule API.Client.Channel do {:error, :not_found} -> {:reply, {:error, %{reason: :not_found}}, socket} - :error -> - {:reply, {:error, %{reason: :not_found}}, socket} - {:error, {:forbidden, violated_properties: violated_properties}} -> {:reply, {:error, %{reason: :forbidden, violated_properties: violated_properties}}, socket} @@ -944,18 +544,28 @@ defmodule API.Client.Channel do socket ) do # Flow authorization can happen out-of-band since we just authorized the resource above - with {:ok, resource} <- Map.fetch(socket.assigns.resources, resource_id), - {:ok, expires_at, policy} <- authorize_resource(socket, resource_id), - {:ok, gateway} <- Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject), - true <- Gateways.gateway_can_connect_to_resource?(gateway, resource) do + with {:ok, resource, membership_id, policy_id, expires_at} <- + Cache.Client.authorize_resource( + socket.assigns.cache, + socket.assigns.client, + resource_id, + socket.assigns.subject + ), + {:ok, gateway} <- + Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject, preload: :online?), + %Cache.Cacheable.GatewayGroup{} <- + Enum.find(resource.gateway_groups, {:error, :not_found}, fn g -> + g.id == Ecto.UUID.dump!(gateway.group_id) + end), + true <- gateway.online? do # TODO: Optimization {:ok, flow} = Flows.create_flow( socket.assigns.client, gateway, resource_id, - policy, - Map.fetch!(socket.assigns.memberships, policy.actor_group_id).id, + policy_id, + membership_id, socket.assigns.subject, expires_at ) @@ -979,9 +589,6 @@ defmodule API.Client.Channel do {:error, :not_found} -> {:reply, {:error, %{reason: :not_found}}, socket} - :error -> - {:reply, {:error, %{reason: :not_found}}, socket} - {:error, {:forbidden, violated_properties: violated_properties}} -> {:reply, {:error, %{reason: :forbidden, violated_properties: violated_properties}}, socket} @@ -1053,88 +660,6 @@ defmodule API.Client.Channel do end end - defp select_gateway_version_requirement(client) do - case Version.parse(client.last_seen_version) do - {:ok, _version} -> - gateway_version_requirement = - Enum.find_value( - @gateway_compatibility, - fn {client_version_requirement, gateway_version_requirement} -> - if Version.match?(client.last_seen_version, client_version_requirement) do - gateway_version_requirement - end - end - ) - - {:ok, gateway_version_requirement || "> 0.0.0"} - - :error -> - {:error, %{reason: :invalid_version}} - end - end - - # DEPRECATED IN 1.4 - defp maybe_update_gateway_version_requirement(resource, gateway_version_requirement) do - case map_or_drop_compatible_resource(resource, "1.0.0") do - {:cont, _resource} -> - gateway_version_requirement - - :drop -> - if resource.type == :internet do - ">= 1.3.0" - else - ">= 1.2.0" - end - end - end - - defp filter_compatible_gateways(gateways, gateway_version_requirement) do - gateways - |> Enum.filter(fn gateway -> - Version.match?(gateway.last_seen_version, gateway_version_requirement) - end) - |> case do - [] -> {:error, :not_found} - gateways -> {:ok, gateways} - end - end - - # DEPRECATED IN 1.4 - defp map_and_filter_compatible_resources(resources, client_version) do - Enum.flat_map(resources, fn resource -> - case map_or_drop_compatible_resource(resource, client_version) do - {:cont, resource} -> [resource] - :drop -> [] - end - end) - end - - # DEPRECATED IN 1.4 - def map_or_drop_compatible_resource(resource, client_or_gateway_version) do - cond do - resource.gateway_groups == [] -> - :drop - - resource.type == :internet and Version.match?(client_or_gateway_version, ">= 1.3.0") -> - {:cont, resource} - - resource.type == :internet -> - :drop - - Version.match?(client_or_gateway_version, ">= 1.2.0") -> - {:cont, resource} - - true -> - resource.address - |> String.codepoints() - |> Resources.map_resource_address() - |> case do - {:cont, address} -> {:cont, %{resource | address: address}} - :drop -> :drop - end - end - end - defp generate_preshared_key(client, gateway) do Domain.Crypto.psk(client, gateway) end @@ -1180,89 +705,247 @@ defmodule API.Client.Channel do } end - defp disconnect(socket) do - push(socket, "disconnect", %{reason: :token_expired}) - send(socket.transport_pid, %Phoenix.Socket.Broadcast{event: "disconnect"}) + ########################################## + #### Handling changes from the domain #### + ########################################## + + # ACCOUNTS + + defp handle_change( + %Change{ + op: :update, + old_struct: %Accounts.Account{} = old_account, + struct: %Accounts.Account{} = account + }, + socket + ) do + # Update our subject's account + subject = %{socket.assigns.subject | account: account} + socket = assign(socket, subject: subject) + + if old_account.config != account.config do + client = %{socket.assigns.client | account: account} + payload = %{interface: Views.Interface.render(client)} + :ok = push(socket, "config_changed", payload) + end + + {:noreply, socket} + end + + # ACTOR_GROUP_MEMBERSHIPS + + defp handle_change( + %Change{op: :insert, struct: %Actors.Membership{actor_id: actor_id}}, + %{assigns: %{client: %{actor_id: id}}} = socket + ) + when id == actor_id do + Cache.Client.add_membership(socket.assigns.cache, socket.assigns.client) + |> push_resource_updates(socket) + end + + defp handle_change( + %Change{ + op: :delete, + old_struct: %Actors.Membership{actor_id: actor_id} = membership + }, + %{assigns: %{client: %{actor_id: id}}} = socket + ) + when id == actor_id do + Cache.Client.delete_membership(socket.assigns.cache, membership, socket.assigns.client) + |> push_resource_updates(socket) + end + + # CLIENTS + + defp handle_change( + %Change{ + op: :update, + old_struct: %Clients.Client{} = old_client, + struct: %Clients.Client{id: client_id} = client + }, + %{assigns: %{client: %{id: id}}} = socket + ) + when id == client_id do + # Maintain our preloaded identity + client = %{client | identity: socket.assigns.client.identity} + socket = assign(socket, client: client) + + # Changes in client verification can affect the list of allowed resources + if old_client.verified_at != client.verified_at do + Cache.Client.recompute_connectable_resources(socket.assigns.cache, socket.assigns.client) + |> push_resource_updates(socket) + else + {:noreply, socket} + end + end + + defp handle_change( + %Change{op: :delete, old_struct: %Clients.Client{id: id}}, + %{assigns: %{client: %{id: client_id}}} = socket + ) + when id == client_id do + # TODO: Hard delete + # Deleting a client won't necessary delete its tokens in the case of a headless client. + # So we explicitly handle the deleted client here by forcing it to reconnect. {:stop, :shutdown, socket} end - # TODO: Optimization - # We can reduce memory usage of this cache by an order of magnitude by storing - # optimized versions of the fields we need to evaluate policy conditions and - # render data to the client. - defp hydrate_policies_and_resources(socket) do - OpenTelemetry.Tracer.with_span "client.hydrate_policies_and_resources", - attributes: %{ - account_id: socket.assigns.client.account_id - } do - {_policies, acc} = - Policies.all_policies_for_actor!(socket.assigns.subject.actor) - |> Enum.map_reduce(%{policies: %{}, resources: %{}}, fn policy, acc -> - resources = Map.put(acc.resources, policy.resource_id, policy.resource) + # GATEWAY_GROUPS - # Remove resource from policy to avoid storing twice - policies = Map.put(acc.policies, policy.id, Map.delete(policy, :resource)) + defp handle_change( + %Change{ + op: :update, + old_struct: %Gateways.Group{name: old_name}, + struct: %Gateways.Group{name: name} = group + }, + socket + ) + when old_name != name do + Cache.Client.update_resources_with_group_name( + socket.assigns.cache, + group, + socket.assigns.client + ) + |> push_resource_updates(socket) + end - {policy, Map.merge(acc, %{policies: policies, resources: resources})} - end) + # POLICIES - assign(socket, - policies: acc.policies, - resources: acc.resources - ) + defp handle_change( + %Change{op: :insert, struct: %Policies.Policy{} = policy}, + socket + ) do + Cache.Client.add_policy( + socket.assigns.cache, + policy, + socket.assigns.client, + socket.assigns.subject + ) + |> push_resource_updates(socket) + end + + defp handle_change( + %Change{ + op: :update, + old_struct: %Policies.Policy{ + resource_id: old_resource_id, + actor_group_id: old_actor_group_id, + conditions: old_conditions + }, + struct: %Policies.Policy{ + resource_id: resource_id, + actor_group_id: actor_group_id, + conditions: conditions, + disabled_at: disabled_at + } + } = change, + socket + ) + when old_resource_id != resource_id or old_actor_group_id != actor_group_id or + old_conditions != conditions do + # TODO: Optimization + # Breaking update - process this as a delete and then create to make our lives easier. + # We could be smarter here and process the individual side effects more cleverly to avoid + # sending resource_deleted and resource_created_or_updated if the policy is not actually changing + # the client's connectable_resources. + {:noreply, socket} = handle_change(%{change | op: :delete}, socket) + + # DO NOT re-add disabled policies + if is_nil(disabled_at) do + handle_change(%{change | op: :insert}, socket) + else + {:noreply, socket} end end - defp hydrate_memberships(socket) do - OpenTelemetry.Tracer.with_span "client.hydrate_memberships", - attributes: %{ - account_id: socket.assigns.client.account_id - } do - memberships = - Actors.all_memberships_for_actor!(socket.assigns.subject.actor) - |> Enum.map(fn membership -> - {membership.group_id, membership} - end) - |> Enum.into(%{}) - - assign(socket, memberships: memberships) - end + # Other update, i.e. description - just update our state + defp handle_change( + %Change{ + op: :update, + old_struct: %Policies.Policy{}, + struct: %Policies.Policy{} = policy + }, + socket + ) do + Cache.Client.update_policy(socket.assigns.cache, policy) + |> push_resource_updates(socket) end - defp authorized_resources(socket) do - OpenTelemetry.Tracer.with_span "client.authorized_resources", - attributes: %{ - account_id: socket.assigns.client.account_id - } do - client = socket.assigns.client - - resource_ids = - socket.assigns.policies - |> Map.values() - |> Policies.filter_by_conforming_policies_for_client(client) - |> Enum.map(& &1.resource_id) - |> Enum.uniq() - - socket.assigns.resources - |> Map.take(resource_ids) - |> Map.values() - |> map_and_filter_compatible_resources(client.last_seen_version) - end + defp handle_change( + %Change{op: :delete, old_struct: %Policies.Policy{} = policy}, + socket + ) do + Cache.Client.delete_policy(socket.assigns.cache, policy, socket.assigns.client) + |> push_resource_updates(socket) end - # Returns either the longest authorized policy or an error tuple of violated properties - defp authorize_resource(socket, resource_id) do - OpenTelemetry.Tracer.with_span "client.authorize_resource", - attributes: %{ - account_id: socket.assigns.client.account_id - } do - socket.assigns.policies - |> Enum.filter(fn {_id, policy} -> policy.resource_id == resource_id end) - |> Enum.map(fn {_id, policy} -> policy end) - |> Policies.longest_conforming_policy_for_client( - socket.assigns.client, - socket.assigns.subject.expires_at - ) + # RESOURCE_CONNECTIONS + + defp handle_change( + %Change{ + op: :insert, + struct: %Resources.Connection{} = connection + }, + socket + ) do + Cache.Client.add_resource_connection( + socket.assigns.cache, + connection, + socket.assigns.subject, + socket.assigns.client + ) + |> push_resource_updates(socket) + end + + defp handle_change( + %Change{ + op: :delete, + old_struct: %Resources.Connection{} = connection + }, + socket + ) do + Cache.Client.delete_resource_connection( + socket.assigns.cache, + connection, + socket.assigns.client + ) + |> push_resource_updates(socket) + end + + # RESOURCES + + defp handle_change( + %Change{ + op: :update, + old_struct: %Resources.Resource{}, + struct: %Resources.Resource{} = resource + }, + socket + ) do + Cache.Client.update_resource( + socket.assigns.cache, + resource, + socket.assigns.client + ) + |> push_resource_updates(socket) + end + + defp handle_change(%Change{}, socket), do: {:noreply, socket} + + defp push_resource_updates({:ok, added_resources, removed_ids, cache}, socket) do + # TODO: Multi-site resources + # Currently, connlib doesn't handle resources changing sites, so we need to delete then create. + # We handle that scenario by sending resource_deleted then resource_created_or_updated, so it's + # important that deletions are processed first here. + # See https://github.com/firezone/firezone/issues/9881 + for resource_id <- removed_ids do + push(socket, "resource_deleted", resource_id) end + + for resource <- added_resources do + push(socket, "resource_created_or_updated", Views.Resource.render(resource)) + end + + {:noreply, assign(socket, cache: cache)} end end diff --git a/elixir/apps/api/lib/api/client/socket.ex b/elixir/apps/api/lib/api/client/socket.ex index 9dfdfe428..4e14c283e 100644 --- a/elixir/apps/api/lib/api/client/socket.ex +++ b/elixir/apps/api/lib/api/client/socket.ex @@ -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 diff --git a/elixir/apps/api/lib/api/client/views/gateway_group.ex b/elixir/apps/api/lib/api/client/views/gateway_group.ex index 902879d54..91cab2696 100644 --- a/elixir/apps/api/lib/api/client/views/gateway_group.ex +++ b/elixir/apps/api/lib/api/client/views/gateway_group.ex @@ -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 diff --git a/elixir/apps/api/lib/api/client/views/resource.ex b/elixir/apps/api/lib/api/client/views/resource.ex index 9110a36f5..a51a873dc 100644 --- a/elixir/apps/api/lib/api/client/views/resource.ex +++ b/elixir/apps/api/lib/api/client/views/resource.ex @@ -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 diff --git a/elixir/apps/api/lib/api/gateway/channel.ex b/elixir/apps/api/lib/api/gateway/channel.ex index 8e350e35e..99c00ddbc 100644 --- a/elixir/apps/api/lib/api/gateway/channel.ex +++ b/elixir/apps/api/lib/api/gateway/channel.ex @@ -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 diff --git a/elixir/apps/api/lib/api/gateway/views/flow.ex b/elixir/apps/api/lib/api/gateway/views/flow.ex index abd6a3f6a..092e678bd 100644 --- a/elixir/apps/api/lib/api/gateway/views/flow.ex +++ b/elixir/apps/api/lib/api/gateway/views/flow.ex @@ -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 diff --git a/elixir/apps/api/lib/api/gateway/views/resource.ex b/elixir/apps/api/lib/api/gateway/views/resource.ex index c543447dc..c27f402b4 100644 --- a/elixir/apps/api/lib/api/gateway/views/resource.ex +++ b/elixir/apps/api/lib/api/gateway/views/resource.ex @@ -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 diff --git a/elixir/apps/api/test/api/client/channel_test.exs b/elixir/apps/api/test/api/client/channel_test.exs index 1370e6525..360139460 100644 --- a/elixir/apps/api/test/api/client/channel_test.exs +++ b/elixir/apps/api/test/api/client/channel_test.exs @@ -1,6 +1,7 @@ defmodule API.Client.ChannelTest do use API.ChannelCase, async: true - alias Domain.Clients + alias Domain.{Clients, Changes, Gateways, PubSub, Resources} + import ExUnit.CaptureLog setup do account = @@ -204,11 +205,7 @@ defmodule API.Client.ChannelTest do client: client, subject: subject } do - # We need to trap exits to avoid test process termination - # because it is linked to the created test channel process - Process.flag(:trap_exit, true) - - :ok = Domain.PubSub.subscribe("sessions:#{subject.token_id}") + :ok = PubSub.subscribe("sessions:#{subject.token_id}") {:ok, _reply, _socket} = API.Client.Socket @@ -227,7 +224,7 @@ defmodule API.Client.ChannelTest do "expires_at" => token.expires_at } - Domain.Events.Hooks.Tokens.on_delete(data) + Domain.Changes.Hooks.Tokens.on_delete(100, data) assert_receive %Phoenix.Socket.Broadcast{ topic: topic, @@ -237,42 +234,6 @@ defmodule API.Client.ChannelTest do assert topic == "sessions:#{token.id}" end - test "selects compatible gateway versions", %{client: client, subject: subject} do - client = %{client | last_seen_version: "1.0.99"} - - {:ok, _reply, socket} = - API.Client.Socket - |> socket("client:#{client.id}", %{ - client: client, - subject: subject - }) - |> subscribe_and_join(API.Client.Channel, "client") - - assert socket.assigns.gateway_version_requirement == "> 0.0.0" - - client = %{client | last_seen_version: "1.1.99"} - - {:ok, _reply, socket} = - API.Client.Socket - |> socket("client:#{client.id}", %{ - client: client, - subject: subject - }) - |> subscribe_and_join(API.Client.Channel, "client") - - assert socket.assigns.gateway_version_requirement == ">= 1.1.0" - - client = %{client | last_seen_version: "development"} - - assert API.Client.Socket - |> socket("client:#{client.id}", %{ - client: client, - subject: subject - }) - |> subscribe_and_join(API.Client.Channel, "client") == - {:error, %{reason: :invalid_version}} - end - test "sends list of available resources after join", %{ client: client, internet_gateway_group: internet_gateway_group, @@ -669,1889 +630,86 @@ defmodule API.Client.ChannelTest do end end - describe "handle_info/2" do - # test "subscribes for client events", %{ - # client: client - # } do - # assert_push "init", %{} - # Process.flag(:trap_exit, true) - # PubSub.Client.broadcast(client.id, :token_expired) - # assert_push "disconnect", %{reason: :token_expired}, 250 - # end - - # test "subscribes for resource events", %{ - # dns_resource: resource, - # subject: subject - # } do - # assert_push "init", %{} - # - # {:ok, _resource} = Domain.Resources.update_resource(resource, %{name: "foobar"}, subject) - # - # old_data = %{ - # "id" => resource.id, - # "account_id" => resource.account_id, - # "address" => resource.address, - # "name" => resource.name, - # "type" => "dns", - # "filters" => [], - # "ip_stack" => "dual" - # } - # - # data = Map.put(old_data, "name", "new name") - # Events.Hooks.Resources.on_update(old_data, data) - # - # assert_push "resource_created_or_updated", %{} - # end - - # test "subscribes for policy events", %{ - # dns_resource_policy: dns_resource_policy, - # subject: subject - # } do - # assert_push "init", %{} - # {:ok, policy} = Domain.Policies.disable_policy(dns_resource_policy, subject) - # - # # Simulate disable - # old_data = %{ - # "id" => policy.id, - # "account_id" => policy.account_id, - # "resource_id" => policy.resource_id, - # "actor_group_id" => policy.actor_group_id, - # "conditions" => [], - # "disabled_at" => nil - # } - # - # data = Map.put(old_data, "disabled_at", "2024-01-01T00:00:00Z") - # Events.Hooks.Policies.on_update(old_data, data) - # - # assert_push "resource_deleted", _payload - # refute_push "resource_created_or_updated", _payload - # end - - # describe "handle_info/2 :config_changed" do - # test "sends updated configuration", %{ - # account: account, - # client: client, - # socket: socket - # } do - # channel_pid = socket.channel_pid - # - # Fixtures.Accounts.update_account( - # account, - # config: %{ - # clients_upstream_dns: [ - # %{protocol: "ip_port", address: "1.2.3.1"}, - # %{protocol: "ip_port", address: "1.8.8.1:53"} - # ], - # search_domain: "example.com" - # } - # ) - # - # send(channel_pid, :config_changed) - # - # assert_push "config_changed", %{interface: interface} - # - # assert interface == %{ - # ipv4: client.ipv4, - # ipv6: client.ipv6, - # upstream_dns: [ - # %{protocol: :ip_port, address: "1.2.3.1:53"}, - # %{protocol: :ip_port, address: "1.8.8.1:53"} - # ], - # search_domain: "example.com" - # } - # end - # end - - # describe "handle_info/2 {:updated, client}" do - # test "sends init message when breaking fields change", %{ - # socket: socket, - # client: client - # } do - # assert_push "init", %{} - # - # updated_client = %{client | verified_at: DateTime.utc_now()} - # send(socket.channel_pid, {:updated, updated_client}) - # assert_push "init", %{} - # end - # - # test "does not send init message when name changes", %{ - # socket: socket, - # client: client - # } do - # assert_push "init", %{} - # - # send(socket.channel_pid, {:updated, %{client | name: "New Name"}}) - # - # refute_push "init", %{} - # end - # end - - # describe "handle_info/2 :token_expired" do - # test "sends a token_expired messages and closes the socket", %{ - # socket: socket - # } do - # Process.flag(:trap_exit, true) - # channel_pid = socket.channel_pid - # - # send(channel_pid, :token_expired) - # assert_push "disconnect", %{reason: :token_expired} - # - # assert_receive {:EXIT, ^channel_pid, {:shutdown, :token_expired}} - # end - # end - - test "pushes ice_candidates message", %{ - client: client, - gateway: gateway, - socket: socket - } do - candidates = ["foo", "bar"] - - send( - socket.channel_pid, - {{:ice_candidates, client.id}, gateway.id, candidates} - ) - - assert_push "ice_candidates", payload - - assert payload == %{ - candidates: candidates, - gateway_id: gateway.id - } - end - - test "pushes invalidate_ice_candidates message", %{ - client: client, - gateway: gateway, - socket: socket - } do - candidates = ["foo", "bar"] - - send( - socket.channel_pid, - {{:invalidate_ice_candidates, client.id}, gateway.id, candidates} - ) - - assert_push "invalidate_ice_candidates", payload - - assert payload == %{ - candidates: candidates, - gateway_id: gateway.id - } - end - - # test "pushes message to the socket for authorized clients", %{ - # gateway_group: gateway_group, - # dns_resource: resource, - # socket: socket - # } do - # send(socket.channel_pid, {:create_resource, resource.id}) - # - # assert_push "resource_created_or_updated", payload - # - # assert payload == %{ - # id: resource.id, - # type: :dns, - # ip_stack: :ipv4_only, - # name: resource.name, - # address: resource.address, - # address_description: resource.address_description, - # gateway_groups: [ - # %{id: gateway_group.id, name: gateway_group.name} - # ], - # filters: [ - # %{protocol: :tcp, port_range_end: 80, port_range_start: 80}, - # %{protocol: :tcp, port_range_end: 433, port_range_start: 433}, - # %{protocol: :udp, port_range_end: 200, port_range_start: 100}, - # %{protocol: :icmp} - # ] - # } - # end - # - # test "does not push resources that can't be access by the client", %{ - # nonconforming_resource: resource, - # socket: socket - # } do - # send(socket.channel_pid, {:create_resource, resource.id}) - # refute_push "resource_created_or_updated", %{} - # end - # end - - # test "pushes message to the socket for authorized clients", %{ - # gateway_group: gateway_group, - # dns_resource: resource, - # socket: socket - # } do - # send(socket.channel_pid, {:update_resource, resource.id}) - # - # assert_push "resource_created_or_updated", payload - # - # assert payload == %{ - # id: resource.id, - # type: :dns, - # ip_stack: :ipv4_only, - # name: resource.name, - # address: resource.address, - # address_description: resource.address_description, - # gateway_groups: [ - # %{id: gateway_group.id, name: gateway_group.name} - # ], - # filters: [ - # %{protocol: :tcp, port_range_end: 80, port_range_start: 80}, - # %{protocol: :tcp, port_range_end: 433, port_range_start: 433}, - # %{protocol: :udp, port_range_end: 200, port_range_start: 100}, - # %{protocol: :icmp} - # ] - # } - # end - # - # test "does not push resources that can't be access by the client", %{ - # nonconforming_resource: resource, - # socket: socket - # } do - # send(socket.channel_pid, {:update_resource, resource.id}) - # refute_push "resource_created_or_updated", %{} - # end - - # test "does nothing", %{ - # dns_resource: resource, - # socket: socket - # } do - # send(socket.channel_pid, {:delete_resource, resource.id}) - # refute_push "resource_deleted", %{} - # end - - # test "subscribes for policy events for actor group", %{ - # account: account, - # gateway_group: gateway_group, - # actor: actor, - # socket: socket - # } do - # resource = - # Fixtures.Resources.create_resource( - # type: :ip, - # address: "192.168.100.2", - # account: account, - # connections: [%{gateway_group_id: gateway_group.id}] - # ) - # - # group = Fixtures.Actors.create_group(account: account) - # - # policy = - # Fixtures.Policies.create_policy( - # account: account, - # actor_group: group, - # resource: resource - # ) - # - # send(socket.channel_pid, {:create_membership, actor.id, group.id}) - # - # Fixtures.Policies.disable_policy(policy) - # - # # Simulate disable - # old_data = %{ - # "id" => policy.id, - # "account_id" => policy.account_id, - # "resource_id" => policy.resource_id, - # "actor_group_id" => policy.actor_group_id, - # "conditions" => [], - # "disabled_at" => nil - # } - # - # data = Map.put(old_data, "disabled_at", "2024-01-01T00:00:00Z") - # Events.Hooks.Policies.on_update(old_data, data) - # - # assert_push "resource_deleted", resource_id - # assert resource_id == resource.id - # - # refute_push "resource_created_or_updated", %{} - # end - # end - - # test "allow_access pushes message to the socket", %{ - # account: account, - # gateway: gateway, - # gateway_group: gateway_group, - # dns_resource: resource, - # socket: socket - # } do - # group = Fixtures.Actors.create_group(account: account) - # - # policy = - # Fixtures.Policies.create_policy( - # account: account, - # actor_group: group, - # resource: resource - # ) - # - # send(socket.channel_pid, {:allow_access, policy.id, group.id, resource.id}) - # - # assert_push "resource_created_or_updated", payload - # - # assert payload == %{ - # id: resource.id, - # type: :dns, - # ip_stack: :ipv4_only, - # name: resource.name, - # address: resource.address, - # address_description: resource.address_description, - # gateway_groups: [ - # %{id: gateway_group.id, name: gateway_group.name} - # ], - # filters: [ - # %{protocol: :tcp, port_range_end: 80, port_range_start: 80}, - # %{protocol: :tcp, port_range_end: 433, port_range_start: 433}, - # %{protocol: :udp, port_range_end: 200, port_range_start: 100}, - # %{protocol: :icmp} - # ] - # } - # end - - # test "pushes message to the socket", %{ - # account: account, - # gateway_group: gateway_group, - # socket: socket - # } do - # resource = - # Fixtures.Resources.create_resource( - # type: :ip, - # address: "192.168.100.3", - # account: account, - # connections: [%{gateway_group_id: gateway_group.id}] - # ) - # - # group = Fixtures.Actors.create_group(account: account) - # - # policy = - # Fixtures.Policies.create_policy( - # account: account, - # actor_group: group, - # resource: resource - # ) - # - # send(socket.channel_pid, {:reject_access, policy.id, group.id, resource.id}) - # - # assert_push "resource_deleted", resource_id - # assert resource_id == resource.id - # - # refute_push "resource_created_or_updated", %{} - # end - # - # test "broadcasts a message to re-add the resource if other policy is found", %{ - # account: account, - # gateway_group: gateway_group, - # dns_resource: resource, - # socket: socket - # } do - # group = Fixtures.Actors.create_group(account: account) - # - # policy = - # Fixtures.Policies.create_policy( - # account: account, - # actor_group: group, - # resource: resource - # ) - # - # send(socket.channel_pid, {:reject_access, policy.id, group.id, resource.id}) - # - # assert_push "resource_deleted", resource_id - # assert resource_id == resource.id - # - # assert_push "resource_created_or_updated", payload - # - # assert payload == %{ - # id: resource.id, - # type: :dns, - # ip_stack: :ipv4_only, - # name: resource.name, - # address: resource.address, - # address_description: resource.address_description, - # gateway_groups: [ - # %{id: gateway_group.id, name: gateway_group.name} - # ], - # filters: [ - # %{protocol: :tcp, port_range_end: 80, port_range_start: 80}, - # %{protocol: :tcp, port_range_end: 433, port_range_start: 433}, - # %{protocol: :udp, port_range_end: 200, port_range_start: 100}, - # %{protocol: :icmp} - # ] - # } - # end - # end - end - - describe "handle_in/3 create_flow" do - test "returns error when resource is not found", %{socket: socket} do - resource_id = Ecto.UUID.generate() - - push(socket, "create_flow", %{ - "resource_id" => resource_id, - "connected_gateway_ids" => [] - }) - - assert_push "flow_creation_failed", %{reason: :not_found, resource_id: ^resource_id} - end - - test "returns error when all gateways are offline", %{ - dns_resource: resource, - socket: socket - } do - global_relay_group = Fixtures.Relays.create_global_group() - - global_relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - stamp_secret = Ecto.UUID.generate() - :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - }) - - assert_push "flow_creation_failed", %{reason: :offline, resource_id: resource_id} - assert resource_id == resource.id - end - - test "returns error when client has no policy allowing access to resource", %{ + describe "handle_info/2 recompute_authorized_resources" do + test "sends resource_created_or_updated for new connectable_resources", %{ account: account, + actor: actor, socket: socket } do - resource = Fixtures.Resources.create_resource(account: account) - - gateway = Fixtures.Gateways.create_gateway(account: account) - :ok = Domain.Gateways.Presence.connect(gateway) - - attrs = %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - } - - push(socket, "create_flow", attrs) - - assert_push "flow_creation_failed", %{reason: :not_found, resource_id: resource_id} - assert resource_id == resource.id - end - - test "returns error when flow is not authorized due to failing conditions", %{ - account: account, - client: client, - actor_group: actor_group, - gateway_group: gateway_group, - gateway: gateway, - membership: membership, - socket: socket - } do - send(socket.channel_pid, {:created, membership}) - - resource = - Fixtures.Resources.create_resource( - account: account, - connections: [%{gateway_group_id: gateway_group.id}] - ) - - send(socket.channel_pid, {:created, resource}) - - policy = - Fixtures.Policies.create_policy( - account: account, - actor_group: actor_group, - resource: resource, - conditions: [ - %{ - property: :remote_ip_location_region, - operator: :is_not_in, - values: [client.last_seen_remote_ip_location_region] - } - ] - ) - - send(socket.channel_pid, {:created, policy}) - - attrs = %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - } - - :ok = Domain.Gateways.Presence.connect(gateway) - - push(socket, "create_flow", attrs) - - assert_push "flow_creation_failed", %{ - reason: :forbidden, - violated_properties: [:remote_ip_location_region], - resource_id: resource_id - } - - assert resource_id == resource.id - end - - test "returns error when all gateways connected to the resource are offline", %{ - account: account, - dns_resource: resource, - socket: socket - } do - gateway = Fixtures.Gateways.create_gateway(account: account) - :ok = Domain.Gateways.Presence.connect(gateway) - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - }) - - assert_push "flow_creation_failed", %{ - reason: :offline, - resource_id: resource_id - } - - assert resource_id == resource.id - end - - test "returns online gateway connected to a resource", %{ - dns_resource: resource, - dns_resource_policy: policy, - membership: membership, - client: client, - gateway_group_token: gateway_group_token, - gateway: gateway, - socket: socket - } do - global_relay_group = Fixtures.Relays.create_global_group() - - global_relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - stamp_secret = Ecto.UUID.generate() - :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) - - Fixtures.Relays.update_relay(global_relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - :ok = Domain.PubSub.Account.subscribe(gateway.account_id) - :ok = Domain.Gateways.Presence.connect(gateway) - Domain.PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) - - # Prime cache - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - }) - - gateway_id = gateway.id - - assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, payload} - - assert %{ - client: received_client, - resource: received_resource, - authorization_expires_at: authorization_expires_at, - ice_credentials: _ice_credentials, - preshared_key: preshared_key - } = payload - - assert received_client.id == client.id - assert received_resource.id == resource.id - assert authorization_expires_at == socket.assigns.subject.expires_at - assert String.length(preshared_key) == 44 - end - - test "returns online gateway connected to an internet resource", %{ - account: account, - membership: membership, - internet_resource_policy: policy, - internet_gateway_group_token: gateway_group_token, - internet_gateway: gateway, - internet_resource: resource, - client: client, - socket: socket - } do - Fixtures.Accounts.update_account(account, - features: %{ - internet_resource: true - } - ) - - global_relay_group = Fixtures.Relays.create_global_group() - - global_relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - stamp_secret = Ecto.UUID.generate() - :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) - - :ok = Domain.PubSub.Account.subscribe(account.id) - - Fixtures.Relays.update_relay(global_relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - Domain.PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) - - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - }) - - gateway_id = gateway.id - - assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, payload} - - assert %{ - client: recv_client, - resource: recv_resource, - authorization_expires_at: authorization_expires_at, - ice_credentials: _ice_credentials, - preshared_key: preshared_key - } = payload - - assert recv_client.id == client.id - assert recv_resource.id == resource.id - assert authorization_expires_at == socket.assigns.subject.expires_at - assert String.length(preshared_key) == 44 - end - - test "broadcasts authorize_flow to the gateway and flow_created to the client", %{ - dns_resource: resource, - dns_resource_policy: policy, - membership: membership, - client: client, - gateway_group_token: gateway_group_token, - gateway: gateway, - subject: subject, - socket: socket - } do - global_relay_group = Fixtures.Relays.create_global_group() - - global_relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - stamp_secret = Ecto.UUID.generate() - :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) - :ok = Domain.PubSub.Account.subscribe(gateway.account_id) - - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - Fixtures.Relays.update_relay(global_relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - Domain.PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - }) - - gateway_id = gateway.id - - assert_receive {{:authorize_flow, ^gateway_id}, {channel_pid, socket_ref}, payload} - - assert %{ - client: recv_client, - resource: recv_resource, - authorization_expires_at: authorization_expires_at, - ice_credentials: ice_credentials, - preshared_key: preshared_key - } = payload - - client_id = recv_client.id - resource_id = recv_resource.id - - assert flow = Repo.get_by(Domain.Flows.Flow, client_id: client.id, resource_id: resource.id) - assert flow.client_id == client_id - assert flow.resource_id == resource_id - assert flow.gateway_id == gateway.id - assert flow.policy_id == policy.id - assert flow.token_id == subject.token_id - - assert client_id == client.id - assert resource_id == resource.id - assert authorization_expires_at == socket.assigns.subject.expires_at - - send( - channel_pid, - {:connect, socket_ref, resource_id, gateway.group_id, gateway.id, gateway.public_key, - gateway.ipv4, gateway.ipv6, preshared_key, ice_credentials} - ) - - gateway_group_id = gateway.group_id - gateway_id = gateway.id - gateway_public_key = gateway.public_key - gateway_ipv4 = gateway.ipv4 - gateway_ipv6 = gateway.ipv6 - - assert_push "flow_created", %{ - gateway_public_key: ^gateway_public_key, - gateway_ipv4: ^gateway_ipv4, - gateway_ipv6: ^gateway_ipv6, - resource_id: ^resource_id, - client_ice_credentials: %{username: client_ice_username, password: client_ice_password}, - gateway_group_id: ^gateway_group_id, - gateway_id: ^gateway_id, - gateway_ice_credentials: %{ - username: gateway_ice_username, - password: gateway_ice_password - }, - preshared_key: ^preshared_key - } - - assert String.length(client_ice_username) == 4 - assert String.length(client_ice_password) == 22 - assert String.length(gateway_ice_username) == 4 - assert String.length(gateway_ice_password) == 22 - assert client_ice_username != gateway_ice_username - assert client_ice_password != gateway_ice_password - end - - test "works with service accounts", %{ - account: account, - dns_resource: resource, - dns_resource_policy: policy, - membership: membership, - gateway: gateway, - gateway_group_token: gateway_group_token, - actor_group: actor_group - } do - actor = Fixtures.Actors.create_actor(type: :service_account, account: account) - client = Fixtures.Clients.create_client(account: account, actor: actor) - Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group) - - identity = Fixtures.Auth.create_identity(account: account, actor: actor) - subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) - - {:ok, _reply, socket} = - API.Client.Socket - |> socket("client:#{client.id}", %{ - client: client, - subject: subject - }) - |> subscribe_and_join(API.Client.Channel, "client") - - global_relay_group = Fixtures.Relays.create_global_group() - - global_relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - stamp_secret = Ecto.UUID.generate() - :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) - - Fixtures.Relays.update_relay(global_relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - Domain.PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) - - :ok = Domain.PubSub.Account.subscribe(account.id) - - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - }) - - gateway_id = gateway.id - - assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, _payload} - end - - test "selects compatible gateway versions", %{ - account: account, - gateway_group: gateway_group, - dns_resource: resource, - dns_resource_policy: policy, - membership: membership, - subject: subject, - client: client, - socket: socket - } do - global_relay_group = Fixtures.Relays.create_global_group() - - relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - :ok = Domain.Relays.connect_relay(relay, Ecto.UUID.generate()) - - Fixtures.Relays.update_relay(relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - client = %{client | last_seen_version: "1.4.55"} - - gateway = - Fixtures.Gateways.create_gateway( - account: account, - group: gateway_group, - context: - Fixtures.Auth.build_context( - type: :gateway_group, - user_agent: "Linux/24.04 connlib/1.0.412" - ) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - - :ok = Domain.PubSub.Account.subscribe(account.id) - - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - {:ok, _reply, socket} = - API.Client.Socket - |> socket("client:#{client.id}", %{ - client: client, - subject: subject - }) - |> subscribe_and_join(API.Client.Channel, "client") - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - }) - - assert_push "flow_creation_failed", %{ - reason: :not_found, - resource_id: resource_id - } - - assert resource_id == resource.id - - gateway = - Fixtures.Gateways.create_gateway( - account: account, - group: gateway_group, - context: - Fixtures.Auth.build_context( - type: :gateway_group, - user_agent: "Linux/24.04 connlib/1.4.11" - ) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [] - }) - - gateway_id = gateway.id - - assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, _payload} - end - - test "selects already connected gateway", %{ - account: account, - gateway_group: gateway_group, - dns_resource: resource, - dns_resource_policy: policy, - membership: membership, - socket: socket - } do - global_relay_group = Fixtures.Relays.create_global_group() - - relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - :ok = Domain.Relays.connect_relay(relay, Ecto.UUID.generate()) - - Fixtures.Relays.update_relay(relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - gateway1 = - Fixtures.Gateways.create_gateway( - account: account, - group: gateway_group - ) - - :ok = Domain.Gateways.Presence.connect(gateway1) - - gateway2 = - Fixtures.Gateways.create_gateway( - account: account, - group: gateway_group - ) - - :ok = Domain.Gateways.Presence.connect(gateway2) - - :ok = Domain.PubSub.Account.subscribe(account.id) - - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [gateway2.id] - }) - - gateway_id = gateway2.id - - assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, %{}} - - assert Repo.get_by(Domain.Flows.Flow, - resource_id: resource.id, - gateway_id: gateway2.id, - account_id: account.id - ) - - push(socket, "create_flow", %{ - "resource_id" => resource.id, - "connected_gateway_ids" => [gateway1.id] - }) - - gateway_id = gateway1.id - - assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, %{}} - - assert Repo.get_by(Domain.Flows.Flow, - resource_id: resource.id, - gateway_id: gateway1.id, - account_id: account.id - ) - end - end - - describe "handle_in/3 prepare_connection" do - test "returns error when resource is not found", %{socket: socket} do - ref = push(socket, "prepare_connection", %{"resource_id" => Ecto.UUID.generate()}) - assert_reply ref, :error, %{reason: :not_found} - end - - test "returns error when there are no online relays", %{ - dns_resource: resource, - socket: socket - } do - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - assert_reply ref, :error, %{reason: :offline} - end - - test "returns error when all gateways are offline", %{ - dns_resource: resource, - socket: socket - } do - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - assert_reply ref, :error, %{reason: :offline} - end - - test "returns error when client has no policy allowing access to resource", %{ - account: account, - socket: socket - } do - resource = Fixtures.Resources.create_resource(account: account) - - gateway = Fixtures.Gateways.create_gateway(account: account) - :ok = Domain.Gateways.Presence.connect(gateway) - - attrs = %{ - "resource_id" => resource.id - } - - ref = push(socket, "prepare_connection", attrs) - assert_reply ref, :error, %{reason: :not_found} - end - - test "returns error when all gateways connected to the resource are offline", %{ - account: account, - dns_resource: resource, - socket: socket - } do - gateway = Fixtures.Gateways.create_gateway(account: account) - :ok = Domain.Gateways.Presence.connect(gateway) - - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - assert_reply ref, :error, %{reason: :offline} - end - - test "returns online gateway connected to the resource", %{ - dns_resource: resource, - gateway: gateway, - socket: socket - } do - global_relay_group = Fixtures.Relays.create_global_group() - - global_relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - stamp_secret = Ecto.UUID.generate() - :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) - - Fixtures.Relays.update_relay(global_relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - resource_id = resource.id - - assert_reply ref, :ok, %{ - gateway_id: gateway_id, - gateway_remote_ip: gateway_last_seen_remote_ip, - resource_id: ^resource_id - } - - assert gateway_id == gateway.id - assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip - end - - test "does not return gateways that do not support the resource", %{ - account: account, - dns_resource: dns_resource, - internet_resource: internet_resource, - socket: socket - } do - gateway = Fixtures.Gateways.create_gateway(account: account) - :ok = Domain.Gateways.Presence.connect(gateway) - - ref = push(socket, "prepare_connection", %{"resource_id" => dns_resource.id}) - assert_reply ref, :error, %{reason: :offline} - - ref = push(socket, "prepare_connection", %{"resource_id" => internet_resource.id}) - assert_reply ref, :error, %{reason: :offline} - end - - test "returns gateway that support the DNS resource address syntax", %{ - account: account, - actor_group: actor_group, - membership: membership, - socket: socket - } do - global_relay_group = Fixtures.Relays.create_global_group() - global_relay = Fixtures.Relays.create_relay(group: global_relay_group) - stamp_secret = Ecto.UUID.generate() - :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) - - Fixtures.Relays.update_relay(global_relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) gateway_group = Fixtures.Gateways.create_group(account: account) - gateway = - Fixtures.Gateways.create_gateway( - account: account, - group: gateway_group, - context: %{ - user_agent: "iOS/12.5 (iPhone) connlib/1.1.0" - } - ) - - resource = - Fixtures.Resources.create_resource( - address: "foo.*.example.com", - account: account, - connections: [%{gateway_group_id: gateway_group.id}] - ) - - policy = - Fixtures.Policies.create_policy( - account: account, - actor_group: actor_group, - resource: resource - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - resource_id = resource.id - - assert_reply ref, :error, %{reason: :not_found} - - gateway = - Fixtures.Gateways.create_gateway( - account: account, - group: gateway_group, - context: %{ - user_agent: "iOS/12.5 (iPhone) connlib/1.2.0" - } - ) - - Fixtures.Relays.update_relay(global_relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - :ok = Domain.PubSub.Account.subscribe(account.id) - - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - - assert_reply ref, :ok, %{ - gateway_id: gateway_id, - gateway_remote_ip: gateway_last_seen_remote_ip, - resource_id: ^resource_id - } - - assert gateway_id == gateway.id - assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip - end - - test "returns gateway that support Internet resources", %{ - account: account, - internet_gateway_group: internet_gateway_group, - internet_resource: resource, - socket: socket - } do - account = - Fixtures.Accounts.update_account(account, - features: %{ - internet_resource: true - } - ) - - global_relay_group = Fixtures.Relays.create_global_group() - global_relay = Fixtures.Relays.create_relay(group: global_relay_group) - stamp_secret = Ecto.UUID.generate() - :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) - - Fixtures.Relays.update_relay(global_relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - gateway = - Fixtures.Gateways.create_gateway( - account: account, - group: internet_gateway_group, - context: %{ - user_agent: "iOS/12.5 (iPhone) connlib/1.2.0" - } - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - resource_id = resource.id - - assert_reply ref, :error, %{reason: :not_found} - - gateway = - Fixtures.Gateways.create_gateway( - account: account, - group: internet_gateway_group, - context: %{ - user_agent: "iOS/12.5 (iPhone) connlib/1.3.0" - } - ) - - Fixtures.Relays.update_relay(global_relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - - assert_reply ref, :ok, %{ - gateway_id: gateway_id, - gateway_remote_ip: gateway_last_seen_remote_ip, - resource_id: ^resource_id - } - - assert gateway_id == gateway.id - assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip - end - - test "works with service accounts", %{ - account: account, - dns_resource: resource, - gateway: gateway, - actor_group: actor_group - } do - actor = Fixtures.Actors.create_actor(type: :service_account, account: account) - client = Fixtures.Clients.create_client(account: account, actor: actor) - Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group) - - identity = Fixtures.Auth.create_identity(account: account, actor: actor) - subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) - - {:ok, _reply, socket} = - API.Client.Socket - |> socket("client:#{client.id}", %{ - client: client, - subject: subject - }) - |> subscribe_and_join(API.Client.Channel, "client") - - global_relay_group = Fixtures.Relays.create_global_group() - - relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - :ok = Domain.Relays.connect_relay(relay, Ecto.UUID.generate()) - - Fixtures.Relays.update_relay(relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - - assert_reply ref, :ok, %{} - end - - test "selects compatible gateway versions", %{ - account: account, - gateway_group: gateway_group, - dns_resource: resource, - subject: subject, - client: client - } do - global_relay_group = Fixtures.Relays.create_global_group() - - relay = - Fixtures.Relays.create_relay( - group: global_relay_group, - last_seen_remote_ip_location_lat: 37, - last_seen_remote_ip_location_lon: -120 - ) - - :ok = Domain.Relays.connect_relay(relay, Ecto.UUID.generate()) - - Fixtures.Relays.update_relay(relay, - last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) - ) - - client = %{client | last_seen_version: "1.1.55"} - - gateway = - Fixtures.Gateways.create_gateway( - account: account, - group: gateway_group, - context: - Fixtures.Auth.build_context( - type: :gateway_group, - user_agent: "Linux/24.04 connlib/1.0.412" - ) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - - {:ok, _reply, socket} = - API.Client.Socket - |> socket("client:#{client.id}", %{ - client: client, - subject: subject - }) - |> subscribe_and_join(API.Client.Channel, "client") - - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - - assert_reply ref, :error, %{reason: :not_found} - - gateway = - Fixtures.Gateways.create_gateway( - account: account, - group: gateway_group, - context: - Fixtures.Auth.build_context( - type: :gateway_group, - user_agent: "Linux/24.04 connlib/1.1.11" - ) - ) - - :ok = Domain.Gateways.Presence.connect(gateway) - - ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) - - assert_reply ref, :ok, %{} - end - end - - describe "handle_in/3 reuse_connection" do - test "returns error when resource is not found", %{gateway: gateway, socket: socket} do - attrs = %{ - "resource_id" => Ecto.UUID.generate(), - "gateway_id" => gateway.id, - "payload" => "DNS_Q" - } - - ref = push(socket, "reuse_connection", attrs) - assert_reply ref, :error, %{reason: :not_found} - end - - test "returns error when gateway is not found", %{dns_resource: resource, socket: socket} do - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => Ecto.UUID.generate(), - "payload" => "DNS_Q" - } - - ref = push(socket, "reuse_connection", attrs) - assert_reply ref, :error, %{reason: :not_found} - end - - test "returns error when gateway is not connected to resource", %{ - account: account, - dns_resource: resource, - socket: socket - } do - gateway = Fixtures.Gateways.create_gateway(account: account) - :ok = Domain.Gateways.Presence.connect(gateway) - - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "payload" => "DNS_Q" - } - - ref = push(socket, "reuse_connection", attrs) - assert_reply ref, :error, %{reason: :offline} - end - - test "returns error when flow is not authorized due to failing conditions", %{ - account: account, - client: client, - actor_group: actor_group, - membership: membership, - gateway_group: gateway_group, - gateway: gateway, - socket: socket - } do resource = Fixtures.Resources.create_resource( account: account, connections: [%{gateway_group_id: gateway_group.id}] ) - policy = - Fixtures.Policies.create_policy( - account: account, - actor_group: actor_group, - resource: resource, - conditions: [ - %{ - property: :remote_ip_location_region, - operator: :is_not_in, - values: [client.last_seen_remote_ip_location_region] - } - ] - ) + # Create a policy that becomes valid in one second + now = DateTime.utc_now() - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "payload" => "DNS_Q" - } + day_letter = + case Date.day_of_week(now) do + # Monday + 1 -> "M" + # Tuesday + 2 -> "T" + # Wednesday + 3 -> "W" + # Thursday + 4 -> "R" + # Friday + 5 -> "F" + # Saturday + 6 -> "S" + # Sunday + 7 -> "U" + end - :ok = Domain.Gateways.Presence.connect(gateway) - :ok = Domain.PubSub.Account.subscribe(account.id) + # This test will flake if run within 1 second of midnight UTC. Sorry about that. + time_range = "#{day_letter}/#{now.hour}:#{now.minute}:#{now.second + 1}-23:59:59/UTC" - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - ref = push(socket, "reuse_connection", attrs) - - assert_reply ref, :error, %{ - reason: :forbidden, - violated_properties: [:remote_ip_location_region] - } - end - - test "returns error when client has no policy allowing access to resource", %{ - account: account, - socket: socket - } do - resource = Fixtures.Resources.create_resource(account: account) - - gateway = Fixtures.Gateways.create_gateway(account: account) - :ok = Domain.Gateways.Presence.connect(gateway) - - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "payload" => "DNS_Q" - } - - ref = push(socket, "reuse_connection", attrs) - assert_reply ref, :error, %{reason: :not_found} - end - - test "returns error when gateway is offline", %{ - dns_resource: resource, - gateway: gateway, - socket: socket - } do - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "payload" => "DNS_Q" - } - - ref = push(socket, "reuse_connection", attrs) - assert_reply ref, :error, %{reason: :offline} - end - - test "broadcasts allow_access to the gateways and then returns connect message", %{ - dns_resource: resource, - dns_resource_policy: policy, - membership: membership, - gateway: gateway, - client: client, - socket: socket - } do - public_key = gateway.public_key - resource_id = resource.id - client_id = client.id - - :ok = Domain.Gateways.Presence.connect(gateway) - :ok = Domain.PubSub.Account.subscribe(resource.account_id) - - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "payload" => "DNS_Q" - } - - ref = push(socket, "reuse_connection", attrs) - - gateway_id = gateway.id - - assert_receive {{:allow_access, ^gateway_id}, {channel_pid, socket_ref}, payload} - - assert %{ - resource: recv_resource, - client: recv_client, - authorization_expires_at: authorization_expires_at, - client_payload: "DNS_Q" - } = payload - - assert recv_resource.id == resource_id - assert recv_client.id == client_id - assert authorization_expires_at == socket.assigns.subject.expires_at - - send( - channel_pid, - {:connect, socket_ref, resource.id, gateway.public_key, "DNS_RPL"} + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource, + conditions: [ + %{ + property: :current_utc_datetime, + operator: :is_in_day_of_week_time_ranges, + values: [time_range] + } + ] ) - assert_reply ref, :ok, %{ - resource_id: ^resource_id, - persistent_keepalive: 25, - gateway_public_key: ^public_key, - gateway_payload: "DNS_RPL" - } - end + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) - test "works with service accounts", %{ - account: account, - dns_resource: resource, - dns_resource_policy: policy, - membership: membership, - gateway: gateway, - gateway_group_token: gateway_group_token, - actor_group: actor_group - } do - actor = Fixtures.Actors.create_actor(type: :service_account, account: account) - client = Fixtures.Clients.create_client(account: account, actor: actor) - Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group) - - identity = Fixtures.Auth.create_identity(account: account, actor: actor) - subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) - - {:ok, _reply, socket} = - API.Client.Socket - |> socket("client:#{client.id}", %{ - client: client, - subject: subject - }) - |> subscribe_and_join(API.Client.Channel, "client") - - :ok = Domain.Gateways.Presence.connect(gateway) - Phoenix.PubSub.subscribe(Domain.PubSub, Domain.Tokens.socket_id(gateway_group_token)) - - :ok = Domain.PubSub.Account.subscribe(account.id) - - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - push(socket, "reuse_connection", %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "payload" => "DNS_Q" + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership }) - gateway_id = gateway.id + refute_push "resource_created_or_updated", _payload + refute_push "resource_deleted", _payload - assert_receive {{:allow_access, ^gateway_id}, _refs, _payload} + Process.sleep(2000) + + send(socket.channel_pid, :recompute_authorized_resources) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id end end - describe "handle_in/3 request_connection" do - test "returns error when resource is not found", %{gateway: gateway, socket: socket} do - attrs = %{ - "resource_id" => Ecto.UUID.generate(), - "gateway_id" => gateway.id, - "client_payload" => "RTC_SD", - "client_preshared_key" => "PSK" - } - - ref = push(socket, "request_connection", attrs) - assert_reply ref, :error, %{reason: :not_found} - end - - test "returns error when gateway is not found", %{dns_resource: resource, socket: socket} do - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => Ecto.UUID.generate(), - "client_payload" => "RTC_SD", - "client_preshared_key" => "PSK" - } - - ref = push(socket, "request_connection", attrs) - assert_reply ref, :error, %{reason: :not_found} - end - - test "returns error when gateway is not connected to resource", %{ - account: account, - dns_resource: resource, - socket: socket - } do - gateway = Fixtures.Gateways.create_gateway(account: account) - :ok = Domain.Gateways.Presence.connect(gateway) - - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "client_payload" => "RTC_SD", - "client_preshared_key" => "PSK" - } - - ref = push(socket, "request_connection", attrs) - assert_reply ref, :error, %{reason: :offline} - end - - test "returns error when client has no policy allowing access to resource", %{ - account: account, - socket: socket - } do - resource = Fixtures.Resources.create_resource(account: account) - - gateway = Fixtures.Gateways.create_gateway(account: account) - :ok = Domain.Gateways.Presence.connect(gateway) - - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "client_payload" => "RTC_SD", - "client_preshared_key" => "PSK" - } - - ref = push(socket, "request_connection", attrs) - assert_reply ref, :error, %{reason: :not_found} - end - - test "returns error when flow is not authorized due to failing conditions", %{ - account: account, - client: client, - actor_group: actor_group, - membership: membership, - gateway_group: gateway_group, - gateway: gateway, - socket: socket - } do - resource = - Fixtures.Resources.create_resource( - account: account, - connections: [%{gateway_group_id: gateway_group.id}] - ) - - policy = - Fixtures.Policies.create_policy( - account: account, - actor_group: actor_group, - resource: resource, - conditions: [ - %{ - property: :remote_ip_location_region, - operator: :is_not_in, - values: [client.last_seen_remote_ip_location_region] - } - ] - ) - - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "client_payload" => "RTC_SD", - "client_preshared_key" => "PSK" - } - - :ok = Domain.Gateways.Presence.connect(gateway) - - :ok = Domain.PubSub.Account.subscribe(account.id) - - send(socket.channel_pid, {:created, resource}) - send(socket.channel_pid, {:created, policy}) - send(socket.channel_pid, {:created, membership}) - - ref = push(socket, "request_connection", attrs) - - assert_reply ref, :error, %{ - reason: :forbidden, - violated_properties: [:remote_ip_location_region] - } - end - - test "returns error when gateway is offline", %{ - dns_resource: resource, - gateway: gateway, - socket: socket - } do - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "client_payload" => "RTC_SD", - "client_preshared_key" => "PSK" - } - - ref = push(socket, "request_connection", attrs) - assert_reply ref, :error, %{reason: :offline} - end - - test "broadcasts request_connection to the gateways and then returns connect message", %{ - dns_resource: resource, - gateway_group_token: gateway_group_token, - gateway: gateway, - client: client, - socket: socket - } do - public_key = gateway.public_key - resource_id = resource.id - client_id = client.id - - :ok = Domain.Gateways.Presence.connect(gateway) - Domain.PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) - - :ok = Domain.PubSub.Account.subscribe(resource.account_id) - - attrs = %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "client_payload" => "RTC_SD", - "client_preshared_key" => "PSK" - } - - ref = push(socket, "request_connection", attrs) - - gateway_id = gateway.id - - assert_receive {{:request_connection, ^gateway_id}, {channel_pid, socket_ref}, payload} - - assert %{ - resource: recv_resource, - client: recv_client, - client_preshared_key: "PSK", - client_payload: "RTC_SD", - authorization_expires_at: authorization_expires_at - } = payload - - assert recv_resource.id == resource_id - assert recv_client.id == client_id - - assert authorization_expires_at == socket.assigns.subject.expires_at - - send( - channel_pid, - {:connect, socket_ref, resource.id, gateway.public_key, "FULL_RTC_SD"} - ) - - assert_reply ref, :ok, %{ - resource_id: ^resource_id, - persistent_keepalive: 25, - gateway_public_key: ^public_key, - gateway_payload: "FULL_RTC_SD" - } - end - - test "works with service accounts", %{ - account: account, - dns_resource: resource, - gateway_group_token: gateway_group_token, - gateway: gateway, - actor_group: actor_group - } do - actor = Fixtures.Actors.create_actor(type: :service_account, account: account) - client = Fixtures.Clients.create_client(account: account, actor: actor) - Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group) - - identity = Fixtures.Auth.create_identity(account: account, actor: actor) - subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) - - {:ok, _reply, socket} = - API.Client.Socket - |> socket("client:#{client.id}", %{ - client: client, - subject: subject - }) - |> subscribe_and_join(API.Client.Channel, "client") - - :ok = Domain.Gateways.Presence.connect(gateway) - Phoenix.PubSub.subscribe(Domain.PubSub, Domain.Tokens.socket_id(gateway_group_token)) - - :ok = Domain.PubSub.Account.subscribe(account.id) - - push(socket, "request_connection", %{ - "resource_id" => resource.id, - "gateway_id" => gateway.id, - "client_payload" => "RTC_SD", - "client_preshared_key" => "PSK" - }) - - gateway_id = gateway.id - - assert_receive {{:request_connection, ^gateway_id}, _refs, _payload} - end - end - - describe "handle_in/3 broadcast_ice_candidates" do - test "does nothing when gateways list is empty", %{ - socket: socket - } do - candidates = ["foo", "bar"] - - attrs = %{ - "candidates" => candidates, - "gateway_ids" => [] - } - - push(socket, "broadcast_ice_candidates", attrs) - refute_receive {:ice_candidates, _client_id, _candidates} - end - - test "broadcasts :ice_candidates message to all gateways", %{ - client: client, - gateway_group_token: gateway_group_token, - gateway: gateway, - socket: socket - } do - candidates = ["foo", "bar"] - - attrs = %{ - "candidates" => candidates, - "gateway_ids" => [gateway.id] - } - - :ok = Domain.Gateways.Presence.connect(gateway) - Domain.PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) - - :ok = Domain.PubSub.Account.subscribe(client.account_id) - - push(socket, "broadcast_ice_candidates", attrs) - - gateway_id = gateway.id - - assert_receive {{:ice_candidates, ^gateway_id}, client_id, ^candidates}, 200 - assert client.id == client_id - end - end - - describe "handle_in/3 broadcast_invalidated_ice_candidates" do - test "does nothing when gateways list is empty", %{ - socket: socket - } do - candidates = ["foo", "bar"] - - attrs = %{ - "candidates" => candidates, - "gateway_ids" => [] - } - - push(socket, "broadcast_invalidated_ice_candidates", attrs) - refute_receive {:invalidate_ice_candidates, _client_id, _candidates} - end - - test "broadcasts :invalidate_ice_candidates message to all gateways", %{ - client: client, - gateway_group_token: gateway_group_token, - gateway: gateway, - socket: socket - } do - candidates = ["foo", "bar"] - - attrs = %{ - "candidates" => candidates, - "gateway_ids" => [gateway.id] - } - - :ok = Domain.Gateways.Presence.connect(gateway) - Domain.PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) - :ok = Domain.PubSub.Account.subscribe(client.account_id) - - push(socket, "broadcast_invalidated_ice_candidates", attrs) - - gateway_id = gateway.id - - assert_receive {{:invalidate_ice_candidates, ^gateway_id}, client_id, ^candidates}, 200 - assert client.id == client_id - end - end - - # Debouncer tests - describe "handle_info/3" do + describe "handle_info/2 for presence events" do test "push_leave cancels leave if reconnecting with the same stamp secret" do relay_group = Fixtures.Relays.create_global_group() @@ -2656,8 +814,2742 @@ defmodule API.Client.ChannelTest do assert relay_id == relay1.id end + end - test "for unknown messages it doesn't crash", %{socket: socket} do + describe "handle_info/2 for change events" 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 "for account updates the subject in the socket", %{ + socket: socket, + account: account + } do + updated_account = %{ + account + | name: "New Name", + updated_at: DateTime.utc_now() + } + + change = %Changes.Change{ + lsn: 100, + op: :update, + old_struct: account, + struct: updated_account + } + + send(socket.channel_pid, change) + + assert %{ + assigns: %{ + subject: %{account: ^updated_account} + } + } = :sys.get_state(socket.channel_pid) + + refute_push "config_changed", %{interface: %{}} + end + + test "for account updates pushes config_changed if account config changed", %{ + socket: socket, + account: account, + client: client + } do + updated_account = %{account | config: %{account.config | search_domain: "new.example.com"}} + + change = %Changes.Change{ + lsn: 100, + op: :update, + old_struct: account, + struct: updated_account + } + + send(socket.channel_pid, change) + + assert_push "config_changed", payload + + assert payload == %{ + interface: %{ + ipv4: client.ipv4, + ipv6: client.ipv6, + search_domain: "new.example.com", + upstream_dns: [ + %{address: "1.1.1.1:53", protocol: :ip_port}, + %{address: "8.8.8.8:53", protocol: :ip_port} + ] + } + } + end + + test "for actor_group_membership inserts pushes resource_created_or_updated if connectable_resources changes", + %{ + actor: actor, + account: account, + socket: socket + } do + actor_group = Fixtures.Actors.create_group(account: account) + Fixtures.Auth.create_identity(actor: actor, account: account) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + 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 + ) + + change = %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + } + + send(socket.channel_pid, change) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + assert payload.type == resource.type + assert payload.address == resource.address + assert payload.address_description == resource.address_description + assert payload.name == resource.name + assert payload.ip_stack == resource.ip_stack + + assert payload.gateway_groups == [ + %{ + id: gateway_group.id, + name: gateway_group.name + } + ] + end + + test "for actor_group_membership deletes pushes resource_deleted if connectable_resources changes", + %{ + actor: actor, + account: account, + socket: socket + } do + actor_group = Fixtures.Actors.create_group(account: account) + Fixtures.Auth.create_identity(actor: actor, account: account) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: Fixtures.Gateways.create_group(account: account).id}] + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :insert, + struct: resource + }) + + policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 300, + op: :insert, + struct: policy + }) + + assert_push "resource_created_or_updated", payload + assert payload.id == resource.id + + send(socket.channel_pid, %Changes.Change{ + lsn: 400, + op: :delete, + old_struct: membership + }) + + assert_push "resource_deleted", payload + + assert payload == resource.id + end + + test "for actor_group_membership deletes does not push resource_deleted if another policy exists", + %{ + actor: actor, + account: account, + socket: socket + } do + # Create two actor groups + actor_group_1 = Fixtures.Actors.create_group(account: account) + actor_group_2 = Fixtures.Actors.create_group(account: account) + Fixtures.Auth.create_identity(actor: actor, account: account) + + # Add actor to both groups + membership_1 = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group_1 + ) + + membership_2 = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group_2 + ) + + # Send membership inserts + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership_1 + }) + + send(socket.channel_pid, %Changes.Change{ + lsn: 101, + op: :insert, + struct: membership_2 + }) + + # Create a resource accessible by both groups + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: Fixtures.Gateways.create_group(account: account).id}] + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :insert, + struct: resource + }) + + # Create policies for both groups pointing to the same resource + policy_1 = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group_1, + resource: resource + ) + + policy_2 = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group_2, + resource: resource + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 300, + op: :insert, + struct: policy_1 + }) + + # Resource should be created when first policy is added + assert_push "resource_created_or_updated", payload + assert payload.id == resource.id + + send(socket.channel_pid, %Changes.Change{ + lsn: 301, + op: :insert, + struct: policy_2 + }) + + # No duplicate resource creation for second policy + refute_push "resource_created_or_updated", _payload + + # Delete first membership + send(socket.channel_pid, %Changes.Change{ + lsn: 400, + op: :delete, + old_struct: membership_1 + }) + + # Resource should NOT be deleted because policy_2 still grants access + refute_push "resource_deleted", _payload + + # Delete second membership + send(socket.channel_pid, %Changes.Change{ + lsn: 500, + op: :delete, + old_struct: membership_2 + }) + + # Now resource should be deleted since no policies grant access + assert_push "resource_deleted", payload + assert payload == resource.id + end + + test "for client updates pushes added and deleted resources if verified status changes", + %{ + client: client, + actor: actor, + account: account, + socket: socket + } do + resource = Fixtures.Resources.create_resource(account: account) + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource, + conditions: [ + %{ + property: :client_verified, + operator: :is, + values: ["true"] + } + ] + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + refute_push "resource_created_or_updated", _payload + + verified_client = Fixtures.Clients.verify_client(client) + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :update, + old_struct: client, + struct: verified_client + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + + send(socket.channel_pid, %Changes.Change{ + lsn: 300, + op: :update, + old_struct: verified_client, + struct: client + }) + + assert_push "resource_deleted", payload + assert payload == resource.id + end + + test "for client deletions disconnects socket", %{ + client: client, + socket: socket + } do + Process.flag(:trap_exit, true) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :delete, + old_struct: client + }) + + assert_receive {:EXIT, _pid, _reason} + end + + test "for gateway_groups pushes resource_created_or_updated for name changes", %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + gateway_group = Fixtures.Gateways.create_group(account: account) + + 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 + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :update, + old_struct: gateway_group, + struct: %{gateway_group | name: "test"} + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + + assert payload.gateway_groups == [ + %{ + id: gateway_group.id, + name: "test" + } + ] + end + + test "for policy inserts sends resource_created_or_updated if new access is granted", %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + refute_push "resource_created_or_updated", _payload + + policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :insert, + struct: policy + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + end + + test "for breaking policy updates sends resource_deleted followed by resource_created_or_updated", + %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + + updated_policy = + Fixtures.Policies.update_policy(policy, + conditions: [ + %{ + property: :remote_ip_location_region, + operator: :is_not_in, + values: ["BR"] + } + ] + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 300, + op: :update, + old_struct: policy, + struct: updated_policy + }) + + assert_push "resource_deleted", payload + + assert payload == resource.id + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + + updated_policy = + Fixtures.Policies.update_policy(policy, + conditions: [ + %{ + property: :remote_ip_location_region, + operator: :is_in, + values: ["BR"] + } + ] + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 400, + op: :update, + old_struct: policy, + struct: updated_policy + }) + + assert_push "resource_deleted", payload + + assert payload == resource.id + + refute_push "resource_created_or_updated", _payload + end + + test "for policy deletions sends resource_deleted", %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :delete, + old_struct: policy + }) + + assert_push "resource_deleted", payload + + assert payload == resource.id + + refute_push "resource_created_or_updated", _payload + end + + test "for non-breaking policy updates just updates our state", %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + + updated_policy = Fixtures.Policies.update_policy(policy, description: "Updated description") + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :update, + old_struct: policy, + struct: updated_policy + }) + + refute_push "resource_deleted", _payload + refute_push "resource_created_or_updated", _payload + + assert %{assigns: %{last_lsn: 200}} = :sys.get_state(socket.channel_pid) + end + + test "for resource site changes pushes resource_deleted followed by resource_created_or_updated", + %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + 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 + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + + assert payload.gateway_groups == [ + %{ + id: gateway_group.id, + name: gateway_group.name + } + ] + + new_gateway_group = Fixtures.Gateways.create_group(account: account) + + Fixtures.Resources.update_resource(resource, + connections: [%{gateway_group_id: new_gateway_group.id}] + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 150, + op: :delete, + old_struct: %Resources.Connection{ + resource_id: resource.id, + gateway_group_id: gateway_group.id + } + }) + + assert_push "resource_deleted", payload + assert payload == resource.id + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :insert, + struct: %Resources.Connection{ + resource_id: resource.id, + gateway_group_id: new_gateway_group.id + } + }) + + assert_push "resource_created_or_updated", payload + assert payload.id == resource.id + + assert payload.gateway_groups == [ + %{ + id: new_gateway_group.id, + name: new_gateway_group.name + } + ] + end + + test "for resource_connection insert adds gateway group to existing resource", %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group_1 = Fixtures.Gateways.create_group(account: account) + gateway_group_2 = Fixtures.Gateways.create_group(account: account) + + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: gateway_group_1.id}] + ) + + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", payload + assert length(payload.gateway_groups) == 1 + + # Add a second gateway group to the same resource + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :insert, + struct: %Resources.Connection{ + resource_id: resource.id, + gateway_group_id: gateway_group_2.id + } + }) + + # Resource should be toggled (deleted then created) to handle the update + assert_push "resource_deleted", deleted_id + assert deleted_id == resource.id + + assert_push "resource_created_or_updated", payload + assert payload.id == resource.id + assert length(payload.gateway_groups) == 2 + end + + test "for resource_connection delete removes gateway group but keeps resource if other groups exist", + %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group_1 = Fixtures.Gateways.create_group(account: account) + gateway_group_2 = Fixtures.Gateways.create_group(account: account) + + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [ + %{gateway_group_id: gateway_group_1.id}, + %{gateway_group_id: gateway_group_2.id} + ] + ) + + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", payload + assert length(payload.gateway_groups) == 2 + + # Remove one gateway group + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :delete, + old_struct: %Resources.Connection{ + resource_id: resource.id, + gateway_group_id: gateway_group_1.id + } + }) + + # Resource should be toggled (deleted then created) with one less gateway group + assert_push "resource_deleted", deleted_id + assert deleted_id == resource.id + + assert_push "resource_created_or_updated", payload + assert payload.id == resource.id + assert length(payload.gateway_groups) == 1 + end + + test "for resource_connection delete removes resource when last gateway group is removed", %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + 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 + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", _payload + + # Remove the only gateway group + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :delete, + old_struct: %Resources.Connection{ + resource_id: resource.id, + gateway_group_id: gateway_group.id + } + }) + + # Resource should be deleted and not re-created + assert_push "resource_deleted", deleted_id + assert deleted_id == resource.id + + refute_push "resource_created_or_updated", _payload + end + + test "for multiple policies with different conditions on same resource applies most permissive", + %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group_1 = Fixtures.Actors.create_group(account: account) + actor_group_2 = Fixtures.Actors.create_group(account: account) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + # Create restrictive policy with client verification requirement + _restrictive_policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group_1, + resource: resource, + conditions: [ + %{ + property: :client_verified, + operator: :is, + values: ["true"] + } + ] + ) + + membership_1 = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group_1 + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership_1 + }) + + # Resource should not be accessible for unverified client + refute_push "resource_created_or_updated", _payload + + # Add a more permissive policy without conditions from second group + permissive_policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group_2, + resource: resource, + conditions: [] + ) + + membership_2 = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group_2 + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :insert, + struct: membership_2 + }) + + # Now insert the permissive policy + send(socket.channel_pid, %Changes.Change{ + lsn: 201, + op: :insert, + struct: permissive_policy + }) + + # Resource should now be accessible due to permissive policy + assert_push "resource_created_or_updated", payload + assert payload.id == resource.id + + # Delete the second membership (which has the permissive policy) + send(socket.channel_pid, %Changes.Change{ + lsn: 300, + op: :delete, + old_struct: membership_2 + }) + + # Resource should be removed since only restrictive policy remains + assert_push "resource_deleted", deleted_id + assert deleted_id == resource.id + end + + test "for resource update that changes address compatibility removes access", %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + # Create IPv4 resource + resource = + Fixtures.Resources.create_resource( + type: :ip, + address: "192.168.1.1", + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", payload + assert payload.id == resource.id + + # Update resource to IPv6 (assuming client doesn't support IPv6) + updated_resource = %{resource | address: "2001:db8::1"} + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :update, + old_struct: resource, + struct: updated_resource + }) + + # The behavior depends on client version/capability + # For now we'll just verify the update is handled without crash + assert %{assigns: %{last_lsn: 200}} = :sys.get_state(socket.channel_pid) + end + + test "for resource updates sends resource_created_or_updated", %{ + socket: socket, + account: account, + actor: actor + } do + Fixtures.Auth.create_identity(actor: actor, account: account) + actor_group = Fixtures.Actors.create_group(account: account) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + 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 + ) + + membership = + Fixtures.Actors.create_membership( + account: account, + actor: actor, + group: actor_group + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == resource.id + + updated_resource = Fixtures.Resources.update_resource(resource, name: "Updated Name") + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :update, + old_struct: resource, + struct: updated_resource + }) + + assert_push "resource_created_or_updated", payload + + assert payload.id == updated_resource.id + end + end + + describe "handle_info/2 ice_candidates" do + test "pushes ice_candidates message", %{ + client: client, + gateway: gateway, + socket: socket + } do + candidates = ["foo", "bar"] + + send( + socket.channel_pid, + {{:ice_candidates, client.id}, gateway.id, candidates} + ) + + assert_push "ice_candidates", payload + + assert payload == %{ + candidates: candidates, + gateway_id: gateway.id + } + end + end + + describe "handle_info/2 invalidate_ice_candidates" do + test "pushes invalidate_ice_candidates message", %{ + client: client, + gateway: gateway, + socket: socket + } do + candidates = ["foo", "bar"] + + send( + socket.channel_pid, + {{:invalidate_ice_candidates, client.id}, gateway.id, candidates} + ) + + assert_push "invalidate_ice_candidates", payload + + assert payload == %{ + candidates: candidates, + gateway_id: gateway.id + } + end + end + + describe "handle_in/3 create_flow" do + test "returns error when resource is not found", %{socket: socket} do + resource_id = Ecto.UUID.generate() + + push(socket, "create_flow", %{ + "resource_id" => resource_id, + "connected_gateway_ids" => [] + }) + + assert_push "flow_creation_failed", %{reason: :not_found, resource_id: ^resource_id} + end + + test "returns error when all gateways are offline", %{ + dns_resource: resource, + socket: socket + } do + global_relay_group = Fixtures.Relays.create_global_group() + + global_relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + }) + + assert_push "flow_creation_failed", %{reason: :offline, resource_id: resource_id} + assert resource_id == resource.id + end + + test "returns :not_found when client has no policy allowing access to resource", %{ + account: account, + socket: socket + } do + resource = Fixtures.Resources.create_resource(account: account) + + gateway = Fixtures.Gateways.create_gateway(account: account) + :ok = Gateways.Presence.connect(gateway) + + attrs = %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + } + + push(socket, "create_flow", attrs) + + assert_push "flow_creation_failed", %{reason: :not_found, resource_id: resource_id} + assert resource_id == resource.id + end + + # In practice, this will only happen if a client maliciously sends a resource_id, because + # it won't have this resource in its resource list. + test "returns :not_found if resource isn't in connectable resources", %{ + account: account, + client: client, + actor_group: actor_group, + gateway_group: gateway_group, + gateway: gateway, + membership: membership, + socket: socket + } do + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: membership + }) + + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 200, + op: :insert, + struct: resource + }) + + policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource, + conditions: [ + %{ + property: :remote_ip_location_region, + operator: :is_not_in, + values: [client.last_seen_remote_ip_location_region] + } + ] + ) + + send(socket.channel_pid, %Changes.Change{ + lsn: 300, + op: :insert, + struct: policy + }) + + attrs = %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + } + + :ok = Gateways.Presence.connect(gateway) + + push(socket, "create_flow", attrs) + + assert_push "flow_creation_failed", %{ + reason: :not_found, + resource_id: resource_id + } + + assert resource_id == resource.id + end + + test "returns error when all gateways connected to the resource are offline", %{ + account: account, + dns_resource: resource, + socket: socket + } do + gateway = Fixtures.Gateways.create_gateway(account: account) + :ok = Gateways.Presence.connect(gateway) + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + }) + + assert_push "flow_creation_failed", %{ + reason: :offline, + resource_id: resource_id + } + + assert resource_id == resource.id + end + + test "returns online gateway connected to a resource", %{ + dns_resource: resource, + dns_resource_policy: policy, + membership: membership, + client: client, + gateway_group_token: gateway_group_token, + gateway: gateway, + socket: socket + } do + global_relay_group = Fixtures.Relays.create_global_group() + + global_relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) + + Fixtures.Relays.update_relay(global_relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + :ok = PubSub.Account.subscribe(gateway.account_id) + :ok = Gateways.Presence.connect(gateway) + :ok = PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) + + # Prime cache + send(socket.channel_pid, {:created, resource}) + send(socket.channel_pid, {:created, policy}) + send(socket.channel_pid, {:created, membership}) + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + }) + + gateway_id = gateway.id + + assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, payload} + + assert %{ + client: received_client, + resource: received_resource, + authorization_expires_at: authorization_expires_at, + ice_credentials: _ice_credentials, + preshared_key: preshared_key + } = payload + + assert received_client.id == client.id + assert received_resource.id == Ecto.UUID.dump!(resource.id) + assert authorization_expires_at == socket.assigns.subject.expires_at + assert String.length(preshared_key) == 44 + end + + test "returns online gateway connected to an internet resource", %{ + account: account, + membership: membership, + internet_resource_policy: policy, + internet_gateway_group_token: gateway_group_token, + internet_gateway: gateway, + internet_resource: resource, + client: client, + socket: socket + } do + Fixtures.Accounts.update_account(account, + features: %{ + internet_resource: true + } + ) + + global_relay_group = Fixtures.Relays.create_global_group() + + global_relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) + + :ok = PubSub.Account.subscribe(account.id) + + Fixtures.Relays.update_relay(global_relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + :ok = Gateways.Presence.connect(gateway) + PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) + + send(socket.channel_pid, {:created, resource}) + send(socket.channel_pid, {:created, policy}) + send(socket.channel_pid, {:created, membership}) + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + }) + + gateway_id = gateway.id + + assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, payload} + + assert %{ + client: recv_client, + resource: recv_resource, + authorization_expires_at: authorization_expires_at, + ice_credentials: _ice_credentials, + preshared_key: preshared_key + } = payload + + assert recv_client.id == client.id + assert recv_resource.id == Ecto.UUID.dump!(resource.id) + assert authorization_expires_at == socket.assigns.subject.expires_at + assert String.length(preshared_key) == 44 + end + + test "broadcasts authorize_flow to the gateway and flow_created to the client", %{ + dns_resource: resource, + dns_resource_policy: policy, + membership: membership, + client: client, + gateway_group_token: gateway_group_token, + gateway: gateway, + subject: subject, + socket: socket + } do + global_relay_group = Fixtures.Relays.create_global_group() + + global_relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) + :ok = PubSub.Account.subscribe(gateway.account_id) + + send(socket.channel_pid, {:created, resource}) + send(socket.channel_pid, {:created, policy}) + send(socket.channel_pid, {:created, membership}) + + Fixtures.Relays.update_relay(global_relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + :ok = Gateways.Presence.connect(gateway) + PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + }) + + gateway_id = gateway.id + + assert_receive {{:authorize_flow, ^gateway_id}, {channel_pid, socket_ref}, payload} + + assert %{ + client: recv_client, + resource: recv_resource, + authorization_expires_at: authorization_expires_at, + ice_credentials: ice_credentials, + preshared_key: preshared_key + } = payload + + client_id = recv_client.id + resource_id = recv_resource.id + + assert flow = Repo.get_by(Domain.Flows.Flow, client_id: client.id, resource_id: resource.id) + assert flow.client_id == client_id + assert flow.resource_id == Ecto.UUID.load!(resource_id) + assert flow.gateway_id == gateway.id + assert flow.policy_id == policy.id + assert flow.token_id == subject.token_id + + assert client_id == client.id + assert Ecto.UUID.load!(resource_id) == resource.id + assert authorization_expires_at == socket.assigns.subject.expires_at + + send( + channel_pid, + {:connect, socket_ref, resource_id, gateway.group_id, gateway.id, gateway.public_key, + gateway.ipv4, gateway.ipv6, preshared_key, ice_credentials} + ) + + gateway_group_id = gateway.group_id + gateway_id = gateway.id + gateway_public_key = gateway.public_key + gateway_ipv4 = gateway.ipv4 + gateway_ipv6 = gateway.ipv6 + + resource_id = Ecto.UUID.load!(resource_id) + + assert_push "flow_created", %{ + gateway_public_key: ^gateway_public_key, + gateway_ipv4: ^gateway_ipv4, + gateway_ipv6: ^gateway_ipv6, + resource_id: ^resource_id, + client_ice_credentials: %{username: client_ice_username, password: client_ice_password}, + gateway_group_id: ^gateway_group_id, + gateway_id: ^gateway_id, + gateway_ice_credentials: %{ + username: gateway_ice_username, + password: gateway_ice_password + }, + preshared_key: ^preshared_key + } + + assert String.length(client_ice_username) == 4 + assert String.length(client_ice_password) == 22 + assert String.length(gateway_ice_username) == 4 + assert String.length(gateway_ice_password) == 22 + assert client_ice_username != gateway_ice_username + assert client_ice_password != gateway_ice_password + end + + test "works with service accounts", %{ + account: account, + dns_resource: resource, + dns_resource_policy: policy, + membership: membership, + gateway: gateway, + gateway_group_token: gateway_group_token, + actor_group: actor_group + } do + actor = Fixtures.Actors.create_actor(type: :service_account, account: account) + client = Fixtures.Clients.create_client(account: account, actor: actor) + Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group) + + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) + + {:ok, _reply, socket} = + API.Client.Socket + |> socket("client:#{client.id}", %{ + client: client, + subject: subject + }) + |> subscribe_and_join(API.Client.Channel, "client") + + global_relay_group = Fixtures.Relays.create_global_group() + + global_relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) + + Fixtures.Relays.update_relay(global_relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + :ok = Gateways.Presence.connect(gateway) + PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) + + :ok = PubSub.Account.subscribe(account.id) + + send(socket.channel_pid, {:created, resource}) + send(socket.channel_pid, {:created, policy}) + send(socket.channel_pid, {:created, membership}) + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + }) + + gateway_id = gateway.id + + assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, _payload} + end + + test "selects compatible gateway versions", %{ + account: account, + gateway_group: gateway_group, + dns_resource: resource, + dns_resource_policy: policy, + membership: membership, + subject: subject, + client: client, + socket: socket + } do + global_relay_group = Fixtures.Relays.create_global_group() + + relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + :ok = Domain.Relays.connect_relay(relay, Ecto.UUID.generate()) + + Fixtures.Relays.update_relay(relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + client = %{client | last_seen_version: "1.4.55"} + + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: gateway_group, + context: + Fixtures.Auth.build_context( + type: :gateway_group, + user_agent: "Linux/24.04 connlib/1.0.412" + ) + ) + + :ok = Gateways.Presence.connect(gateway) + + :ok = PubSub.Account.subscribe(account.id) + + send(socket.channel_pid, %Changes.Change{ + lsn: 0, + op: :insert, + struct: resource + }) + + send(socket.channel_pid, %Changes.Change{ + lsn: 1, + op: :insert, + struct: policy + }) + + send(socket.channel_pid, %Changes.Change{ + lsn: 2, + op: :insert, + struct: membership + }) + + {:ok, _reply, socket} = + API.Client.Socket + |> socket("client:#{client.id}", %{ + client: client, + subject: subject + }) + |> subscribe_and_join(API.Client.Channel, "client") + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + }) + + assert_push "flow_creation_failed", %{ + reason: :offline, + resource_id: resource_id + } + + assert resource_id == resource.id + + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: gateway_group, + context: + Fixtures.Auth.build_context( + type: :gateway_group, + user_agent: "Linux/24.04 connlib/1.4.11" + ) + ) + + :ok = Gateways.Presence.connect(gateway) + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [] + }) + + gateway_id = gateway.id + + assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, _payload} + end + + test "selects already connected gateway", %{ + account: account, + gateway_group: gateway_group, + dns_resource: resource, + dns_resource_policy: policy, + membership: membership, + socket: socket + } do + global_relay_group = Fixtures.Relays.create_global_group() + + relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + :ok = Domain.Relays.connect_relay(relay, Ecto.UUID.generate()) + + Fixtures.Relays.update_relay(relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + gateway1 = + Fixtures.Gateways.create_gateway( + account: account, + group: gateway_group + ) + + :ok = Gateways.Presence.connect(gateway1) + + gateway2 = + Fixtures.Gateways.create_gateway( + account: account, + group: gateway_group + ) + + :ok = Gateways.Presence.connect(gateway2) + + :ok = PubSub.Account.subscribe(account.id) + + send(socket.channel_pid, {:created, resource}) + send(socket.channel_pid, {:created, policy}) + send(socket.channel_pid, {:created, membership}) + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [gateway2.id] + }) + + gateway_id = gateway2.id + + assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, %{}} + + assert Repo.get_by(Domain.Flows.Flow, + resource_id: resource.id, + gateway_id: gateway2.id, + account_id: account.id + ) + + push(socket, "create_flow", %{ + "resource_id" => resource.id, + "connected_gateway_ids" => [gateway1.id] + }) + + gateway_id = gateway1.id + + assert_receive {{:authorize_flow, ^gateway_id}, {_channel_pid, _socket_ref}, %{}} + + assert Repo.get_by(Domain.Flows.Flow, + resource_id: resource.id, + gateway_id: gateway1.id, + account_id: account.id + ) + end + end + + describe "handle_in/3 prepare_connection" do + test "returns error when resource is not found", %{socket: socket} do + ref = push(socket, "prepare_connection", %{"resource_id" => Ecto.UUID.generate()}) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns error when there are no online relays", %{ + dns_resource: resource, + socket: socket + } do + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + assert_reply ref, :error, %{reason: :offline} + end + + test "returns error when all gateways are offline", %{ + dns_resource: resource, + socket: socket + } do + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + assert_reply ref, :error, %{reason: :offline} + end + + test "returns error when client has no policy allowing access to resource", %{ + account: account, + socket: socket + } do + resource = Fixtures.Resources.create_resource(account: account) + + gateway = Fixtures.Gateways.create_gateway(account: account) + :ok = Gateways.Presence.connect(gateway) + + attrs = %{ + "resource_id" => resource.id + } + + ref = push(socket, "prepare_connection", attrs) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns error when all gateways connected to the resource are offline", %{ + account: account, + dns_resource: resource, + socket: socket + } do + gateway = Fixtures.Gateways.create_gateway(account: account) + :ok = Gateways.Presence.connect(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + assert_reply ref, :error, %{reason: :offline} + end + + test "returns online gateway connected to the resource", %{ + dns_resource: resource, + gateway: gateway, + socket: socket + } do + global_relay_group = Fixtures.Relays.create_global_group() + + global_relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) + + Fixtures.Relays.update_relay(global_relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + :ok = Gateways.Presence.connect(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + resource_id = resource.id + + assert_reply ref, :ok, %{ + gateway_id: gateway_id, + gateway_remote_ip: gateway_last_seen_remote_ip, + resource_id: ^resource_id + } + + assert gateway_id == gateway.id + assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip + end + + test "does not return gateways that do not support the resource", %{ + account: account, + dns_resource: dns_resource, + internet_resource: internet_resource, + socket: socket + } do + gateway = Fixtures.Gateways.create_gateway(account: account) + :ok = Gateways.Presence.connect(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => dns_resource.id}) + assert_reply ref, :error, %{reason: :offline} + + ref = push(socket, "prepare_connection", %{"resource_id" => internet_resource.id}) + assert_reply ref, :error, %{reason: :offline} + end + + test "returns gateway that support the DNS resource address syntax", %{ + account: account, + actor_group: actor_group, + membership: membership, + socket: socket + } do + global_relay_group = Fixtures.Relays.create_global_group() + global_relay = Fixtures.Relays.create_relay(group: global_relay_group) + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) + + Fixtures.Relays.update_relay(global_relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + gateway_group = Fixtures.Gateways.create_group(account: account) + + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: gateway_group, + context: %{ + user_agent: "iOS/12.5 (iPhone) connlib/1.1.0" + } + ) + + resource = + Fixtures.Resources.create_resource( + address: "foo.*.example.com", + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource + ) + + :ok = Gateways.Presence.connect(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + resource_id = resource.id + + assert_reply ref, :error, %{reason: :not_found} + + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: gateway_group, + context: %{ + user_agent: "iOS/12.5 (iPhone) connlib/1.2.0" + } + ) + + Fixtures.Relays.update_relay(global_relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + :ok = Gateways.Presence.connect(gateway) + :ok = PubSub.Account.subscribe(account.id) + + send(socket.channel_pid, %Changes.Change{ + lsn: 0, + op: :insert, + struct: resource + }) + + send(socket.channel_pid, %Changes.Change{ + lsn: 1, + op: :insert, + struct: policy + }) + + send(socket.channel_pid, %Changes.Change{ + lsn: 2, + op: :insert, + struct: membership + }) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + + assert_reply ref, :ok, %{ + gateway_id: gateway_id, + gateway_remote_ip: gateway_last_seen_remote_ip, + resource_id: ^resource_id + } + + assert gateway_id == gateway.id + assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip + end + + test "returns gateway that support Internet resources", %{ + account: account, + internet_gateway_group: internet_gateway_group, + internet_resource: resource, + socket: socket + } do + account = + Fixtures.Accounts.update_account(account, + features: %{ + internet_resource: true + } + ) + + global_relay_group = Fixtures.Relays.create_global_group() + global_relay = Fixtures.Relays.create_relay(group: global_relay_group) + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(global_relay, stamp_secret) + + Fixtures.Relays.update_relay(global_relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: internet_gateway_group, + context: %{ + user_agent: "iOS/12.5 (iPhone) connlib/1.2.0" + } + ) + + :ok = Gateways.Presence.connect(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + resource_id = resource.id + + assert_reply ref, :error, %{reason: :offline} + + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: internet_gateway_group, + context: %{ + user_agent: "iOS/12.5 (iPhone) connlib/1.3.0" + } + ) + + Fixtures.Relays.update_relay(global_relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + :ok = Gateways.Presence.connect(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + + assert_reply ref, :ok, %{ + gateway_id: gateway_id, + gateway_remote_ip: gateway_last_seen_remote_ip, + resource_id: ^resource_id + } + + assert gateway_id == gateway.id + assert gateway_last_seen_remote_ip == gateway.last_seen_remote_ip + end + + test "works with service accounts", %{ + account: account, + dns_resource: resource, + gateway: gateway, + actor_group: actor_group + } do + actor = Fixtures.Actors.create_actor(type: :service_account, account: account) + client = Fixtures.Clients.create_client(account: account, actor: actor) + Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group) + + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) + + {:ok, _reply, socket} = + API.Client.Socket + |> socket("client:#{client.id}", %{ + client: client, + subject: subject + }) + |> subscribe_and_join(API.Client.Channel, "client") + + global_relay_group = Fixtures.Relays.create_global_group() + + relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + :ok = Domain.Relays.connect_relay(relay, Ecto.UUID.generate()) + + Fixtures.Relays.update_relay(relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + :ok = Gateways.Presence.connect(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + + assert_reply ref, :ok, %{} + end + + test "selects compatible gateway versions", %{ + account: account, + gateway_group: gateway_group, + dns_resource: resource, + subject: subject, + client: client + } do + global_relay_group = Fixtures.Relays.create_global_group() + + relay = + Fixtures.Relays.create_relay( + group: global_relay_group, + last_seen_remote_ip_location_lat: 37, + last_seen_remote_ip_location_lon: -120 + ) + + :ok = Domain.Relays.connect_relay(relay, Ecto.UUID.generate()) + + Fixtures.Relays.update_relay(relay, + last_seen_at: DateTime.utc_now() |> DateTime.add(-10, :second) + ) + + client = %{client | last_seen_version: "1.2.55"} + + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: gateway_group, + context: + Fixtures.Auth.build_context( + type: :gateway_group, + user_agent: "Linux/24.04 connlib/1.0.412" + ) + ) + + :ok = Gateways.Presence.connect(gateway) + + {:ok, _reply, socket} = + API.Client.Socket + |> socket("client:#{client.id}", %{ + client: client, + subject: subject + }) + |> subscribe_and_join(API.Client.Channel, "client") + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + + assert_reply ref, :error, %{reason: :offline} + + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: gateway_group, + context: + Fixtures.Auth.build_context( + type: :gateway_group, + user_agent: "Linux/24.04 connlib/1.1.11" + ) + ) + + :ok = Gateways.Presence.connect(gateway) + + ref = push(socket, "prepare_connection", %{"resource_id" => resource.id}) + + assert_reply ref, :ok, %{} + end + end + + describe "handle_in/3 reuse_connection" do + test "returns error when resource is not found", %{gateway: gateway, socket: socket} do + attrs = %{ + "resource_id" => Ecto.UUID.generate(), + "gateway_id" => gateway.id, + "payload" => "DNS_Q" + } + + ref = push(socket, "reuse_connection", attrs) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns error when gateway is not found", %{dns_resource: resource, socket: socket} do + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => Ecto.UUID.generate(), + "payload" => "DNS_Q" + } + + ref = push(socket, "reuse_connection", attrs) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns not_found when gateway is not connected to resource", %{ + account: account, + dns_resource: resource, + socket: socket + } do + gateway = Fixtures.Gateways.create_gateway(account: account) + :ok = Gateways.Presence.connect(gateway) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "payload" => "DNS_Q" + } + + ref = push(socket, "reuse_connection", attrs) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns :not_found when resource is not in connectable_resources", %{ + account: account, + client: client, + actor_group: actor_group, + membership: membership, + gateway_group: gateway_group, + gateway: gateway, + socket: socket + } do + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource, + conditions: [ + %{ + property: :remote_ip_location_region, + operator: :is_not_in, + values: [client.last_seen_remote_ip_location_region] + } + ] + ) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "payload" => "DNS_Q" + } + + :ok = Gateways.Presence.connect(gateway) + :ok = PubSub.Account.subscribe(account.id) + + send(socket.channel_pid, {:created, resource}) + send(socket.channel_pid, {:created, policy}) + send(socket.channel_pid, {:created, membership}) + + ref = push(socket, "reuse_connection", attrs) + + assert_reply ref, :error, %{ + reason: :not_found + } + end + + test "returns error when client has no policy allowing access to resource", %{ + account: account, + socket: socket + } do + resource = Fixtures.Resources.create_resource(account: account) + + gateway = Fixtures.Gateways.create_gateway(account: account) + :ok = Gateways.Presence.connect(gateway) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "payload" => "DNS_Q" + } + + ref = push(socket, "reuse_connection", attrs) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns error when gateway is offline", %{ + dns_resource: resource, + gateway: gateway, + socket: socket + } do + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "payload" => "DNS_Q" + } + + ref = push(socket, "reuse_connection", attrs) + assert_reply ref, :error, %{reason: :offline} + end + + test "broadcasts allow_access to the gateways and then returns connect message", %{ + dns_resource: resource, + dns_resource_policy: policy, + membership: membership, + gateway: gateway, + client: client, + socket: socket + } do + public_key = gateway.public_key + resource_id = resource.id + client_id = client.id + + :ok = Gateways.Presence.connect(gateway) + :ok = PubSub.Account.subscribe(resource.account_id) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: resource + }) + + send(socket.channel_pid, %Changes.Change{ + lsn: 101, + op: :insert, + struct: policy + }) + + send(socket.channel_pid, %Changes.Change{ + lsn: 102, + op: :insert, + struct: membership + }) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "payload" => "DNS_Q" + } + + ref = push(socket, "reuse_connection", attrs) + + gateway_id = gateway.id + + assert_receive {{:allow_access, ^gateway_id}, {channel_pid, socket_ref}, payload} + + assert %{ + resource: recv_resource, + client: recv_client, + authorization_expires_at: authorization_expires_at, + client_payload: "DNS_Q" + } = payload + + assert recv_resource.id == Ecto.UUID.dump!(resource_id) + assert recv_client.id == client_id + assert authorization_expires_at == socket.assigns.subject.expires_at + + send( + channel_pid, + {:connect, socket_ref, Ecto.UUID.dump!(resource.id), gateway.public_key, "DNS_RPL"} + ) + + assert_reply ref, :ok, %{ + resource_id: ^resource_id, + persistent_keepalive: 25, + gateway_public_key: ^public_key, + gateway_payload: "DNS_RPL" + } + end + + test "works with service accounts", %{ + account: account, + dns_resource: resource, + dns_resource_policy: policy, + membership: membership, + gateway: gateway, + gateway_group_token: gateway_group_token, + actor_group: actor_group + } do + actor = Fixtures.Actors.create_actor(type: :service_account, account: account) + client = Fixtures.Clients.create_client(account: account, actor: actor) + Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group) + + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) + + {:ok, _reply, socket} = + API.Client.Socket + |> socket("client:#{client.id}", %{ + client: client, + subject: subject + }) + |> subscribe_and_join(API.Client.Channel, "client") + + :ok = Gateways.Presence.connect(gateway) + Phoenix.PubSub.subscribe(PubSub, Domain.Tokens.socket_id(gateway_group_token)) + + :ok = PubSub.Account.subscribe(account.id) + + send(socket.channel_pid, {:created, resource}) + send(socket.channel_pid, {:created, policy}) + send(socket.channel_pid, {:created, membership}) + + push(socket, "reuse_connection", %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "payload" => "DNS_Q" + }) + + gateway_id = gateway.id + + assert_receive {{:allow_access, ^gateway_id}, _refs, _payload} + end + end + + describe "handle_in/3 request_connection" do + test "returns error when resource is not found", %{gateway: gateway, socket: socket} do + attrs = %{ + "resource_id" => Ecto.UUID.generate(), + "gateway_id" => gateway.id, + "client_payload" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns error when gateway is not found", %{dns_resource: resource, socket: socket} do + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => Ecto.UUID.generate(), + "client_payload" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns error when gateway is not connected to resource", %{ + account: account, + dns_resource: resource, + socket: socket + } do + gateway = Fixtures.Gateways.create_gateway(account: account) + :ok = Gateways.Presence.connect(gateway) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "client_payload" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns error when client has no policy allowing access to resource", %{ + account: account, + socket: socket + } do + resource = Fixtures.Resources.create_resource(account: account) + + gateway = Fixtures.Gateways.create_gateway(account: account) + :ok = Gateways.Presence.connect(gateway) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "client_payload" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + assert_reply ref, :error, %{reason: :not_found} + end + + test "returns not_found when resource is not in connectable_resources", %{ + account: account, + client: client, + actor_group: actor_group, + membership: membership, + gateway_group: gateway_group, + gateway: gateway, + socket: socket + } do + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: gateway_group.id}] + ) + + policy = + Fixtures.Policies.create_policy( + account: account, + actor_group: actor_group, + resource: resource, + conditions: [ + %{ + property: :remote_ip_location_region, + operator: :is_not_in, + values: [client.last_seen_remote_ip_location_region] + } + ] + ) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "client_payload" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + :ok = Gateways.Presence.connect(gateway) + + :ok = PubSub.Account.subscribe(account.id) + + send(socket.channel_pid, %Changes.Change{ + lsn: 100, + op: :insert, + struct: resource + }) + + send(socket.channel_pid, %Changes.Change{ + lsn: 101, + op: :insert, + struct: policy + }) + + send(socket.channel_pid, %Changes.Change{ + lsn: 102, + op: :insert, + struct: membership + }) + + ref = push(socket, "request_connection", attrs) + + assert_reply ref, :error, %{ + reason: :not_found + } + end + + test "returns error when gateway is offline", %{ + dns_resource: resource, + gateway: gateway, + socket: socket + } do + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "client_payload" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + assert_reply ref, :error, %{reason: :offline} + end + + test "broadcasts request_connection to the gateways and then returns connect message", %{ + dns_resource: resource, + gateway_group_token: gateway_group_token, + gateway: gateway, + client: client, + socket: socket + } do + public_key = gateway.public_key + resource_id = resource.id + client_id = client.id + + :ok = Gateways.Presence.connect(gateway) + PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) + + :ok = PubSub.Account.subscribe(resource.account_id) + + attrs = %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "client_payload" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + + gateway_id = gateway.id + + assert_receive {{:request_connection, ^gateway_id}, {channel_pid, socket_ref}, payload} + + assert %{ + resource: recv_resource, + client: recv_client, + client_preshared_key: "PSK", + client_payload: "RTC_SD", + authorization_expires_at: authorization_expires_at + } = payload + + assert recv_resource.id == Ecto.UUID.dump!(resource_id) + assert recv_client.id == client_id + + assert authorization_expires_at == socket.assigns.subject.expires_at + + send( + channel_pid, + {:connect, socket_ref, Ecto.UUID.dump!(resource.id), gateway.public_key, "FULL_RTC_SD"} + ) + + assert_reply ref, :ok, %{ + resource_id: ^resource_id, + persistent_keepalive: 25, + gateway_public_key: ^public_key, + gateway_payload: "FULL_RTC_SD" + } + end + + test "works with service accounts", %{ + account: account, + dns_resource: resource, + gateway_group_token: gateway_group_token, + gateway: gateway, + actor_group: actor_group + } do + actor = Fixtures.Actors.create_actor(type: :service_account, account: account) + client = Fixtures.Clients.create_client(account: account, actor: actor) + Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group) + + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) + + {:ok, _reply, socket} = + API.Client.Socket + |> socket("client:#{client.id}", %{ + client: client, + subject: subject + }) + |> subscribe_and_join(API.Client.Channel, "client") + + :ok = Gateways.Presence.connect(gateway) + Phoenix.PubSub.subscribe(PubSub, Domain.Tokens.socket_id(gateway_group_token)) + + :ok = PubSub.Account.subscribe(account.id) + + push(socket, "request_connection", %{ + "resource_id" => resource.id, + "gateway_id" => gateway.id, + "client_payload" => "RTC_SD", + "client_preshared_key" => "PSK" + }) + + gateway_id = gateway.id + + assert_receive {{:request_connection, ^gateway_id}, _refs, _payload} + end + end + + describe "handle_in/3 broadcast_ice_candidates" do + test "does nothing when gateways list is empty", %{ + socket: socket + } do + candidates = ["foo", "bar"] + + attrs = %{ + "candidates" => candidates, + "gateway_ids" => [] + } + + push(socket, "broadcast_ice_candidates", attrs) + refute_receive {:ice_candidates, _client_id, _candidates} + end + + test "broadcasts :ice_candidates message to all gateways", %{ + client: client, + gateway_group_token: gateway_group_token, + gateway: gateway, + socket: socket + } do + candidates = ["foo", "bar"] + + attrs = %{ + "candidates" => candidates, + "gateway_ids" => [gateway.id] + } + + :ok = Gateways.Presence.connect(gateway) + PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) + + :ok = PubSub.Account.subscribe(client.account_id) + + push(socket, "broadcast_ice_candidates", attrs) + + gateway_id = gateway.id + + assert_receive {{:ice_candidates, ^gateway_id}, client_id, ^candidates}, 200 + assert client.id == client_id + end + end + + describe "handle_in/3 broadcast_invalidated_ice_candidates" do + test "does nothing when gateways list is empty", %{ + socket: socket + } do + candidates = ["foo", "bar"] + + attrs = %{ + "candidates" => candidates, + "gateway_ids" => [] + } + + push(socket, "broadcast_invalidated_ice_candidates", attrs) + refute_receive {:invalidate_ice_candidates, _client_id, _candidates} + end + + test "broadcasts :invalidate_ice_candidates message to all gateways", %{ + client: client, + gateway_group_token: gateway_group_token, + gateway: gateway, + socket: socket + } do + candidates = ["foo", "bar"] + + attrs = %{ + "candidates" => candidates, + "gateway_ids" => [gateway.id] + } + + :ok = Gateways.Presence.connect(gateway) + :ok = PubSub.subscribe(Domain.Tokens.socket_id(gateway_group_token)) + :ok = PubSub.Account.subscribe(client.account_id) + + push(socket, "broadcast_invalidated_ice_candidates", attrs) + + gateway_id = gateway.id + + assert_receive {{:invalidate_ice_candidates, ^gateway_id}, client_id, ^candidates}, 200 + assert client.id == client_id + end + end + + describe "handle_in/3 for unknown message" do + test "it doesn't crash", %{socket: socket} do ref = push(socket, "unknown_message", %{}) assert_reply ref, :error, %{reason: :unknown_message} end diff --git a/elixir/apps/api/test/api/gateway/channel_test.exs b/elixir/apps/api/test/api/gateway/channel_test.exs index f6aaedfd1..2ac7fe831 100644 --- a/elixir/apps/api/test/api/gateway/channel_test.exs +++ b/elixir/apps/api/test/api/gateway/channel_test.exs @@ -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)) diff --git a/elixir/apps/domain/lib/domain/actors.ex b/elixir/apps/domain/lib/domain/actors.ex index cf1e7f49a..772697146 100644 --- a/elixir/apps/domain/lib/domain/actors.ex +++ b/elixir/apps/domain/lib/domain/actors.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/actors/membership.ex b/elixir/apps/domain/lib/domain/actors/membership.ex index a9297a3cb..7babb7155 100644 --- a/elixir/apps/domain/lib/domain/actors/membership.ex +++ b/elixir/apps/domain/lib/domain/actors/membership.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/application.ex b/elixir/apps/domain/lib/domain/application.ex index d20a71a13..3361baedc 100644 --- a/elixir/apps/domain/lib/domain/application.ex +++ b/elixir/apps/domain/lib/domain/application.ex @@ -81,7 +81,7 @@ defmodule Domain.Application do defp replication do connection_modules = [ - Domain.Events.ReplicationConnection, + Domain.Changes.ReplicationConnection, Domain.ChangeLogs.ReplicationConnection ] diff --git a/elixir/apps/domain/lib/domain/cache/cacheable.ex b/elixir/apps/domain/lib/domain/cache/cacheable.ex new file mode 100644 index 000000000..b94cc1620 --- /dev/null +++ b/elixir/apps/domain/lib/domain/cache/cacheable.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/cache/cacheable/gateway_group.ex b/elixir/apps/domain/lib/domain/cache/cacheable/gateway_group.ex new file mode 100644 index 000000000..60880adcf --- /dev/null +++ b/elixir/apps/domain/lib/domain/cache/cacheable/gateway_group.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/cache/cacheable/policy.ex b/elixir/apps/domain/lib/domain/cache/cacheable/policy.ex new file mode 100644 index 000000000..b6fd0354d --- /dev/null +++ b/elixir/apps/domain/lib/domain/cache/cacheable/policy.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/cache/cacheable/resource.ex b/elixir/apps/domain/lib/domain/cache/cacheable/resource.ex new file mode 100644 index 000000000..f1254eb74 --- /dev/null +++ b/elixir/apps/domain/lib/domain/cache/cacheable/resource.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/cache/client.ex b/elixir/apps/domain/lib/domain/cache/client.ex new file mode 100644 index 000000000..c0338e4dd --- /dev/null +++ b/elixir/apps/domain/lib/domain/cache/client.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/cache/gateway.ex b/elixir/apps/domain/lib/domain/cache/gateway.ex new file mode 100644 index 000000000..30636b633 --- /dev/null +++ b/elixir/apps/domain/lib/domain/cache/gateway.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/change.ex b/elixir/apps/domain/lib/domain/changes/change.ex new file mode 100644 index 000000000..6ebba7c6c --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/change.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks.ex b/elixir/apps/domain/lib/domain/changes/hooks.ex new file mode 100644 index 000000000..4c7757b81 --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/accounts.ex b/elixir/apps/domain/lib/domain/changes/hooks/accounts.ex new file mode 100644 index 000000000..f3a396411 --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/accounts.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/actor_group_memberships.ex b/elixir/apps/domain/lib/domain/changes/hooks/actor_group_memberships.ex new file mode 100644 index 000000000..17043c698 --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/actor_group_memberships.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/clients.ex b/elixir/apps/domain/lib/domain/changes/hooks/clients.ex new file mode 100644 index 000000000..830bc652e --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/clients.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/flows.ex b/elixir/apps/domain/lib/domain/changes/hooks/flows.ex new file mode 100644 index 000000000..7db0509fc --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/flows.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/gateway_groups.ex b/elixir/apps/domain/lib/domain/changes/hooks/gateway_groups.ex new file mode 100644 index 000000000..cbd6af200 --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/gateway_groups.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/gateways.ex b/elixir/apps/domain/lib/domain/changes/hooks/gateways.ex new file mode 100644 index 000000000..0fee631d4 --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/gateways.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/policies.ex b/elixir/apps/domain/lib/domain/changes/hooks/policies.ex new file mode 100644 index 000000000..511ba8d05 --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/policies.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/resource_connections.ex b/elixir/apps/domain/lib/domain/changes/hooks/resource_connections.ex new file mode 100644 index 000000000..3d67be99f --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/resource_connections.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/resources.ex b/elixir/apps/domain/lib/domain/changes/hooks/resources.ex new file mode 100644 index 000000000..dd3fd4c2f --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/resources.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/changes/hooks/tokens.ex b/elixir/apps/domain/lib/domain/changes/hooks/tokens.ex new file mode 100644 index 000000000..a9c3d3128 --- /dev/null +++ b/elixir/apps/domain/lib/domain/changes/hooks/tokens.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/replication_connection.ex b/elixir/apps/domain/lib/domain/changes/replication_connection.ex similarity index 67% rename from elixir/apps/domain/lib/domain/events/replication_connection.ex rename to elixir/apps/domain/lib/domain/changes/replication_connection.ex index 73d2376b7..dc580131c 100644 --- a/elixir/apps/domain/lib/domain/events/replication_connection.ex +++ b/elixir/apps/domain/lib/domain/changes/replication_connection.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/clients/client.ex b/elixir/apps/domain/lib/domain/clients/client.ex index dba43a9b5..6c71f9b90 100644 --- a/elixir/apps/domain/lib/domain/clients/client.ex +++ b/elixir/apps/domain/lib/domain/clients/client.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index 3bf383eac..e2419bc86 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -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. diff --git a/elixir/apps/domain/lib/domain/events/hooks.ex b/elixir/apps/domain/lib/domain/events/hooks.ex deleted file mode 100644 index f6b1eaae5..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/accounts.ex b/elixir/apps/domain/lib/domain/events/hooks/accounts.ex deleted file mode 100644 index feba1c03f..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/accounts.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/actor_group_memberships.ex b/elixir/apps/domain/lib/domain/events/hooks/actor_group_memberships.ex deleted file mode 100644 index 3143900ac..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/actor_group_memberships.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/clients.ex b/elixir/apps/domain/lib/domain/events/hooks/clients.ex deleted file mode 100644 index 23e5c8fc4..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/clients.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/flows.ex b/elixir/apps/domain/lib/domain/events/hooks/flows.ex deleted file mode 100644 index 98a295904..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/flows.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/gateway_groups.ex b/elixir/apps/domain/lib/domain/events/hooks/gateway_groups.ex deleted file mode 100644 index 1b3c4f857..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/gateway_groups.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/gateways.ex b/elixir/apps/domain/lib/domain/events/hooks/gateways.ex deleted file mode 100644 index a73925b0e..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/gateways.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/policies.ex b/elixir/apps/domain/lib/domain/events/hooks/policies.ex deleted file mode 100644 index 2e5f4529a..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/policies.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/resource_connections.ex b/elixir/apps/domain/lib/domain/events/hooks/resource_connections.ex deleted file mode 100644 index 2e38c3bc6..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/resource_connections.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/resources.ex b/elixir/apps/domain/lib/domain/events/hooks/resources.ex deleted file mode 100644 index 57f1a0521..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/resources.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/events/hooks/tokens.ex b/elixir/apps/domain/lib/domain/events/hooks/tokens.ex deleted file mode 100644 index e5603a29d..000000000 --- a/elixir/apps/domain/lib/domain/events/hooks/tokens.ex +++ /dev/null @@ -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 diff --git a/elixir/apps/domain/lib/domain/flows.ex b/elixir/apps/domain/lib/domain/flows.ex index 7488ccff6..f0d869a49 100644 --- a/elixir/apps/domain/lib/domain/flows.ex +++ b/elixir/apps/domain/lib/domain/flows.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/flows/flow.ex b/elixir/apps/domain/lib/domain/flows/flow.ex index ce7797b53..cd164077c 100644 --- a/elixir/apps/domain/lib/domain/flows/flow.ex +++ b/elixir/apps/domain/lib/domain/flows/flow.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/flows/flow/query.ex b/elixir/apps/domain/lib/domain/flows/flow/query.ex index 3c55ce732..17f6df896 100644 --- a/elixir/apps/domain/lib/domain/flows/flow/query.ex +++ b/elixir/apps/domain/lib/domain/flows/flow/query.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 209ec0e98..b46d8a454 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways/gateway.ex b/elixir/apps/domain/lib/domain/gateways/gateway.ex index 01675879f..bbe9c6006 100644 --- a/elixir/apps/domain/lib/domain/gateways/gateway.ex +++ b/elixir/apps/domain/lib/domain/gateways/gateway.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways/group.ex b/elixir/apps/domain/lib/domain/gateways/group.ex index 960e7488c..e7b04c6a5 100644 --- a/elixir/apps/domain/lib/domain/gateways/group.ex +++ b/elixir/apps/domain/lib/domain/gateways/group.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies.ex b/elixir/apps/domain/lib/domain/policies.ex index 92b3580f0..82f99979e 100644 --- a/elixir/apps/domain/lib/domain/policies.ex +++ b/elixir/apps/domain/lib/domain/policies.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/condition.ex b/elixir/apps/domain/lib/domain/policies/condition.ex index ff78fa90e..cab7ac90e 100644 --- a/elixir/apps/domain/lib/domain/policies/condition.ex +++ b/elixir/apps/domain/lib/domain/policies/condition.ex @@ -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[ diff --git a/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex b/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex index d14ec6e97..a9da2f9e7 100644 --- a/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex +++ b/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/policy.ex b/elixir/apps/domain/lib/domain/policies/policy.ex index 329c4adef..9964d697b 100644 --- a/elixir/apps/domain/lib/domain/policies/policy.ex +++ b/elixir/apps/domain/lib/domain/policies/policy.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/policy/query.ex b/elixir/apps/domain/lib/domain/policies/policy/query.ex index 6fcfc06a5..6b7e63761 100644 --- a/elixir/apps/domain/lib/domain/policies/policy/query.ex +++ b/elixir/apps/domain/lib/domain/policies/policy/query.ex @@ -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() diff --git a/elixir/apps/domain/lib/domain/pubsub.ex b/elixir/apps/domain/lib/domain/pubsub.ex index 721d5b31e..17d4b010c 100644 --- a/elixir/apps/domain/lib/domain/pubsub.ex +++ b/elixir/apps/domain/lib/domain/pubsub.ex @@ -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() diff --git a/elixir/apps/domain/lib/domain/relays.ex b/elixir/apps/domain/lib/domain/relays.ex index 2dbc7c56c..0ce071279 100644 --- a/elixir/apps/domain/lib/domain/relays.ex +++ b/elixir/apps/domain/lib/domain/relays.ex @@ -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}" diff --git a/elixir/apps/domain/lib/domain/replication/decoder.ex b/elixir/apps/domain/lib/domain/replication/decoder.ex index abc64922e..d2036bbe1 100644 --- a/elixir/apps/domain/lib/domain/replication/decoder.ex +++ b/elixir/apps/domain/lib/domain/replication/decoder.ex @@ -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 | [<>]] ] = 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 diff --git a/elixir/apps/domain/lib/domain/resources.ex b/elixir/apps/domain/lib/domain/resources.ex index 0209eb955..3b4ec5661 100644 --- a/elixir/apps/domain/lib/domain/resources.ex +++ b/elixir/apps/domain/lib/domain/resources.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources/connection.ex b/elixir/apps/domain/lib/domain/resources/connection.ex index ea217918d..d66b52e87 100644 --- a/elixir/apps/domain/lib/domain/resources/connection.ex +++ b/elixir/apps/domain/lib/domain/resources/connection.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources/resource.ex b/elixir/apps/domain/lib/domain/resources/resource.ex index 7bc9038b9..2567a9662 100644 --- a/elixir/apps/domain/lib/domain/resources/resource.ex +++ b/elixir/apps/domain/lib/domain/resources/resource.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/types/int4range.ex b/elixir/apps/domain/lib/domain/types/int4range.ex index f4c54f9b8..edafd397b 100644 --- a/elixir/apps/domain/lib/domain/types/int4range.ex +++ b/elixir/apps/domain/lib/domain/types/int4range.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/types/ip.ex b/elixir/apps/domain/lib/domain/types/ip.ex index 8a4dcb07a..2c64ef41a 100644 --- a/elixir/apps/domain/lib/domain/types/ip.ex +++ b/elixir/apps/domain/lib/domain/types/ip.ex @@ -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 diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 90b1d441c..6611e8a65 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events/hooks/accounts_test.exs b/elixir/apps/domain/test/domain/changes/hooks/accounts_test.exs similarity index 64% rename from elixir/apps/domain/test/domain/events/hooks/accounts_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/accounts_test.exs index 8f584d6e6..f23cd6cf4 100644 --- a/elixir/apps/domain/test/domain/events/hooks/accounts_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/accounts_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events/hooks/actor_group_memberships_test.exs b/elixir/apps/domain/test/domain/changes/hooks/actor_group_memberships_test.exs similarity index 75% rename from elixir/apps/domain/test/domain/events/hooks/actor_group_memberships_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/actor_group_memberships_test.exs index 14a7fdb9e..b77d1ad51 100644 --- a/elixir/apps/domain/test/domain/events/hooks/actor_group_memberships_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/actor_group_memberships_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events/hooks/clients_test.exs b/elixir/apps/domain/test/domain/changes/hooks/clients_test.exs similarity index 70% rename from elixir/apps/domain/test/domain/events/hooks/clients_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/clients_test.exs index 2f88af771..0d3f14797 100644 --- a/elixir/apps/domain/test/domain/events/hooks/clients_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/clients_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events/hooks/flows_test.exs b/elixir/apps/domain/test/domain/changes/hooks/flows_test.exs similarity index 78% rename from elixir/apps/domain/test/domain/events/hooks/flows_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/flows_test.exs index f981c2aac..204531cfa 100644 --- a/elixir/apps/domain/test/domain/events/hooks/flows_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/flows_test.exs @@ -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" diff --git a/elixir/apps/domain/test/domain/events/hooks/gateway_groups_test.exs b/elixir/apps/domain/test/domain/changes/hooks/gateway_groups_test.exs similarity index 67% rename from elixir/apps/domain/test/domain/events/hooks/gateway_groups_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/gateway_groups_test.exs index 72cd98bfc..886ee1ba7 100644 --- a/elixir/apps/domain/test/domain/events/hooks/gateway_groups_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/gateway_groups_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events/hooks/gateways_test.exs b/elixir/apps/domain/test/domain/changes/hooks/gateways_test.exs similarity index 70% rename from elixir/apps/domain/test/domain/events/hooks/gateways_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/gateways_test.exs index 05a4d5a41..89665f2ff 100644 --- a/elixir/apps/domain/test/domain/events/hooks/gateways_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/gateways_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events/hooks/policies_test.exs b/elixir/apps/domain/test/domain/changes/hooks/policies_test.exs similarity index 85% rename from elixir/apps/domain/test/domain/events/hooks/policies_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/policies_test.exs index fa0fc4423..4dd4e5079 100644 --- a/elixir/apps/domain/test/domain/events/hooks/policies_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/policies_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events/hooks/resource_connections_test.exs b/elixir/apps/domain/test/domain/changes/hooks/resource_connections_test.exs similarity index 61% rename from elixir/apps/domain/test/domain/events/hooks/resource_connections_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/resource_connections_test.exs index 19bbdb342..29d026b75 100644 --- a/elixir/apps/domain/test/domain/events/hooks/resource_connections_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/resource_connections_test.exs @@ -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"] diff --git a/elixir/apps/domain/test/domain/events/hooks/resources_test.exs b/elixir/apps/domain/test/domain/changes/hooks/resources_test.exs similarity index 84% rename from elixir/apps/domain/test/domain/events/hooks/resources_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/resources_test.exs index 58b5af71a..9b3f8b017 100644 --- a/elixir/apps/domain/test/domain/events/hooks/resources_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/resources_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events/hooks/tokens_test.exs b/elixir/apps/domain/test/domain/changes/hooks/tokens_test.exs similarity index 57% rename from elixir/apps/domain/test/domain/events/hooks/tokens_test.exs rename to elixir/apps/domain/test/domain/changes/hooks/tokens_test.exs index 27ee987f5..39220cc9f 100644 --- a/elixir/apps/domain/test/domain/events/hooks/tokens_test.exs +++ b/elixir/apps/domain/test/domain/changes/hooks/tokens_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events/replication_connection_test.exs b/elixir/apps/domain/test/domain/changes/replication_connection_test.exs similarity index 85% rename from elixir/apps/domain/test/domain/events/replication_connection_test.exs rename to elixir/apps/domain/test/domain/changes/replication_connection_test.exs index 99e6a171f..2a002ccdf 100644 --- a/elixir/apps/domain/test/domain/events/replication_connection_test.exs +++ b/elixir/apps/domain/test/domain/changes/replication_connection_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/events_test.exs b/elixir/apps/domain/test/domain/events_test.exs deleted file mode 100644 index 8d300ec1b..000000000 --- a/elixir/apps/domain/test/domain/events_test.exs +++ /dev/null @@ -1,4 +0,0 @@ -defmodule Domain.EventsTest do - # TODO: WAL - # Add integration tests to ensure side effects trigger broadcasts in general -end diff --git a/elixir/apps/domain/test/domain/flows_test.exs b/elixir/apps/domain/test/domain/flows_test.exs index 770f5db28..b1c425e16 100644 --- a/elixir/apps/domain/test/domain/flows_test.exs +++ b/elixir/apps/domain/test/domain/flows_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 8f92139ae..8bfdffb54 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/policies_test.exs b/elixir/apps/domain/test/domain/policies_test.exs index 9738934cd..70cc6bdfe 100644 --- a/elixir/apps/domain/test/domain/policies_test.exs +++ b/elixir/apps/domain/test/domain/policies_test.exs @@ -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, diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index ed640f7ff..d9d5b7b0d 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -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 diff --git a/elixir/apps/domain/test/support/fixtures/policies.ex b/elixir/apps/domain/test/support/fixtures/policies.ex index e1230eb37..098267e09 100644 --- a/elixir/apps/domain/test/support/fixtures/policies.ex +++ b/elixir/apps/domain/test/support/fixtures/policies.ex @@ -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 diff --git a/elixir/apps/domain/test/support/fixtures/resources.ex b/elixir/apps/domain/test/support/fixtures/resources.ex index 8d0b000d3..7c32294da 100644 --- a/elixir/apps/domain/test/support/fixtures/resources.ex +++ b/elixir/apps/domain/test/support/fixtures/resources.ex @@ -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 diff --git a/elixir/apps/web/lib/web/live/policies/index.ex b/elixir/apps/web/lib/web/live/policies/index.ex index 35d6109bd..5deae3bcf 100644 --- a/elixir/apps/web/lib/web/live/policies/index.ex +++ b/elixir/apps/web/lib/web/live/policies/index.ex @@ -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 diff --git a/elixir/apps/web/lib/web/live/resources/index.ex b/elixir/apps/web/lib/web/live/resources/index.ex index 6dce207b5..a60f0c19c 100644 --- a/elixir/apps/web/lib/web/live/resources/index.ex +++ b/elixir/apps/web/lib/web/live/resources/index.ex @@ -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 diff --git a/elixir/apps/web/test/web/acceptance/auth_test.exs b/elixir/apps/web/test/web/acceptance/auth_test.exs index 09827ff69..e6d562c97 100644 --- a/elixir/apps/web/test/web/acceptance/auth_test.exs +++ b/elixir/apps/web/test/web/acceptance/auth_test.exs @@ -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, diff --git a/elixir/apps/web/test/web/live/policies/index_test.exs b/elixir/apps/web/test/web/live/policies/index_test.exs index 465e16935..b490401c5 100644 --- a/elixir/apps/web/test/web/live/policies/index_test.exs +++ b/elixir/apps/web/test/web/live/policies/index_test.exs @@ -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") diff --git a/elixir/apps/web/test/web/live/resources/edit_test.exs b/elixir/apps/web/test/web/live/resources/edit_test.exs index 9d8624594..e901a1af0 100644 --- a/elixir/apps/web/test/web/live/resources/edit_test.exs +++ b/elixir/apps/web/test/web/live/resources/edit_test.exs @@ -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 diff --git a/elixir/apps/web/test/web/live/resources/index_test.exs b/elixir/apps/web/test/web/live/resources/index_test.exs index be1fa56e2..218d5cdf0 100644 --- a/elixir/apps/web/test/web/live/resources/index_test.exs +++ b/elixir/apps/web/test/web/live/resources/index_test.exs @@ -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, diff --git a/elixir/config/config.exs b/elixir/config/config.exs index 90123143d..7dfba1523 100644 --- a/elixir/config/config.exs +++ b/elixir/config/config.exs @@ -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 diff --git a/elixir/config/runtime.exs b/elixir/config/runtime.exs index 15d174d32..fb46e0677 100644 --- a/elixir/config/runtime.exs +++ b/elixir/config/runtime.exs @@ -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), diff --git a/elixir/config/test.exs b/elixir/config/test.exs index 093a41de9..578831a88 100644 --- a/elixir/config/test.exs +++ b/elixir/config/test.exs @@ -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}"