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}"