diff --git a/apps/api/lib/api/application.ex b/apps/api/lib/api/application.ex index 0f60d82fa..1933bf15f 100644 --- a/apps/api/lib/api/application.ex +++ b/apps/api/lib/api/application.ex @@ -4,9 +4,6 @@ defmodule API.Application do @impl true def start(_type, _args) do children = [ - API.Client.Presence, - API.Gateway.Presence, - API.Relay.Presence, API.Telemetry, API.Endpoint ] diff --git a/apps/api/lib/api/client/channel.ex b/apps/api/lib/api/client/channel.ex index f18fa11c4..cbba437cc 100644 --- a/apps/api/lib/api/client/channel.ex +++ b/apps/api/lib/api/client/channel.ex @@ -1,6 +1,9 @@ defmodule API.Client.Channel do use API, :channel - alias API.Client.Presence + alias Domain.Clients + + # TODO: we need to self-terminate channel once the user token is set to expire, preventing + # users from holding infinite session for if they want to keep websocket open for a while @impl true def join("client", _payload, socket) do @@ -10,11 +13,8 @@ defmodule API.Client.Channel do @impl true def handle_info(:after_join, socket) do - {:ok, _} = - Presence.track(socket, socket.assigns.client.id, %{ - online_at: System.system_time(:second) - }) - + :ok = Clients.connect_client(socket.assigns.client, socket) + :ok = push(socket, "resources", %{resources: []}) {:noreply, socket} end end diff --git a/apps/api/lib/api/client/socket.ex b/apps/api/lib/api/client/socket.ex index a59bb8b4b..7ce8d8f98 100644 --- a/apps/api/lib/api/client/socket.ex +++ b/apps/api/lib/api/client/socket.ex @@ -1,5 +1,6 @@ defmodule API.Client.Socket do use Phoenix.Socket + alias Domain.{Auth, Clients} ## Channels @@ -8,11 +9,18 @@ defmodule API.Client.Socket do ## Authentication @impl true - def connect(%{"token" => token, "id" => external_id}, socket, connect_info) do - %{user_agent: user_agent, peer_data: peer_data} = connect_info + def connect(%{"token" => token} = attrs, socket, connect_info) do + %{user_agent: user_agent, peer_data: %{address: remote_ip}} = connect_info + + # TODO: we want to scope tokens for specific use cases, so token generated in auth flow + # should be only good for websockets, but not to be put in a browser cookie + with {:ok, subject} <- Auth.consume_auth_token(token, remote_ip, user_agent), + {:ok, client} <- Clients.upsert_client(attrs, subject) do + socket = + socket + |> assign(:subject, subject) + |> assign(:client, client) - with {:ok, subject} <- Auth.fetch_subject_by_token(token), - {:ok, client} <- Clients.upsert_client(external_id, user_agent, peer_data, subject) do {:ok, socket} end end diff --git a/apps/api/lib/api/endpoint.ex b/apps/api/lib/api/endpoint.ex index c24b88e65..4b839645c 100644 --- a/apps/api/lib/api/endpoint.ex +++ b/apps/api/lib/api/endpoint.ex @@ -5,10 +5,28 @@ defmodule API.Endpoint do plug Phoenix.CodeReloader end + plug Plug.RewriteOn, [:x_forwarded_proto] + plug Plug.MethodOverride + + plug RemoteIp, + headers: ["x-forwarded-for"], + proxies: {__MODULE__, :external_trusted_proxies, []}, + clients: {__MODULE__, :clients, []} + plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] socket "/gateway", API.Gateway.Socket, API.Sockets.options() socket "/client", API.Client.Socket, API.Sockets.options() socket "/relay", API.Relay.Socket, API.Sockets.options() + + def external_trusted_proxies do + Domain.Config.fetch_env!(:api, :external_trusted_proxies) + |> Enum.map(&to_string/1) + end + + def clients do + Domain.Config.fetch_env!(:api, :private_clients) + |> Enum.map(&to_string/1) + end end diff --git a/apps/api/lib/api/gateway/channel.ex b/apps/api/lib/api/gateway/channel.ex index 6d2044be0..9d5240a5f 100644 --- a/apps/api/lib/api/gateway/channel.ex +++ b/apps/api/lib/api/gateway/channel.ex @@ -1,6 +1,6 @@ defmodule API.Gateway.Channel do use API, :channel - alias API.Gateway.Presence + alias Domain.Gateways @impl true def join("gateway", _payload, socket) do @@ -10,11 +10,7 @@ defmodule API.Gateway.Channel do @impl true def handle_info(:after_join, socket) do - {:ok, _} = - Presence.track(socket, socket.assigns.gateway.id, %{ - online_at: System.system_time(:second) - }) - + Gateways.connect_gateway(socket.assigns.gateway, socket) {:noreply, socket} end end diff --git a/apps/api/lib/api/gateway/socket.ex b/apps/api/lib/api/gateway/socket.ex index 0d98310d6..354c0bbce 100644 --- a/apps/api/lib/api/gateway/socket.ex +++ b/apps/api/lib/api/gateway/socket.ex @@ -1,5 +1,6 @@ defmodule API.Gateway.Socket do use Phoenix.Socket + alias Domain.Gateways ## Channels @@ -7,9 +8,47 @@ defmodule API.Gateway.Socket do ## Authentication + def encode_token!(%Gateways.Token{value: value} = token) when not is_nil(value) do + body = {token.id, token.value} + config = fetch_config!() + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + Plug.Crypto.sign(key_base, salt, body) + end + @impl true - def connect(_params, socket, _connect_info) do - {:ok, socket} + def connect(%{"token" => encrypted_secret} = attrs, socket, connect_info) do + %{user_agent: user_agent, peer_data: %{address: remote_ip}} = connect_info + + config = fetch_config!() + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + max_age = Keyword.fetch!(config, :max_age) + + attrs = + attrs + |> Map.take(~w[external_id name_suffix public_key]) + |> Map.put("last_seen_user_agent", user_agent) + |> Map.put("last_seen_remote_ip", remote_ip) + + with {:ok, {id, secret}} <- + Plug.Crypto.verify(key_base, salt, encrypted_secret, max_age: max_age), + {:ok, token} <- Gateways.use_token_by_id_and_secret(id, secret), + {:ok, gateway} <- Gateways.upsert_gateway(token, attrs) do + socket = + socket + |> assign(:gateway, gateway) + + {:ok, socket} + end + end + + def connect(_params, _socket, _connect_info) do + {:error, :invalid} + end + + defp fetch_config! do + Domain.Config.fetch_env!(:api, __MODULE__) end @impl true diff --git a/apps/api/lib/api/relay/channel.ex b/apps/api/lib/api/relay/channel.ex index 696811c02..f2cc2301c 100644 --- a/apps/api/lib/api/relay/channel.ex +++ b/apps/api/lib/api/relay/channel.ex @@ -1,20 +1,16 @@ defmodule API.Relay.Channel do use API, :channel - alias API.Relay.Presence + alias Domain.Relays @impl true - def join("relay", _payload, socket) do - send(self(), :after_join) + def join("relay", %{"stamp_secret" => stamp_secret}, socket) do + send(self(), {:after_join, stamp_secret}) {:ok, socket} end @impl true - def handle_info(:after_join, socket) do - {:ok, _} = - Presence.track(socket, socket.assigns.relay.id, %{ - online_at: System.system_time(:second) - }) - + def handle_info({:after_join, stamp_secret}, socket) do + :ok = Relays.connect_relay(socket.assigns.relay, stamp_secret, socket) {:noreply, socket} end end diff --git a/apps/api/lib/api/relay/socket.ex b/apps/api/lib/api/relay/socket.ex index 098cba578..8dcfbde3d 100644 --- a/apps/api/lib/api/relay/socket.ex +++ b/apps/api/lib/api/relay/socket.ex @@ -1,5 +1,6 @@ defmodule API.Relay.Socket do use Phoenix.Socket + alias Domain.Relays ## Channels @@ -7,9 +8,47 @@ defmodule API.Relay.Socket do ## Authentication + def encode_token!(%Relays.Token{value: value} = token) when not is_nil(value) do + body = {token.id, token.value} + config = fetch_config!() + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + Plug.Crypto.sign(key_base, salt, body) + end + @impl true - def connect(_params, socket, _connect_info) do - {:ok, socket} + def connect(%{"token" => encrypted_secret} = attrs, socket, connect_info) do + %{user_agent: user_agent, peer_data: %{address: remote_ip}} = connect_info + + config = fetch_config!() + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + max_age = Keyword.fetch!(config, :max_age) + + attrs = + attrs + |> Map.take(~w[ipv4 ipv6]) + |> Map.put("last_seen_user_agent", user_agent) + |> Map.put("last_seen_remote_ip", remote_ip) + + with {:ok, {id, secret}} <- + Plug.Crypto.verify(key_base, salt, encrypted_secret, max_age: max_age), + {:ok, token} <- Relays.use_token_by_id_and_secret(id, secret), + {:ok, relay} <- Relays.upsert_relay(token, attrs) do + socket = + socket + |> assign(:relay, relay) + + {:ok, socket} + end + end + + def connect(_params, _socket, _connect_info) do + {:error, :invalid} + end + + defp fetch_config! do + Domain.Config.fetch_env!(:api, __MODULE__) end @impl true diff --git a/apps/api/lib/api/sockets.ex b/apps/api/lib/api/sockets.ex index bc6c3852e..656832b5d 100644 --- a/apps/api/lib/api/sockets.ex +++ b/apps/api/lib/api/sockets.ex @@ -16,28 +16,11 @@ defmodule API.Sockets do ] end + @spec handle_error(Plug.Conn.t(), :invalid | :rate_limit | :unauthenticated) :: Plug.Conn.t() def handle_error(conn, :unauthenticated), do: Plug.Conn.send_resp(conn, 403, "Forbidden") def handle_error(conn, :invalid), do: Plug.Conn.send_resp(conn, 422, "Unprocessable Entity") def handle_error(conn, :rate_limit), do: Plug.Conn.send_resp(conn, 429, "Too many requests") - defp parse_ip(connect_info) do - case get_ip_address(connect_info) do - ip when ip in ["", nil] -> - :x_forward_for_header_issue - - ip when is_tuple(ip) -> - :inet.ntoa(ip) |> List.to_string() - end - end - - defp get_ip_address(%{peer_data: %{address: address}, x_headers: []}) do - address - end - - defp get_ip_address(%{x_headers: x_headers}) do - RemoteIp.from(x_headers, HeaderHelpers.remote_ip_opts()) - end - # if Mix.env() == :test do # defp maybe_allow_sandbox_access(%{user_agent: user_agent}) do # %{owner: owner_pid, repo: repos} = diff --git a/apps/api/mix.exs b/apps/api/mix.exs index 873b9ef61..2222bcb01 100644 --- a/apps/api/mix.exs +++ b/apps/api/mix.exs @@ -55,7 +55,8 @@ defmodule API.MixProject do {:telemetry_poller, "~> 1.0"}, # Other deps - {:jason, "~> 1.2"} + {:jason, "~> 1.2"}, + {:remote_ip, "~> 1.1"} # Test deps ] diff --git a/apps/api/test/api/client/channel_test.exs b/apps/api/test/api/client/channel_test.exs index 0194635cb..723ad00c4 100644 --- a/apps/api/test/api/client/channel_test.exs +++ b/apps/api/test/api/client/channel_test.exs @@ -1,8 +1,9 @@ defmodule API.Client.ChannelTest do use API.ChannelCase + alias Domain.ClientsFixtures setup do - client = %{id: Ecto.UUID.generate()} + client = ClientsFixtures.create_client() {:ok, _reply, socket} = API.Client.Socket @@ -13,24 +14,13 @@ defmodule API.Client.ChannelTest do end test "tracks presence after join", %{client: client, socket: socket} do - presence = API.Client.Presence.list(socket) + presence = Domain.Clients.Presence.list(socket) assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, client.id) assert is_number(online_at) end - # test "ping replies with status ok", %{socket: socket} do - # ref = push(socket, "ping", %{"hello" => "there"}) - # assert_reply ref, :ok, %{"hello" => "there"} - # end - - # test "shout broadcasts to client:lobby", %{socket: socket} do - # push(socket, "shout", %{"hello" => "all"}) - # assert_broadcast "shout", %{"hello" => "all"} - # end - - # test "broadcasts are pushed to the client", %{socket: socket} do - # broadcast_from!(socket, "broadcast", %{"some" => "data"}) - # assert_push "broadcast", %{"some" => "data"} - # end + test "sends list of resources after join" do + assert_push "resources", %{resources: []} + end end diff --git a/apps/api/test/api/client/socket_test.exs b/apps/api/test/api/client/socket_test.exs index 7159e77a1..0e68cc3b3 100644 --- a/apps/api/test/api/client/socket_test.exs +++ b/apps/api/test/api/client/socket_test.exs @@ -1,16 +1,52 @@ defmodule API.Client.SocketTest do use API.ChannelCase, async: true - import API.Client.Socket + import API.Client.Socket, only: [id: 1] + alias API.Client.Socket + alias Domain.Auth + alias Domain.{SubjectFixtures, ClientsFixtures} + + @connect_info %{ + user_agent: "iOS/12.7 (iPhone) connlib/0.1.1", + peer_data: %{address: {189, 172, 73, 153}} + } describe "connect/3" do - setup do - socket = socket(API.Client.Socket, "", %{}) - %{socket: socket} + test "returns error when token is missing" do + assert connect(Socket, %{}, @connect_info) == {:error, :invalid} end - test "returns error when token is missing", %{socket: socket} do - connect_info = %{user_agent: "Elixir", peer_data: %{ip: {127, 0, 0, 1}}} - assert connect(%{}, socket, connect_info) == {:error, :invalid} + test "returns error when token is invalid" do + attrs = connect_attrs(token: "foo") + assert connect(Socket, attrs, @connect_info) == {:error, :invalid} + end + + test "creates a new client" do + subject = SubjectFixtures.create_subject() + token = Auth.create_auth_token(subject) + + attrs = connect_attrs(token: token) + + assert {:ok, socket} = connect(Socket, attrs, @connect_info) + assert client = Map.fetch!(socket.assigns, :client) + + assert client.external_id == attrs["external_id"] + assert client.public_key == attrs["public_key"] + assert client.preshared_key == attrs["preshared_key"] + assert client.last_seen_user_agent == @connect_info.user_agent + assert client.last_seen_remote_ip.address == @connect_info.peer_data.address + assert client.last_seen_version == "0.1.1" + end + + test "updates existing client" do + subject = SubjectFixtures.create_subject() + existing_client = ClientsFixtures.create_client(subject: subject) + token = Auth.create_auth_token(subject) + + attrs = connect_attrs(token: token, external_id: existing_client.external_id) + + assert {:ok, socket} = connect(Socket, attrs, @connect_info) + assert client = Repo.one(Domain.Clients.Client) + assert client.id == socket.assigns.client.id end end @@ -22,4 +58,11 @@ defmodule API.Client.SocketTest do assert id(socket) == "client:#{client.id}" end end + + defp connect_attrs(attrs) do + ClientsFixtures.client_attrs() + |> Map.take(~w[external_id public_key preshared_key]a) + |> Map.merge(Enum.into(attrs, %{})) + |> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end) + end end diff --git a/apps/api/test/api/gateway/channel_test.exs b/apps/api/test/api/gateway/channel_test.exs index d0cfd763a..cd9731093 100644 --- a/apps/api/test/api/gateway/channel_test.exs +++ b/apps/api/test/api/gateway/channel_test.exs @@ -1,29 +1,22 @@ defmodule API.Gateway.ChannelTest do use API.ChannelCase + alias Domain.GatewaysFixtures setup do - gateway = %{id: Ecto.UUID.generate()} + gateway = GatewaysFixtures.create_gateway() {:ok, _, socket} = API.Gateway.Socket |> socket("gateway:#{gateway.id}", %{gateway: gateway}) |> subscribe_and_join(API.Gateway.Channel, "gateway") - %{socket: socket} + %{gateway: gateway, socket: socket} end - # test "ping replies with status ok", %{socket: socket} do - # ref = push(socket, "ping", %{"hello" => "there"}) - # assert_reply ref, :ok, %{"hello" => "there"} - # end + test "tracks presence after join", %{gateway: gateway, socket: socket} do + presence = Domain.Gateways.Presence.list(socket) - # test "shout broadcasts to client:lobby", %{socket: socket} do - # push(socket, "shout", %{"hello" => "all"}) - # assert_broadcast "shout", %{"hello" => "all"} - # end - - # test "broadcasts are pushed to the client", %{socket: socket} do - # broadcast_from!(socket, "broadcast", %{"some" => "data"}) - # assert_push "broadcast", %{"some" => "data"} - # end + assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, gateway.id) + assert is_number(online_at) + end end diff --git a/apps/api/test/api/gateway/socket_test.exs b/apps/api/test/api/gateway/socket_test.exs new file mode 100644 index 000000000..71950ebd0 --- /dev/null +++ b/apps/api/test/api/gateway/socket_test.exs @@ -0,0 +1,82 @@ +defmodule API.Gateway.SocketTest do + use API.ChannelCase, async: true + import API.Gateway.Socket, except: [connect: 3] + alias API.Gateway.Socket + alias Domain.GatewaysFixtures + + @connlib_version "0.1.1" + + @connect_info %{ + user_agent: "iOS/12.7 (iPhone) connlib/#{@connlib_version}", + peer_data: %{address: {189, 172, 73, 153}} + } + + describe "encode_token!/1" do + test "returns encoded token" do + token = GatewaysFixtures.create_token() + assert encrypted_secret = encode_token!(token) + + config = Application.fetch_env!(:api, Socket) + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + + assert Plug.Crypto.verify(key_base, salt, encrypted_secret) == + {:ok, {token.id, token.value}} + end + end + + describe "connect/3" do + test "returns error when token is missing" do + assert connect(Socket, %{}, @connect_info) == {:error, :invalid} + end + + test "creates a new gateway" do + token = GatewaysFixtures.create_token() + encrypted_secret = encode_token!(token) + + attrs = connect_attrs(token: encrypted_secret) + + assert {:ok, socket} = connect(Socket, attrs, @connect_info) + assert gateway = Map.fetch!(socket.assigns, :gateway) + + assert gateway.external_id == attrs["external_id"] + assert gateway.public_key == attrs["public_key"] + assert gateway.last_seen_user_agent == @connect_info.user_agent + assert gateway.last_seen_remote_ip.address == @connect_info.peer_data.address + assert gateway.last_seen_version == @connlib_version + end + + test "updates existing gateway" do + token = GatewaysFixtures.create_token() + existing_gateway = GatewaysFixtures.create_gateway(token: token) + encrypted_secret = encode_token!(token) + + attrs = connect_attrs(token: encrypted_secret, external_id: existing_gateway.external_id) + + assert {:ok, socket} = connect(Socket, attrs, @connect_info) + assert gateway = Repo.one(Domain.Gateways.Gateway) + assert gateway.id == socket.assigns.gateway.id + end + + test "returns error when token is invalid" do + attrs = connect_attrs(token: "foo") + assert connect(Socket, attrs, @connect_info) == {:error, :invalid} + end + end + + describe "id/1" do + test "creates a channel for a gateway" do + gateway = %{id: Ecto.UUID.generate()} + socket = socket(API.Gateway.Socket, "", %{gateway: gateway}) + + assert id(socket) == "gateway:#{gateway.id}" + end + end + + defp connect_attrs(attrs) do + GatewaysFixtures.gateway_attrs() + |> Map.take(~w[external_id public_key preshared_key]a) + |> Map.merge(Enum.into(attrs, %{})) + |> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end) + end +end diff --git a/apps/api/test/api/relay/channel_test.exs b/apps/api/test/api/relay/channel_test.exs index e2d8a22ce..b0d4b0f77 100644 --- a/apps/api/test/api/relay/channel_test.exs +++ b/apps/api/test/api/relay/channel_test.exs @@ -1,29 +1,24 @@ defmodule API.Relay.ChannelTest do use API.ChannelCase + alias Domain.RelaysFixtures setup do - relay = %{id: Ecto.UUID.generate()} + relay = RelaysFixtures.create_relay() + + stamp_secret = Domain.Crypto.rand_string() {:ok, _, socket} = API.Relay.Socket |> socket("relay:#{relay.id}", %{relay: relay}) - |> subscribe_and_join(API.Relay.Channel, "relay") + |> subscribe_and_join(API.Relay.Channel, "relay", %{stamp_secret: stamp_secret}) - %{socket: socket} + %{relay: relay, socket: socket} end - # test "ping replies with status ok", %{socket: socket} do - # ref = push(socket, "ping", %{"hello" => "there"}) - # assert_reply ref, :ok, %{"hello" => "there"} - # end + test "tracks presence after join", %{relay: relay, socket: socket} do + presence = Domain.Relays.Presence.list(socket) - # test "shout broadcasts to client:lobby", %{socket: socket} do - # push(socket, "shout", %{"hello" => "all"}) - # assert_broadcast "shout", %{"hello" => "all"} - # end - - # test "broadcasts are pushed to the client", %{socket: socket} do - # broadcast_from!(socket, "broadcast", %{"some" => "data"}) - # assert_push "broadcast", %{"some" => "data"} - # end + assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, relay.id) + assert is_number(online_at) + end end diff --git a/apps/api/test/api/relay/socket_test.exs b/apps/api/test/api/relay/socket_test.exs new file mode 100644 index 000000000..dad565eb5 --- /dev/null +++ b/apps/api/test/api/relay/socket_test.exs @@ -0,0 +1,82 @@ +defmodule API.Relay.SocketTest do + use API.ChannelCase, async: true + import API.Relay.Socket, except: [connect: 3] + alias API.Relay.Socket + alias Domain.RelaysFixtures + + @connlib_version "0.1.1" + + @connect_info %{ + user_agent: "iOS/12.7 (iPhone) connlib/#{@connlib_version}", + peer_data: %{address: {189, 172, 73, 153}} + } + + describe "encode_token!/1" do + test "returns encoded token" do + token = RelaysFixtures.create_token() + assert encrypted_secret = encode_token!(token) + + config = Application.fetch_env!(:api, Socket) + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + + assert Plug.Crypto.verify(key_base, salt, encrypted_secret) == + {:ok, {token.id, token.value}} + end + end + + describe "connect/3" do + test "returns error when token is missing" do + assert connect(Socket, %{}, @connect_info) == {:error, :invalid} + end + + test "creates a new relay" do + token = RelaysFixtures.create_token() + encrypted_secret = encode_token!(token) + + attrs = connect_attrs(token: encrypted_secret) + + assert {:ok, socket} = connect(Socket, attrs, @connect_info) + assert relay = Map.fetch!(socket.assigns, :relay) + + assert relay.ipv4.address == attrs["ipv4"] + assert relay.ipv6.address == attrs["ipv6"] + assert relay.last_seen_user_agent == @connect_info.user_agent + assert relay.last_seen_remote_ip.address == @connect_info.peer_data.address + assert relay.last_seen_version == @connlib_version + end + + test "updates existing relay" do + token = RelaysFixtures.create_token() + existing_relay = RelaysFixtures.create_relay(token: token) + encrypted_secret = encode_token!(token) + + attrs = connect_attrs(token: encrypted_secret, ipv4: existing_relay.ipv4) + + assert {:ok, socket} = connect(Socket, attrs, @connect_info) + assert relay = Repo.one(Domain.Relays.Relay) + assert relay.id == socket.assigns.relay.id + end + + test "returns error when token is invalid" do + attrs = connect_attrs(token: "foo") + assert connect(Socket, attrs, @connect_info) == {:error, :invalid} + end + end + + describe "id/1" do + test "creates a channel for a relay" do + relay = %{id: Ecto.UUID.generate()} + socket = socket(API.Relay.Socket, "", %{relay: relay}) + + assert id(socket) == "relay:#{relay.id}" + end + end + + defp connect_attrs(attrs) do + RelaysFixtures.relay_attrs() + |> Map.take(~w[ipv4 ipv6]a) + |> Map.merge(Enum.into(attrs, %{})) + |> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end) + end +end diff --git a/apps/api/test/support/channel_case.ex b/apps/api/test/support/channel_case.ex index 9e979e351..40f760830 100644 --- a/apps/api/test/support/channel_case.ex +++ b/apps/api/test/support/channel_case.ex @@ -3,9 +3,9 @@ defmodule API.ChannelCase do use Domain.CaseTemplate @presences [ - API.Client.Presence, - API.Gateway.Presence, - API.Relay.Presence + Domain.Clients.Presence, + Domain.Gateways.Presence, + Domain.Relays.Presence ] using do @@ -13,6 +13,7 @@ defmodule API.ChannelCase do # Import conveniences for testing with channels import Phoenix.ChannelTest import API.ChannelCase + alias Domain.Repo # The default endpoint for testing @endpoint API.Endpoint diff --git a/apps/domain/lib/domain/accounts.ex b/apps/domain/lib/domain/accounts.ex new file mode 100644 index 000000000..a1f28b3e9 --- /dev/null +++ b/apps/domain/lib/domain/accounts.ex @@ -0,0 +1,9 @@ +defmodule Domain.Accounts do + alias Domain.Repo + alias Domain.Accounts.Account + + def create_account(attrs) do + Account.Changeset.create_changeset(attrs) + |> Repo.insert() + end +end diff --git a/apps/domain/lib/domain/accounts/account.ex b/apps/domain/lib/domain/accounts/account.ex new file mode 100644 index 000000000..416b23011 --- /dev/null +++ b/apps/domain/lib/domain/accounts/account.ex @@ -0,0 +1,9 @@ +defmodule Domain.Accounts.Account do + use Domain, :schema + + schema "accounts" do + field :name, :string + + timestamps() + end +end diff --git a/apps/domain/lib/domain/accounts/account/changeset.ex b/apps/domain/lib/domain/accounts/account/changeset.ex new file mode 100644 index 000000000..ad8dc552d --- /dev/null +++ b/apps/domain/lib/domain/accounts/account/changeset.ex @@ -0,0 +1,10 @@ +defmodule Domain.Accounts.Account.Changeset do + use Domain, :changeset + alias Domain.Accounts.Account + + def create_changeset(attrs) do + %Account{} + |> cast(attrs, [:name]) + |> validate_required([:name]) + end +end diff --git a/apps/domain/lib/domain/application.ex b/apps/domain/lib/domain/application.ex index 26be7eca1..b51314fcf 100644 --- a/apps/domain/lib/domain/application.ex +++ b/apps/domain/lib/domain/application.ex @@ -19,7 +19,10 @@ defmodule Domain.Application do # Application {Domain.Notifications, name: Domain.Notifications}, - Domain.Auth, + # Domain.Auth, + Domain.Relays, + Domain.Gateways, + Domain.Clients, # Observability Domain.ConnectivityChecks, diff --git a/apps/domain/lib/domain/auth.ex b/apps/domain/lib/domain/auth.ex index 34c19e2f4..b09582598 100644 --- a/apps/domain/lib/domain/auth.ex +++ b/apps/domain/lib/domain/auth.ex @@ -57,27 +57,74 @@ defmodule Domain.Auth do end def fetch_subject!(%Users.User{} = user, remote_ip, user_agent) do + user = Repo.preload(user, :account) role = fetch_user_role!(user) %Subject{ actor: {:user, user}, permissions: role.permissions, + account: user.account, context: %Context{remote_ip: remote_ip, user_agent: user_agent} } end def fetch_subject!(%ApiTokens.ApiToken{} = api_token, remote_ip, user_agent) do - api_token = Repo.preload(api_token, :user) + api_token = Repo.preload(api_token, user: [:account]) role = fetch_user_role!(api_token.user) # XXX: Once we build audit logging here we need to build a different kind of subject %Subject{ actor: {:user, api_token.user}, permissions: role.permissions, + account: api_token.user.account, context: %Context{remote_ip: remote_ip, user_agent: user_agent} } end + # def sign_in(:userpass, login, password): do, {:ok, session_token} + # def sign_in(:api_token, token) + # def sign_in(:user_token, token) + # def sign_in(:oidc, provider, token) + # def sign_in(:saml, provider, token) + + # TODO: for some tokens we want to save remote_ip and invalidate them when the IP changes + def create_auth_token(%Subject{} = subject) do + config = fetch_config!() + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + Plug.Crypto.sign(key_base, salt, token_body(subject)) + end + + defp token_body(%Subject{actor: {:user, user}}), do: {:user, user.id} + + def consume_auth_token(token, remote_ip, user_agent) do + config = fetch_config!() + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + max_age = Keyword.fetch!(config, :max_age) + + case Plug.Crypto.verify(key_base, salt, token, max_age: max_age) do + {:ok, {:user, user_id}} -> + # TODO: we might want to check that user is active in future + user = Users.fetch_user_by_id!(user_id) + role = fetch_user_role!(user) + + {:ok, + %Subject{ + actor: {:user, user}, + permissions: role.permissions, + context: %Context{remote_ip: remote_ip, user_agent: user_agent} + }} + + {:error, reason} -> + {:error, reason} + end + end + + defp fetch_config! do + Config.fetch_env!(:domain, __MODULE__) + end + def fetch_oidc_provider_config(provider_id) do with {:ok, provider} <- fetch_provider(:openid_connect_providers, provider_id) do redirect_uri = diff --git a/apps/domain/lib/domain/auth/roles.ex b/apps/domain/lib/domain/auth/roles.ex index 7beccee8b..8cff319db 100644 --- a/apps/domain/lib/domain/auth/roles.ex +++ b/apps/domain/lib/domain/auth/roles.ex @@ -14,6 +14,9 @@ defmodule Domain.Auth.Roles do Domain.ApiTokens.Authorizer, Domain.ConnectivityChecks.Authorizer, Domain.Devices.Authorizer, + Domain.Clients.Authorizer, + Domain.Gateways.Authorizer, + Domain.Relays.Authorizer, Domain.Rules.Authorizer, Domain.Users.Authorizer ] diff --git a/apps/domain/lib/domain/auth/subject.ex b/apps/domain/lib/domain/auth/subject.ex index 886d4f57d..1a7a79423 100644 --- a/apps/domain/lib/domain/auth/subject.ex +++ b/apps/domain/lib/domain/auth/subject.ex @@ -11,11 +11,13 @@ defmodule Domain.Auth.Subject do @type t :: %__MODULE__{ actor: actor(), permissions: MapSet.t(permission), + account: %Domain.Accounts.Account{}, context: Context.t() } defstruct actor: nil, permissions: MapSet.new(), + account: nil, context: %Context{} def actor_type(%__MODULE__{actor: {actor_type, _}}), do: actor_type diff --git a/apps/domain/lib/domain/clients.ex b/apps/domain/lib/domain/clients.ex new file mode 100644 index 000000000..487fcbb1a --- /dev/null +++ b/apps/domain/lib/domain/clients.ex @@ -0,0 +1,173 @@ +defmodule Domain.Clients do + use Supervisor + alias Domain.{Repo, Auth, Validator} + alias Domain.{Users} + alias Domain.Clients.{Client, Authorizer, Presence} + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(_opts) do + children = [ + Presence + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def count_by_account_id(account_id) do + Client.Query.by_account_id(account_id) + |> Repo.aggregate(:count) + end + + def count_by_user_id(user_id) do + Client.Query.by_user_id(user_id) + |> Repo.aggregate(:count) + end + + def fetch_client_by_id(id, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, + [ + Authorizer.manage_clients_permission(), + Authorizer.manage_own_clients_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions), + true <- Validator.valid_uuid?(id) do + Client.Query.by_id(id) + |> Authorizer.for_subject(subject) + |> Repo.fetch() + else + false -> {:error, :not_found} + other -> other + end + end + + def fetch_client_by_id!(id, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + + Client.Query.by_id(id) + |> Repo.one!() + |> Repo.preload(preload) + end + + def list_clients(%Auth.Subject{} = subject) do + required_permissions = + {:one_of, + [ + Authorizer.manage_clients_permission(), + Authorizer.manage_own_clients_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do + Client.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + end + end + + def list_clients_for_user(%Users.User{} = user, %Auth.Subject{} = subject) do + list_clients_by_user_id(user.id, subject) + end + + def list_clients_by_user_id(user_id, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, + [ + Authorizer.manage_clients_permission(), + Authorizer.manage_own_clients_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions), + true <- Validator.valid_uuid?(user_id) do + Client.Query.by_user_id(user_id) + |> Authorizer.for_subject(subject) + |> Repo.list() + else + false -> {:error, :not_found} + other -> other + end + end + + def change_client(%Client{} = client, attrs \\ %{}) do + Client.Changeset.update_changeset(client, attrs) + end + + def upsert_client(attrs \\ %{}, %Auth.Subject{actor: {:user, %Users.User{} = user}} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_clients_permission()) do + changeset = Client.Changeset.upsert_changeset(user, subject.context, attrs) + + Ecto.Multi.new() + |> Ecto.Multi.insert(:client, changeset, + conflict_target: Client.Changeset.upsert_conflict_target(), + on_conflict: Client.Changeset.upsert_on_conflict(), + returning: true + ) + |> resolve_address_multi(:ipv4) + |> resolve_address_multi(:ipv6) + |> Ecto.Multi.update(:client_with_address, fn + %{client: %Client{} = client, ipv4: ipv4, ipv6: ipv6} -> + Client.Changeset.finalize_upsert_changeset(client, ipv4, ipv6) + end) + |> Repo.transaction() + |> case do + {:ok, %{client_with_address: client}} -> {:ok, client} + {:error, :client, changeset, _effects_so_far} -> {:error, changeset} + end + end + end + + defp resolve_address_multi(multi, type) do + Ecto.Multi.run(multi, type, fn _repo, %{client: %Client{} = client} -> + if address = Map.get(client, type) do + {:ok, address} + else + {:ok, Domain.Network.fetch_next_available_address!(client.account_id, type)} + end + end) + end + + def update_client(%Client{} = client, attrs, %Auth.Subject{} = subject) do + with :ok <- authorize_user_client_management(client.user_id, subject) do + Client.Query.by_id(client.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update(with: &Client.Changeset.update_changeset(&1, attrs)) + end + end + + def delete_client(%Client{} = client, %Auth.Subject{} = subject) do + with :ok <- authorize_user_client_management(client.user_id, subject) do + Client.Query.by_id(client.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update(with: &Client.Changeset.delete_changeset/1) + end + end + + def authorize_user_client_management(%Users.User{} = user, %Auth.Subject{} = subject) do + authorize_user_client_management(user.id, subject) + end + + def authorize_user_client_management(user_id, %Auth.Subject{} = subject) do + required_permissions = + case subject.actor do + {:user, %{id: ^user_id}} -> + Authorizer.manage_own_clients_permission() + + _other -> + Authorizer.manage_clients_permission() + end + + Auth.ensure_has_permissions(subject, required_permissions) + end + + def connect_client(%Client{} = client, socket) do + {:ok, _} = + Presence.track(socket, client.id, %{ + online_at: System.system_time(:second) + }) + + :ok + end +end diff --git a/apps/domain/lib/domain/clients/authorizer.ex b/apps/domain/lib/domain/clients/authorizer.ex new file mode 100644 index 000000000..b956b3996 --- /dev/null +++ b/apps/domain/lib/domain/clients/authorizer.ex @@ -0,0 +1,41 @@ +defmodule Domain.Clients.Authorizer do + use Domain.Auth.Authorizer + alias Domain.Clients.Client + + def manage_own_clients_permission, do: build(Client, :manage_own) + def manage_clients_permission, do: build(Client, :manage) + + @impl Domain.Auth.Authorizer + + def list_permissions_for_role(:admin) do + [ + manage_own_clients_permission(), + manage_clients_permission() + ] + end + + def list_permissions_for_role(:unprivileged) do + [ + manage_own_clients_permission() + ] + end + + def list_permissions_for_role(_) do + [] + end + + @impl Domain.Auth.Authorizer + def for_subject(queryable, %Subject{} = subject) when is_user(subject) do + cond do + has_permission?(subject, manage_clients_permission()) -> + Client.Query.by_account_id(queryable, subject.account.id) + + has_permission?(subject, manage_own_clients_permission()) -> + {:user, %{id: user_id}} = subject.actor + + queryable + |> Client.Query.by_account_id(subject.account.id) + |> Client.Query.by_user_id(user_id) + end + end +end diff --git a/apps/domain/lib/domain/clients/client.ex b/apps/domain/lib/domain/clients/client.ex new file mode 100644 index 000000000..8e31f49e0 --- /dev/null +++ b/apps/domain/lib/domain/clients/client.ex @@ -0,0 +1,26 @@ +defmodule Domain.Clients.Client do + use Domain, :schema + + schema "clients" do + field :external_id, :string + + field :name, :string + + field :public_key, :string + field :preshared_key, Domain.Encrypted.Binary + + field :ipv4, Domain.Types.IP + field :ipv6, Domain.Types.IP + + field :last_seen_user_agent, :string + field :last_seen_remote_ip, Domain.Types.IP + field :last_seen_version, :string + field :last_seen_at, :utc_datetime_usec + + belongs_to :account, Domain.Accounts.Account + belongs_to :user, Domain.Users.User + + field :deleted_at, :utc_datetime_usec + timestamps() + end +end diff --git a/apps/domain/lib/domain/clients/client/changeset.ex b/apps/domain/lib/domain/clients/client/changeset.ex new file mode 100644 index 000000000..8f1ed2406 --- /dev/null +++ b/apps/domain/lib/domain/clients/client/changeset.ex @@ -0,0 +1,97 @@ +defmodule Domain.Clients.Client.Changeset do + use Domain, :changeset + alias Domain.{Version, Auth, Users} + alias Domain.Clients + + @upsert_fields ~w[external_id name public_key preshared_key]a + @conflict_replace_fields ~w[public_key preshared_key + last_seen_user_agent last_seen_remote_ip + last_seen_version last_seen_at]a + @update_fields ~w[name]a + @required_fields @upsert_fields + + # WireGuard base64-encoded string length + @key_length 44 + + def upsert_conflict_target, + do: {:unsafe_fragment, ~s/(account_id, user_id, external_id) WHERE deleted_at IS NULL/} + + def upsert_on_conflict, do: {:replace, @conflict_replace_fields} + + def upsert_changeset(%Users.User{} = user, %Auth.Context{} = context, attrs) do + %Clients.Client{} + |> cast(attrs, @upsert_fields) + |> put_default_value(:name, &generate_name/0) + |> put_change(:user_id, user.id) + |> put_change(:account_id, user.account_id) + |> put_change(:last_seen_user_agent, context.user_agent) + |> put_change(:last_seen_remote_ip, %Postgrex.INET{address: context.remote_ip}) + |> changeset() + |> validate_required(@required_fields) + |> validate_base64(:public_key) + |> validate_base64(:preshared_key) + |> validate_length(:public_key, is: @key_length) + |> validate_length(:preshared_key, is: @key_length) + |> unique_constraint(:ipv4) + |> unique_constraint(:ipv6) + |> put_change(:last_seen_at, DateTime.utc_now()) + |> put_client_version() + end + + def finalize_upsert_changeset(%Clients.Client{} = client, ipv4, ipv6) do + client + |> change() + |> put_change(:ipv4, ipv4) + |> put_change(:ipv6, ipv6) + end + + def update_changeset(%Clients.Client{} = client, attrs) do + client + |> cast(attrs, @update_fields) + |> changeset() + |> validate_required(@required_fields) + end + + def delete_changeset(%Clients.Client{} = client) do + client + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + end + + defp changeset(changeset) do + changeset + |> trim_change(:name) + |> validate_length(:name, min: 1, max: 255) + |> assoc_constraint(:user) + |> unique_constraint([:user_id, :name]) + |> unique_constraint([:user_id, :public_key]) + |> unique_constraint(:external_id) + end + + defp put_client_version(changeset) do + with {_data_or_changes, user_agent} when not is_nil(user_agent) <- + fetch_field(changeset, :last_seen_user_agent), + {:ok, version} <- Version.fetch_version(user_agent) do + put_change(changeset, :last_seen_version, version) + else + {:error, :invalid_user_agent} -> add_error(changeset, :last_seen_user_agent, "is invalid") + _ -> changeset + end + end + + defp generate_name do + name = Domain.NameGenerator.generate() + + hash = + name + |> :erlang.phash2(2 ** 16) + |> Integer.to_string(16) + |> String.pad_leading(4, "0") + + if String.length(name) > 15 do + String.slice(name, 0..10) <> hash + else + name + end + end +end diff --git a/apps/domain/lib/domain/clients/client/query.ex b/apps/domain/lib/domain/clients/client/query.ex new file mode 100644 index 000000000..b9f1bd7a3 --- /dev/null +++ b/apps/domain/lib/domain/clients/client/query.ex @@ -0,0 +1,32 @@ +defmodule Domain.Clients.Client.Query do + use Domain, :query + + def all do + from(clients in Domain.Clients.Client, as: :clients) + |> where([clients: clients], is_nil(clients.deleted_at)) + end + + def by_id(queryable \\ all(), id) do + where(queryable, [clients: clients], clients.id == ^id) + end + + def by_user_id(queryable \\ all(), user_id) do + where(queryable, [clients: clients], clients.user_id == ^user_id) + end + + def by_account_id(queryable \\ all(), account_id) do + where(queryable, [clients: clients], clients.account_id == ^account_id) + end + + def returning_all(queryable \\ all()) do + select(queryable, [clients: clients], clients) + end + + def with_preloaded_user(queryable \\ all()) do + with_named_binding(queryable, :user, fn queryable, binding -> + queryable + |> join(:inner, [clients: clients], user in assoc(clients, ^binding), as: ^binding) + |> preload([clients: clients, user: user], user: user) + end) + end +end diff --git a/apps/api/lib/api/gateway/presence.ex b/apps/domain/lib/domain/clients/presence.ex similarity index 50% rename from apps/api/lib/api/gateway/presence.ex rename to apps/domain/lib/domain/clients/presence.ex index 2752f38ce..b0bf4f34d 100644 --- a/apps/api/lib/api/gateway/presence.ex +++ b/apps/domain/lib/domain/clients/presence.ex @@ -1,5 +1,5 @@ -defmodule API.Gateway.Presence do +defmodule Domain.Clients.Presence do use Phoenix.Presence, - otp_app: :api, + otp_app: :domain, pubsub_server: Domain.PubSub end diff --git a/apps/domain/lib/domain/config/configuration/saml_identity_provider.ex b/apps/domain/lib/domain/config/configuration/saml_identity_provider.ex index 87579bbef..4fab475c2 100644 --- a/apps/domain/lib/domain/config/configuration/saml_identity_provider.ex +++ b/apps/domain/lib/domain/config/configuration/saml_identity_provider.ex @@ -58,7 +58,7 @@ defmodule Domain.Config.Configuration.SAMLIdentityProvider do changeset |> validate_change(:metadata, fn :metadata, value -> try do - Samly.IdpData.from_xml(value, %Samly.IdpData{}) + # Samly.IdpData.from_xml(value, %Samly.IdpData{}) [] catch :exit, e -> diff --git a/apps/domain/lib/domain/devices/device.ex b/apps/domain/lib/domain/devices/device.ex index 147c20587..a3aa2d28a 100644 --- a/apps/domain/lib/domain/devices/device.ex +++ b/apps/domain/lib/domain/devices/device.ex @@ -2,33 +2,33 @@ defmodule Domain.Devices.Device do use Domain, :schema schema "devices" do - field(:name, :string) - field(:description, :string) + field :name, :string + field :description, :string - field(:public_key, :string) - field(:preshared_key, Domain.Encrypted.Binary) + field :public_key, :string + field :preshared_key, Domain.Encrypted.Binary - field(:use_default_allowed_ips, :boolean, read_after_writes: true, default: true) - field(:use_default_dns, :boolean, read_after_writes: true, default: true) - field(:use_default_endpoint, :boolean, read_after_writes: true, default: true) - field(:use_default_mtu, :boolean, read_after_writes: true, default: true) - field(:use_default_persistent_keepalive, :boolean, read_after_writes: true, default: true) + field :use_default_allowed_ips, :boolean, read_after_writes: true, default: true + field :use_default_dns, :boolean, read_after_writes: true, default: true + field :use_default_endpoint, :boolean, read_after_writes: true, default: true + field :use_default_mtu, :boolean, read_after_writes: true, default: true + field :use_default_persistent_keepalive, :boolean, read_after_writes: true, default: true - field(:endpoint, :string) - field(:mtu, :integer) - field(:persistent_keepalive, :integer) - field(:allowed_ips, {:array, Domain.Types.INET}, default: []) - field(:dns, {:array, :string}, default: []) + field :endpoint, :string + field :mtu, :integer + field :persistent_keepalive, :integer + field :allowed_ips, {:array, Domain.Types.INET}, default: [] + field :dns, {:array, :string}, default: [] - field(:ipv4, Domain.Types.IP) - field(:ipv6, Domain.Types.IP) + field :ipv4, Domain.Types.IP + field :ipv6, Domain.Types.IP - field(:remote_ip, Domain.Types.IP) - field(:rx_bytes, :integer) - field(:tx_bytes, :integer) - field(:latest_handshake, :utc_datetime_usec) + field :remote_ip, Domain.Types.IP + field :rx_bytes, :integer + field :tx_bytes, :integer + field :latest_handshake, :utc_datetime_usec - belongs_to(:user, Domain.Users.User) + belongs_to :user, Domain.Users.User timestamps() end diff --git a/apps/domain/lib/domain/gateways.ex b/apps/domain/lib/domain/gateways.ex new file mode 100644 index 000000000..c0048bb34 --- /dev/null +++ b/apps/domain/lib/domain/gateways.ex @@ -0,0 +1,191 @@ +defmodule Domain.Gateways do + use Supervisor + alias Domain.{Repo, Auth, Validator} + alias Domain.Gateways.{Authorizer, Gateway, Group, Token, Presence} + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(_opts) do + children = [ + Presence + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def fetch_group_by_id(id, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()), + true <- Validator.valid_uuid?(id) do + Group.Query.by_id(id) + |> Authorizer.for_subject(subject) + |> Repo.fetch() + else + false -> {:error, :not_found} + other -> other + end + end + + def list_groups(%Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do + Group.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + end + end + + def new_group(attrs \\ %{}) do + change_group(%Group{}, attrs) + end + + def create_group(attrs, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do + subject.account + |> Group.Changeset.create_changeset(attrs) + |> Repo.insert() + end + end + + def change_group(%Group{} = group, attrs \\ %{}) do + group + |> Repo.preload(:account) + |> Group.Changeset.update_changeset(attrs) + end + + def update_group(%Group{} = group, attrs, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do + group + |> Repo.preload(:account) + |> Group.Changeset.update_changeset(attrs) + |> Repo.update() + end + end + + def delete_group(%Group{} = group, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do + Group.Query.by_id(group.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update( + with: fn group -> + :ok = + Token.Query.by_group_id(group.id) + |> Repo.all() + |> Enum.each(fn token -> + Token.Changeset.delete_changeset(token) + |> Repo.update!() + end) + + group + |> Group.Changeset.delete_changeset() + end + ) + end + end + + def use_token_by_id_and_secret(id, secret) do + if Validator.valid_uuid?(id) do + Token.Query.by_id(id) + |> Repo.fetch_and_update( + with: fn token -> + if Domain.Crypto.equal?(secret, token.hash) do + Token.Changeset.use_changeset(token) + else + :not_found + end + end + ) + else + {:error, :not_found} + end + end + + def fetch_gateway_by_id(id, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()), + true <- Validator.valid_uuid?(id) do + Gateway.Query.by_id(id) + |> Authorizer.for_subject(subject) + |> Repo.fetch() + else + false -> {:error, :not_found} + other -> other + end + end + + def fetch_gateway_by_id!(id, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + + Gateway.Query.by_id(id) + |> Repo.one!() + |> Repo.preload(preload) + end + + def list_gateways(%Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do + Gateway.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + end + end + + def change_gateway(%Gateway{} = gateway, attrs \\ %{}) do + Gateway.Changeset.update_changeset(gateway, attrs) + end + + def upsert_gateway(%Token{} = token, attrs) do + changeset = Gateway.Changeset.upsert_changeset(token, attrs) + + Ecto.Multi.new() + |> Ecto.Multi.insert(:gateway, changeset, + conflict_target: Gateway.Changeset.upsert_conflict_target(), + on_conflict: Gateway.Changeset.upsert_on_conflict(), + returning: true + ) + |> resolve_address_multi(:ipv4) + |> resolve_address_multi(:ipv6) + |> Ecto.Multi.update(:gateway_with_address, fn + %{gateway: %Gateway{} = gateway, ipv4: ipv4, ipv6: ipv6} -> + Gateway.Changeset.finalize_upsert_changeset(gateway, ipv4, ipv6) + end) + |> Repo.transaction() + |> case do + {:ok, %{gateway_with_address: gateway}} -> {:ok, gateway} + {:error, :gateway, changeset, _effects_so_far} -> {:error, changeset} + end + end + + defp resolve_address_multi(multi, type) do + Ecto.Multi.run(multi, type, fn _repo, %{gateway: %Gateway{} = gateway} -> + if address = Map.get(gateway, type) do + {:ok, address} + else + {:ok, Domain.Network.fetch_next_available_address!(gateway.account_id, type)} + end + end) + end + + def update_gateway(%Gateway{} = gateway, attrs, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do + Gateway.Query.by_id(gateway.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update(with: &Gateway.Changeset.update_changeset(&1, attrs)) + end + end + + def delete_gateway(%Gateway{} = gateway, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do + Gateway.Query.by_id(gateway.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update(with: &Gateway.Changeset.delete_changeset/1) + end + end + + def connect_gateway(%Gateway{} = gateway, socket) do + {:ok, _} = + Presence.track(socket, gateway.id, %{ + online_at: System.system_time(:second) + }) + + :ok + end +end diff --git a/apps/domain/lib/domain/gateways/authorizer.ex b/apps/domain/lib/domain/gateways/authorizer.ex new file mode 100644 index 000000000..66a925338 --- /dev/null +++ b/apps/domain/lib/domain/gateways/authorizer.ex @@ -0,0 +1,36 @@ +defmodule Domain.Gateways.Authorizer do + use Domain.Auth.Authorizer + alias Domain.Gateways.{Gateway, Group} + + def manage_gateways_permission, do: build(Gateway, :manage) + + @impl Domain.Auth.Authorizer + + def list_permissions_for_role(:admin) do + [ + manage_gateways_permission() + ] + end + + def list_permissions_for_role(_) do + [] + end + + @impl Domain.Auth.Authorizer + def for_subject(queryable, %Subject{} = subject) when is_user(subject) do + cond do + has_permission?(subject, manage_gateways_permission()) -> + by_account_id(queryable, subject) + end + end + + defp by_account_id(queryable, subject) do + cond do + Ecto.Query.has_named_binding?(queryable, :groups) -> + Group.Query.by_account_id(queryable, subject.account.id) + + Ecto.Query.has_named_binding?(queryable, :gateways) -> + Gateway.Query.by_account_id(queryable, subject.account.id) + end + end +end diff --git a/apps/domain/lib/domain/gateways/gateway.ex b/apps/domain/lib/domain/gateways/gateway.ex new file mode 100644 index 000000000..bece0860f --- /dev/null +++ b/apps/domain/lib/domain/gateways/gateway.ex @@ -0,0 +1,26 @@ +defmodule Domain.Gateways.Gateway do + use Domain, :schema + + schema "gateways" do + field :external_id, :string + + field :name_suffix, :string + + field :public_key, :string + + field :ipv4, Domain.Types.IP + field :ipv6, Domain.Types.IP + + field :last_seen_user_agent, :string + field :last_seen_remote_ip, Domain.Types.IP + field :last_seen_version, :string + field :last_seen_at, :utc_datetime_usec + + belongs_to :account, Domain.Accounts.Account + belongs_to :group, Domain.Gateways.Group + belongs_to :token, Domain.Gateways.Token + + field :deleted_at, :utc_datetime_usec + timestamps() + end +end diff --git a/apps/domain/lib/domain/gateways/gateway/changeset.ex b/apps/domain/lib/domain/gateways/gateway/changeset.ex new file mode 100644 index 000000000..2844185c1 --- /dev/null +++ b/apps/domain/lib/domain/gateways/gateway/changeset.ex @@ -0,0 +1,79 @@ +defmodule Domain.Gateways.Gateway.Changeset do + use Domain, :changeset + alias Domain.Version + alias Domain.Gateways + + @upsert_fields ~w[external_id name_suffix public_key + last_seen_user_agent last_seen_remote_ip]a + @conflict_replace_fields ~w[public_key + last_seen_user_agent last_seen_remote_ip + last_seen_version last_seen_at]a + @update_fields ~w[name_suffix]a + @required_fields @upsert_fields + + # WireGuard base64-encoded string length + @key_length 44 + + def upsert_conflict_target, + do: {:unsafe_fragment, ~s/(account_id, group_id, external_id) WHERE deleted_at IS NULL/} + + def upsert_on_conflict, do: {:replace, @conflict_replace_fields} + + def upsert_changeset(%Gateways.Token{} = token, attrs) do + %Gateways.Gateway{} + |> cast(attrs, @upsert_fields) + |> put_default_value(:name_suffix, fn -> Domain.Crypto.rand_string(5) end) + |> changeset() + |> validate_required(@required_fields) + |> validate_base64(:public_key) + |> validate_length(:public_key, is: @key_length) + |> unique_constraint(:ipv4) + |> unique_constraint(:ipv6) + |> put_change(:last_seen_at, DateTime.utc_now()) + |> put_gateway_version() + |> put_change(:account_id, token.account_id) + |> put_change(:group_id, token.group_id) + |> put_change(:token_id, token.id) + |> assoc_constraint(:token) + end + + def finalize_upsert_changeset(%Gateways.Gateway{} = gateway, ipv4, ipv6) do + gateway + |> change() + |> put_change(:ipv4, ipv4) + |> put_change(:ipv6, ipv6) + end + + def update_changeset(%Gateways.Gateway{} = gateway, attrs) do + gateway + |> cast(attrs, @update_fields) + |> changeset() + |> validate_required(@required_fields) + end + + def delete_changeset(%Gateways.Gateway{} = gateway) do + gateway + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + end + + defp changeset(changeset) do + changeset + |> trim_change(:name_suffix) + |> validate_length(:name_suffix, min: 1, max: 8) + |> unique_constraint(:name_suffix, name: :gateways_group_id_name_suffix_index) + |> unique_constraint([:public_key]) + |> unique_constraint(:external_id) + end + + def put_gateway_version(changeset) do + with {_data_or_changes, user_agent} when not is_nil(user_agent) <- + fetch_field(changeset, :last_seen_user_agent), + {:ok, version} <- Version.fetch_version(user_agent) do + put_change(changeset, :last_seen_version, version) + else + {:error, :invalid_user_agent} -> add_error(changeset, :last_seen_user_agent, "is invalid") + _ -> changeset + end + end +end diff --git a/apps/domain/lib/domain/gateways/gateway/query.ex b/apps/domain/lib/domain/gateways/gateway/query.ex new file mode 100644 index 000000000..9ff60a0c3 --- /dev/null +++ b/apps/domain/lib/domain/gateways/gateway/query.ex @@ -0,0 +1,32 @@ +defmodule Domain.Gateways.Gateway.Query do + use Domain, :query + + def all do + from(gateways in Domain.Gateways.Gateway, as: :gateways) + |> where([gateways: gateways], is_nil(gateways.deleted_at)) + end + + def by_id(queryable \\ all(), id) do + where(queryable, [gateways: gateways], gateways.id == ^id) + end + + def by_user_id(queryable \\ all(), user_id) do + where(queryable, [gateways: gateways], gateways.user_id == ^user_id) + end + + def by_account_id(queryable \\ all(), account_id) do + where(queryable, [gateways: gateways], gateways.account_id == ^account_id) + end + + def returning_all(queryable \\ all()) do + select(queryable, [gateways: gateways], gateways) + end + + def with_preloaded_user(queryable \\ all()) do + with_named_binding(queryable, :user, fn queryable, binding -> + queryable + |> join(:inner, [gateways: gateways], user in assoc(gateways, ^binding), as: ^binding) + |> preload([gateways: gateways, user: user], user: user) + end) + end +end diff --git a/apps/domain/lib/domain/gateways/group.ex b/apps/domain/lib/domain/gateways/group.ex new file mode 100644 index 000000000..978085bda --- /dev/null +++ b/apps/domain/lib/domain/gateways/group.ex @@ -0,0 +1,15 @@ +defmodule Domain.Gateways.Group do + use Domain, :schema + + schema "gateway_groups" do + field :name_prefix, :string + field :tags, {:array, :string}, default: [] + + belongs_to :account, Domain.Accounts.Account + has_many :gateways, Domain.Gateways.Gateway, foreign_key: :group_id + has_many :tokens, Domain.Gateways.Token, foreign_key: :group_id + + field :deleted_at, :utc_datetime_usec + timestamps() + end +end diff --git a/apps/domain/lib/domain/gateways/group/changeset.ex b/apps/domain/lib/domain/gateways/group/changeset.ex new file mode 100644 index 000000000..62598f911 --- /dev/null +++ b/apps/domain/lib/domain/gateways/group/changeset.ex @@ -0,0 +1,48 @@ +defmodule Domain.Gateways.Group.Changeset do + use Domain, :changeset + alias Domain.Accounts + alias Domain.Gateways + + @fields ~w[name_prefix tags]a + + def create_changeset(%Accounts.Account{} = account, attrs) do + %Gateways.Group{account: account} + |> changeset(attrs) + |> put_change(:account_id, account.id) + end + + def update_changeset(%Gateways.Group{} = group, attrs) do + changeset(group, attrs) + end + + defp changeset(%Gateways.Group{} = group, attrs) do + group + |> cast(attrs, @fields) + |> trim_change(:name_prefix) + |> put_default_value(:name_prefix, &Domain.NameGenerator.generate/0) + |> validate_length(:name_prefix, min: 1, max: 64) + |> validate_length(:tags, min: 0, max: 128) + |> validate_no_duplicates(:tags) + |> validate_list_elements(:tags, fn key, value -> + if String.length(value) > 64 do + [{key, "should be at most 64 characters long"}] + else + [] + end + end) + |> validate_required(@fields) + |> unique_constraint(:name_prefix, name: :gateway_groups_account_id_name_prefix_index) + |> cast_assoc(:tokens, + with: fn _token, _attrs -> + Domain.Gateways.Token.Changeset.create_changeset(group.account) + end, + required: true + ) + end + + def delete_changeset(%Gateways.Group{} = group) do + group + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + end +end diff --git a/apps/domain/lib/domain/gateways/group/query.ex b/apps/domain/lib/domain/gateways/group/query.ex new file mode 100644 index 000000000..abd98faa1 --- /dev/null +++ b/apps/domain/lib/domain/gateways/group/query.ex @@ -0,0 +1,16 @@ +defmodule Domain.Gateways.Group.Query do + use Domain, :query + + def all do + from(groups in Domain.Gateways.Group, as: :groups) + |> where([groups: groups], is_nil(groups.deleted_at)) + end + + def by_id(queryable \\ all(), id) do + where(queryable, [groups: groups], groups.id == ^id) + end + + def by_account_id(queryable \\ all(), account_id) do + where(queryable, [groups: groups], groups.account_id == ^account_id) + end +end diff --git a/apps/api/lib/api/relay/presence.ex b/apps/domain/lib/domain/gateways/presence.ex similarity index 50% rename from apps/api/lib/api/relay/presence.ex rename to apps/domain/lib/domain/gateways/presence.ex index de97adb05..764b33e8c 100644 --- a/apps/api/lib/api/relay/presence.ex +++ b/apps/domain/lib/domain/gateways/presence.ex @@ -1,5 +1,5 @@ -defmodule API.Relay.Presence do +defmodule Domain.Gateways.Presence do use Phoenix.Presence, - otp_app: :api, + otp_app: :domain, pubsub_server: Domain.PubSub end diff --git a/apps/domain/lib/domain/gateways/token.ex b/apps/domain/lib/domain/gateways/token.ex new file mode 100644 index 000000000..e0febe49e --- /dev/null +++ b/apps/domain/lib/domain/gateways/token.ex @@ -0,0 +1,14 @@ +defmodule Domain.Gateways.Token do + use Domain, :schema + + schema "gateway_tokens" do + field :value, :string, virtual: true + field :hash, :string + + belongs_to :account, Domain.Accounts.Account + belongs_to :group, Domain.Gateways.Group + + field :deleted_at, :utc_datetime_usec + timestamps(updated_at: false) + end +end diff --git a/apps/domain/lib/domain/gateways/token/changeset.ex b/apps/domain/lib/domain/gateways/token/changeset.ex new file mode 100644 index 000000000..8261f85be --- /dev/null +++ b/apps/domain/lib/domain/gateways/token/changeset.ex @@ -0,0 +1,31 @@ +defmodule Domain.Gateways.Token.Changeset do + use Domain, :changeset + alias Domain.Accounts + alias Domain.Gateways + + def create_changeset(%Accounts.Account{} = account) do + %Gateways.Token{} + |> change() + |> put_change(:account_id, account.id) + |> put_change(:value, Domain.Crypto.rand_string()) + |> put_hash(:value, to: :hash) + |> assoc_constraint(:group) + |> check_constraint(:hash, name: :hash_not_null, message: "can't be blank") + end + + def use_changeset(%Gateways.Token{} = token) do + # TODO: While we don't have token rotation implemented, the tokens are all multi-use + # delete_changeset(token) + + token + |> change() + end + + def delete_changeset(%Gateways.Token{} = token) do + token + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + |> put_change(:hash, nil) + |> check_constraint(:hash, name: :hash_not_null, message: "must be blank") + end +end diff --git a/apps/domain/lib/domain/gateways/token/query.ex b/apps/domain/lib/domain/gateways/token/query.ex new file mode 100644 index 000000000..8661bb224 --- /dev/null +++ b/apps/domain/lib/domain/gateways/token/query.ex @@ -0,0 +1,16 @@ +defmodule Domain.Gateways.Token.Query do + use Domain, :query + + def all do + from(token in Domain.Gateways.Token, as: :token) + |> where([token: token], is_nil(token.deleted_at)) + end + + def by_id(queryable \\ all(), id) do + where(queryable, [token: token], token.id == ^id) + end + + def by_group_id(queryable \\ all(), group_id) do + where(queryable, [token: token], token.group_id == ^group_id) + end +end diff --git a/apps/domain/lib/domain/network.ex b/apps/domain/lib/domain/network.ex new file mode 100644 index 000000000..1264a159b --- /dev/null +++ b/apps/domain/lib/domain/network.ex @@ -0,0 +1,28 @@ +defmodule Domain.Network do + alias Domain.Repo + alias Domain.Network.Address + + @cidrs %{ + ipv4: %Postgrex.INET{address: {100, 64, 0, 0}, netmask: 10}, + ipv6: %Postgrex.INET{address: {64768, 0, 0, 0, 0, 0, 0, 0}, netmask: 106} + } + + def fetch_next_available_address!(account_id, type, opts \\ []) do + unless Repo.in_transaction?() do + raise "fetch_next_available_address/1 must be called inside a transaction" + end + + cidrs = Keyword.get(opts, :cidrs, @cidrs) + cidr = Map.fetch!(cidrs, type) + hosts = Domain.Types.CIDR.count_hosts(cidr) + offset = Enum.random(2..max(2, hosts - 2)) + + address = + Address.Query.next_available_address(account_id, cidr, offset) + |> Domain.Repo.one!() + |> Address.Changeset.create_changeset(account_id) + |> Repo.insert!() + + address.address + end +end diff --git a/apps/domain/lib/domain/network/address.ex b/apps/domain/lib/domain/network/address.ex new file mode 100644 index 000000000..ba9b6a68f --- /dev/null +++ b/apps/domain/lib/domain/network/address.ex @@ -0,0 +1,13 @@ +defmodule Domain.Network.Address do + use Domain, :schema + + @primary_key false + schema "network_addresses" do + field :address, Domain.Types.IP, primary_key: true + belongs_to :account, Domain.Accounts.Account, primary_key: true + + field :type, Ecto.Enum, values: [:ipv4, :ipv6] + + timestamps(updated_at: false) + end +end diff --git a/apps/domain/lib/domain/network/address/changeset.ex b/apps/domain/lib/domain/network/address/changeset.ex new file mode 100644 index 000000000..6465fbecb --- /dev/null +++ b/apps/domain/lib/domain/network/address/changeset.ex @@ -0,0 +1,20 @@ +defmodule Domain.Network.Address.Changeset do + use Domain, :changeset + alias Domain.Network.Address + + def create_changeset(address, account_id) do + %Address{} + |> change() + |> put_change(:address, address) + |> put_change(:account_id, account_id) + |> put_default_value(:type, fn changeset -> + case fetch_field(changeset, :address) do + {_data_or_changes, inet} when tuple_size(inet.address) == 4 -> :ipv4 + {_data_or_changes, inet} when tuple_size(inet.address) == 8 -> :ipv6 + _other -> nil + end + end) + |> validate_required([:type, :address]) + |> validate_inclusion(:type, Ecto.Enum.values(Address, :type)) + end +end diff --git a/apps/domain/lib/domain/network/address/query.ex b/apps/domain/lib/domain/network/address/query.ex new file mode 100644 index 000000000..a2c385077 --- /dev/null +++ b/apps/domain/lib/domain/network/address/query.ex @@ -0,0 +1,140 @@ +defmodule Domain.Network.Address.Query do + use Domain, :query + + def all do + from(addresses in Domain.Network.Address, as: :addresses) + end + + def by_account_id(queryable \\ all(), account_id) do + where(queryable, [addresses: addresses], addresses.account_id == ^account_id) + end + + @doc """ + Returns IP address at given integer offset relative to start of CIDR range. + """ + defmacro offset_to_ip(field, cidr) do + quote do + fragment("host(?)::inet + ?", unquote(cidr), unquote(field)) + end + end + + @doc """ + Returns index of last IP address available for allocation in CIDR sequence. + + Notice: the very last address in CIDR is typically a broadcast address that we won't allow to use. + """ + defmacro cidr_end_offset(cidr) do + quote do + fragment( + "host(broadcast(?))::inet - host(?)::inet - 1", + unquote(cidr), + unquote(cidr) + ) + end + end + + @doc """ + Acquires a transactional advisory lock for an IP address using "network_addresses" table oid as namespace. + + To fit bigint offset into int lock identifier we rollover at the integer max value. + """ + defmacro acquire_advisory_lock(field) do + quote do + fragment( + "pg_try_advisory_xact_lock('network_addresses'::regclass::oid::int, mod(?, 2147483647)::int)", + unquote(field) + ) + end + end + + @doc """ + This function returns a query to fetch next available IP address, it works in 2 steps: + + 1. It starts by forward-scanning starting for available addresses at `offset` in a given `network_cidr` + up to the end of CIDR range; + + 2. If forward-scan failed, scan backwards from the offset (exclusive) to start of CIDR range. + + During the search, occupied addresses are skipped. + + We also exclude first (X.X.X.0) and last (broadcast) address in a CIDR from a search range, + to prevent issues with legacy firewalls that consider them "class C" space network addresses. + """ + def next_available_address(account_id, network_cidr, offset) do + forward_search_queryable = + series_from_offset_inclusive_to_end_of_cidr(network_cidr, offset) + |> select_not_used_ips(account_id, network_cidr) + + reverse_search_queryable = + series_from_start_of_cidr_to_offset_exclusive(network_cidr, offset) + |> select_not_used_ips(account_id, network_cidr) + + union_all(forward_search_queryable, ^reverse_search_queryable) + |> limit(1) + end + + # Although sequences can work with inet types, we iterate over the sequence using an + # offset relative to start of the given CIDR range. + # + # This way is chosen because IPv6 cannot be cast to bigint, so by using it directly + # we won't be able to increment/decrement it while building a sequence. + # + # At the same time offset will fit to bigint even for largest CIDR ranges that Firezone supports. + # + # XXX: We can make this code prettier once https://github.com/elixir-ecto/ecto/commit/8f7bb2665bce30dfab18cfed01585c96495575a6 is released. + defp series_from_offset_inclusive_to_end_of_cidr(network_cidr, offset) do + from( + i in fragment( + "SELECT generate_series((?)::bigint, (?)::bigint, ?) AS ip", + ^offset, + cidr_end_offset(^network_cidr), + 1 + ), + as: :q + ) + end + + defp series_from_start_of_cidr_to_offset_exclusive(_network_cidr, offset) do + from( + i in fragment( + "SELECT generate_series((?)::bigint, (?)::bigint, ?) AS ip", + ^(offset - 1), + 2, + -1 + ), + as: :q + ) + end + + defp select_not_used_ips(queryable, account_id, network_cidr) do + host_as_string = network_cidr.address |> :inet.ntoa() |> List.to_string() + + queryable + |> where( + [q: q], + offset_to_ip(q.ip, ^network_cidr) not in subquery( + used_ips_subquery(account_id, network_cidr) + ) + ) + |> where( + [q: q], + acquire_advisory_lock(fragment("hashtext(?) + ?", ^host_as_string, q.ip)) == + true + ) + |> select([q: q], offset_to_ip(q.ip, ^network_cidr)) + end + + defp used_ips_subquery(queryable \\ all(), account_id, cidr) do + queryable + |> by_type(type(cidr)) + |> by_account_id(account_id) + |> select([addresses: addresses], addresses.address) + end + + defp type(%Postgrex.INET{address: address}) when tuple_size(address) == 4, do: :ipv4 + defp type(%Postgrex.INET{address: address}) when tuple_size(address) == 8, do: :ipv6 + + defp by_type(queryable, type) do + where(queryable, [addresses: addresses], addresses.type == ^type) + end +end diff --git a/apps/domain/lib/domain/relays.ex b/apps/domain/lib/domain/relays.ex new file mode 100644 index 000000000..9e3decb04 --- /dev/null +++ b/apps/domain/lib/domain/relays.ex @@ -0,0 +1,164 @@ +defmodule Domain.Relays do + use Supervisor + alias Domain.{Repo, Auth, Validator} + alias Domain.Relays.{Authorizer, Relay, Group, Token, Presence} + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(_opts) do + children = [ + Presence + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def fetch_group_by_id(id, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()), + true <- Validator.valid_uuid?(id) do + Group.Query.by_id(id) + |> Authorizer.for_subject(subject) + |> Repo.fetch() + else + false -> {:error, :not_found} + other -> other + end + end + + def list_groups(%Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do + Group.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + end + end + + def new_group(attrs \\ %{}) do + change_group(%Group{}, attrs) + end + + def create_group(attrs \\ %{}, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do + subject.account + |> Group.Changeset.create_changeset(attrs) + |> Repo.insert() + end + end + + def change_group(%Group{} = group, attrs \\ %{}) do + group + |> Repo.preload(:account) + |> Group.Changeset.update_changeset(attrs) + end + + def update_group(%Group{} = group, attrs \\ %{}, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do + group + |> Repo.preload(:account) + |> Group.Changeset.update_changeset(attrs) + |> Repo.update() + end + end + + def delete_group(%Group{} = group, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do + Group.Query.by_id(group.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update( + with: fn group -> + :ok = + Token.Query.by_group_id(group.id) + |> Repo.all() + |> Enum.each(fn token -> + Token.Changeset.delete_changeset(token) + |> Repo.update!() + end) + + group + |> Group.Changeset.delete_changeset() + end + ) + end + end + + def use_token_by_id_and_secret(id, secret) do + if Validator.valid_uuid?(id) do + Token.Query.by_id(id) + |> Repo.fetch_and_update( + with: fn token -> + if Domain.Crypto.equal?(secret, token.hash) do + Token.Changeset.use_changeset(token) + else + :not_found + end + end + ) + else + {:error, :not_found} + end + end + + def fetch_relay_by_id(id, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()), + true <- Validator.valid_uuid?(id) do + Relay.Query.by_id(id) + |> Authorizer.for_subject(subject) + |> Repo.fetch() + else + false -> {:error, :not_found} + other -> other + end + end + + def fetch_relay_by_id!(id, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + + Relay.Query.by_id(id) + |> Repo.one!() + |> Repo.preload(preload) + end + + def list_relays(%Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do + Relay.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + end + end + + def upsert_relay(%Token{} = token, attrs) do + changeset = Relay.Changeset.upsert_changeset(token, attrs) + + Ecto.Multi.new() + |> Ecto.Multi.insert(:relay, changeset, + conflict_target: Relay.Changeset.upsert_conflict_target(), + on_conflict: Relay.Changeset.upsert_on_conflict(), + returning: true + ) + |> Repo.transaction() + |> case do + {:ok, %{relay: relay}} -> {:ok, relay} + {:error, :relay, changeset, _effects_so_far} -> {:error, changeset} + end + end + + def delete_relay(%Relay{} = relay, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do + Relay.Query.by_id(relay.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update(with: &Relay.Changeset.delete_changeset/1) + end + end + + def connect_relay(%Relay{} = relay, secret, socket) do + {:ok, _} = + Presence.track(socket, relay.id, %{ + online_at: System.system_time(:second), + secret: secret + }) + + :ok + end +end diff --git a/apps/domain/lib/domain/relays/authorizer.ex b/apps/domain/lib/domain/relays/authorizer.ex new file mode 100644 index 000000000..a9f14cf44 --- /dev/null +++ b/apps/domain/lib/domain/relays/authorizer.ex @@ -0,0 +1,36 @@ +defmodule Domain.Relays.Authorizer do + use Domain.Auth.Authorizer + alias Domain.Relays.{Group, Relay} + + def manage_relays_permission, do: build(Relay, :manage) + + @impl Domain.Auth.Authorizer + + def list_permissions_for_role(:admin) do + [ + manage_relays_permission() + ] + end + + def list_permissions_for_role(_) do + [] + end + + @impl Domain.Auth.Authorizer + def for_subject(queryable, %Subject{} = subject) when is_user(subject) do + cond do + has_permission?(subject, manage_relays_permission()) -> + by_account_id(queryable, subject) + end + end + + defp by_account_id(queryable, subject) do + cond do + Ecto.Query.has_named_binding?(queryable, :groups) -> + Group.Query.by_account_id(queryable, subject.account.id) + + Ecto.Query.has_named_binding?(queryable, :relays) -> + Relay.Query.by_account_id(queryable, subject.account.id) + end + end +end diff --git a/apps/domain/lib/domain/relays/group.ex b/apps/domain/lib/domain/relays/group.ex new file mode 100644 index 000000000..37cfbc463 --- /dev/null +++ b/apps/domain/lib/domain/relays/group.ex @@ -0,0 +1,14 @@ +defmodule Domain.Relays.Group do + use Domain, :schema + + schema "relay_groups" do + field :name, :string + + belongs_to :account, Domain.Accounts.Account + has_many :relays, Domain.Relays.Relay, foreign_key: :group_id + has_many :tokens, Domain.Relays.Token, foreign_key: :group_id + + field :deleted_at, :utc_datetime_usec + timestamps() + end +end diff --git a/apps/domain/lib/domain/relays/group/changeset.ex b/apps/domain/lib/domain/relays/group/changeset.ex new file mode 100644 index 000000000..0c5cdb3eb --- /dev/null +++ b/apps/domain/lib/domain/relays/group/changeset.ex @@ -0,0 +1,39 @@ +defmodule Domain.Relays.Group.Changeset do + use Domain, :changeset + alias Domain.Accounts + alias Domain.Relays + + @fields ~w[name]a + + def create_changeset(%Accounts.Account{} = account, attrs) do + %Relays.Group{account: account} + |> changeset(attrs) + |> put_change(:account_id, account.id) + end + + def update_changeset(%Relays.Group{} = group, attrs) do + changeset(group, attrs) + end + + defp changeset(group, attrs) do + group + |> cast(attrs, @fields) + |> trim_change(:name) + |> put_default_value(:name, &Domain.NameGenerator.generate/0) + |> validate_length(:name, min: 1, max: 64) + |> validate_required(@fields) + |> unique_constraint(:name, name: :relay_groups_account_id_name_index) + |> cast_assoc(:tokens, + with: fn _token, _attrs -> + Domain.Relays.Token.Changeset.create_changeset(group.account) + end, + required: true + ) + end + + def delete_changeset(%Relays.Group{} = group) do + group + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + end +end diff --git a/apps/domain/lib/domain/relays/group/query.ex b/apps/domain/lib/domain/relays/group/query.ex new file mode 100644 index 000000000..fe578b508 --- /dev/null +++ b/apps/domain/lib/domain/relays/group/query.ex @@ -0,0 +1,16 @@ +defmodule Domain.Relays.Group.Query do + use Domain, :query + + def all do + from(groups in Domain.Relays.Group, as: :groups) + |> where([groups: groups], is_nil(groups.deleted_at)) + end + + def by_id(queryable \\ all(), id) do + where(queryable, [groups: groups], groups.id == ^id) + end + + def by_account_id(queryable \\ all(), account_id) do + where(queryable, [groups: groups], groups.account_id == ^account_id) + end +end diff --git a/apps/api/lib/api/client/presence.ex b/apps/domain/lib/domain/relays/presence.ex similarity index 51% rename from apps/api/lib/api/client/presence.ex rename to apps/domain/lib/domain/relays/presence.ex index b3a2be2c4..35d672a09 100644 --- a/apps/api/lib/api/client/presence.ex +++ b/apps/domain/lib/domain/relays/presence.ex @@ -1,5 +1,5 @@ -defmodule API.Client.Presence do +defmodule Domain.Relays.Presence do use Phoenix.Presence, - otp_app: :api, + otp_app: :domain, pubsub_server: Domain.PubSub end diff --git a/apps/domain/lib/domain/relays/relay.ex b/apps/domain/lib/domain/relays/relay.ex new file mode 100644 index 000000000..971b357d1 --- /dev/null +++ b/apps/domain/lib/domain/relays/relay.ex @@ -0,0 +1,20 @@ +defmodule Domain.Relays.Relay do + use Domain, :schema + + schema "relays" do + field :ipv4, Domain.Types.IP + field :ipv6, Domain.Types.IP + + field :last_seen_user_agent, :string + field :last_seen_remote_ip, Domain.Types.IP + field :last_seen_version, :string + field :last_seen_at, :utc_datetime_usec + + belongs_to :account, Domain.Accounts.Account + belongs_to :group, Domain.Relays.Group + belongs_to :token, Domain.Relays.Token + + field :deleted_at, :utc_datetime_usec + timestamps() + end +end diff --git a/apps/domain/lib/domain/relays/relay/changeset.ex b/apps/domain/lib/domain/relays/relay/changeset.ex new file mode 100644 index 000000000..490df75ce --- /dev/null +++ b/apps/domain/lib/domain/relays/relay/changeset.ex @@ -0,0 +1,48 @@ +defmodule Domain.Relays.Relay.Changeset do + use Domain, :changeset + alias Domain.Version + alias Domain.Relays + + @upsert_fields ~w[ipv4 ipv6 + last_seen_user_agent last_seen_remote_ip]a + @conflict_replace_fields ~w[ipv4 ipv6 + last_seen_user_agent last_seen_remote_ip + last_seen_version last_seen_at]a + + def upsert_conflict_target, + do: {:unsafe_fragment, ~s/(account_id, COALESCE(ipv4, ipv6)) WHERE deleted_at IS NULL/} + + def upsert_on_conflict, do: {:replace, @conflict_replace_fields} + + def upsert_changeset(%Relays.Token{} = token, attrs) do + %Relays.Relay{} + |> cast(attrs, @upsert_fields) + |> validate_required(~w[last_seen_user_agent last_seen_remote_ip]a) + |> validate_required_one_of(~w[ipv4 ipv6]a) + |> unique_constraint(:ipv4, name: :relays_account_id_ipv4_index) + |> unique_constraint(:ipv6, name: :relays_account_id_ipv6_index) + |> put_change(:last_seen_at, DateTime.utc_now()) + |> put_relay_version() + |> put_change(:account_id, token.account_id) + |> put_change(:group_id, token.group_id) + |> put_change(:token_id, token.id) + |> assoc_constraint(:token) + end + + def delete_changeset(%Relays.Relay{} = relay) do + relay + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + end + + def put_relay_version(changeset) do + with {_data_or_changes, user_agent} when not is_nil(user_agent) <- + fetch_field(changeset, :last_seen_user_agent), + {:ok, version} <- Version.fetch_version(user_agent) do + put_change(changeset, :last_seen_version, version) + else + {:error, :invalid_user_agent} -> add_error(changeset, :last_seen_user_agent, "is invalid") + _ -> changeset + end + end +end diff --git a/apps/domain/lib/domain/relays/relay/query.ex b/apps/domain/lib/domain/relays/relay/query.ex new file mode 100644 index 000000000..5f05c01c7 --- /dev/null +++ b/apps/domain/lib/domain/relays/relay/query.ex @@ -0,0 +1,32 @@ +defmodule Domain.Relays.Relay.Query do + use Domain, :query + + def all do + from(relays in Domain.Relays.Relay, as: :relays) + |> where([relays: relays], is_nil(relays.deleted_at)) + end + + def by_id(queryable \\ all(), id) do + where(queryable, [relays: relays], relays.id == ^id) + end + + def by_user_id(queryable \\ all(), user_id) do + where(queryable, [relays: relays], relays.user_id == ^user_id) + end + + def by_account_id(queryable \\ all(), account_id) do + where(queryable, [relays: relays], relays.account_id == ^account_id) + end + + def returning_all(queryable \\ all()) do + select(queryable, [relays: relays], relays) + end + + def with_preloaded_user(queryable \\ all()) do + with_named_binding(queryable, :user, fn queryable, binding -> + queryable + |> join(:inner, [relays: relays], user in assoc(relays, ^binding), as: ^binding) + |> preload([relays: relays, user: user], user: user) + end) + end +end diff --git a/apps/domain/lib/domain/relays/token.ex b/apps/domain/lib/domain/relays/token.ex new file mode 100644 index 000000000..68b49a241 --- /dev/null +++ b/apps/domain/lib/domain/relays/token.ex @@ -0,0 +1,14 @@ +defmodule Domain.Relays.Token do + use Domain, :schema + + schema "relay_tokens" do + field :value, :string, virtual: true + field :hash, :string + + belongs_to :account, Domain.Accounts.Account + belongs_to :group, Domain.Relays.Group + + field :deleted_at, :utc_datetime_usec + timestamps(updated_at: false) + end +end diff --git a/apps/domain/lib/domain/relays/token/changeset.ex b/apps/domain/lib/domain/relays/token/changeset.ex new file mode 100644 index 000000000..8f3fe3127 --- /dev/null +++ b/apps/domain/lib/domain/relays/token/changeset.ex @@ -0,0 +1,31 @@ +defmodule Domain.Relays.Token.Changeset do + use Domain, :changeset + alias Domain.Accounts + alias Domain.Relays + + def create_changeset(%Accounts.Account{} = account) do + %Relays.Token{} + |> change() + |> put_change(:value, Domain.Crypto.rand_string()) + |> put_hash(:value, to: :hash) + |> assoc_constraint(:group) + |> check_constraint(:hash, name: :hash_not_null, message: "can't be blank") + |> put_change(:account_id, account.id) + end + + def use_changeset(%Relays.Token{} = token) do + # TODO: While we don't have token rotation implemented, the tokens are all multi-use + # delete_changeset(token) + + token + |> change() + end + + def delete_changeset(%Relays.Token{} = token) do + token + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + |> put_change(:hash, nil) + |> check_constraint(:hash, name: :hash_not_null, message: "must be blank") + end +end diff --git a/apps/domain/lib/domain/relays/token/query.ex b/apps/domain/lib/domain/relays/token/query.ex new file mode 100644 index 000000000..7e6905856 --- /dev/null +++ b/apps/domain/lib/domain/relays/token/query.ex @@ -0,0 +1,16 @@ +defmodule Domain.Relays.Token.Query do + use Domain, :query + + def all do + from(token in Domain.Relays.Token, as: :token) + |> where([token: token], is_nil(token.deleted_at)) + end + + def by_id(queryable \\ all(), id) do + where(queryable, [token: token], token.id == ^id) + end + + def by_group_id(queryable \\ all(), group_id) do + where(queryable, [token: token], token.group_id == ^group_id) + end +end diff --git a/apps/domain/lib/domain/repo.ex b/apps/domain/lib/domain/repo.ex index 1616b9425..1520d7117 100644 --- a/apps/domain/lib/domain/repo.ex +++ b/apps/domain/lib/domain/repo.ex @@ -3,11 +3,14 @@ defmodule Domain.Repo do otp_app: :domain, adapter: Ecto.Adapters.Postgres + require Ecto.Query + @doc """ Similar to `Ecto.Repo.one/2`, fetches a single result from the query. Returns `{:ok, schema}` or `{:error, :not_found}` if no result was found. - Raises if there is more than one row matching the query. + + Raises when the query returns more than one row. """ @spec fetch(queryable :: Ecto.Queryable.t(), opts :: Keyword.t()) :: {:ok, Ecto.Schema.t()} | {:error, :not_found} @@ -23,6 +26,39 @@ defmodule Domain.Repo do """ def fetch!(queryable, opts \\ []), do: __MODULE__.one!(queryable, opts) + @doc """ + Uses query to fetch a single result from the database, locks it for update and + then updates it using a changeset within a database transaction. + + Raises when the query returns more than one row. + """ + @spec fetch_and_update( + queryable :: Ecto.Queryable.t(), + [{:with, changeset_fun :: (term() -> Ecto.Changeset.t())}], + opts :: Keyword.t() + ) :: + {:ok, Ecto.Schema.t()} | {:error, :not_found} | {:error, Ecto.Changeset.t()} + def fetch_and_update(queryable, [with: changeset_fun], opts \\ []) + when is_function(changeset_fun, 1) do + transaction(fn -> + queryable = Ecto.Query.lock(queryable, "FOR UPDATE") + + with {:ok, schema} <- fetch(queryable, opts) do + schema + |> changeset_fun.() + |> case do + %Ecto.Changeset{} = changeset -> update(changeset, opts) + reason -> {:error, reason} + end + end + end) + |> case do + {:ok, {:ok, schema}} -> {:ok, schema} + {:ok, {:error, reason}} -> {:error, reason} + {:error, reason} -> {:error, reason} + end + end + @doc """ Similar to `Ecto.Repo.all/2`, fetches all results from the query but return a tuple. """ diff --git a/apps/domain/lib/domain/telemetry.ex b/apps/domain/lib/domain/telemetry.ex index cb6054c44..d1889c381 100644 --- a/apps/domain/lib/domain/telemetry.ex +++ b/apps/domain/lib/domain/telemetry.ex @@ -127,7 +127,7 @@ defmodule Domain.Telemetry do unprivileged_device_configuration: allow_unprivileged_device_configuration, local_authentication: local_auth_enabled, disable_vpn_on_oidc_error: disable_vpn_on_oidc_error, - outbound_email: Web.Mailer.active?(), + # outbound_email: Web.Mailer.active?(), external_database: external_database?(Map.new(Domain.Config.fetch_env!(:domain, Domain.Repo))), logo_type: Domain.Config.Logo.type(logo) diff --git a/apps/domain/lib/domain/types/ip.ex b/apps/domain/lib/domain/types/ip.ex index 9e9a9343b..053557c1d 100644 --- a/apps/domain/lib/domain/types/ip.ex +++ b/apps/domain/lib/domain/types/ip.ex @@ -11,6 +11,8 @@ defmodule Domain.Types.IP do def equal?(left, right), do: left == right + def cast(tuple) when tuple_size(tuple) == 4, do: {:ok, %Postgrex.INET{address: tuple}} + def cast(tuple) when tuple_size(tuple) == 8, do: {:ok, %Postgrex.INET{address: tuple}} def cast(%Postgrex.INET{} = inet), do: {:ok, inet} def cast(binary) when is_binary(binary) do diff --git a/apps/domain/lib/domain/types/protocols.ex b/apps/domain/lib/domain/types/protocols.ex index df6c39112..1805c10b2 100644 --- a/apps/domain/lib/domain/types/protocols.ex +++ b/apps/domain/lib/domain/types/protocols.ex @@ -2,9 +2,9 @@ defimpl String.Chars, for: Postgrex.INET do def to_string(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) end -defimpl Phoenix.HTML.Safe, for: Postgrex.INET do - def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) -end +# defimpl Phoenix.HTML.Safe, for: Postgrex.INET do +# def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) +# end defimpl Jason.Encoder, for: Postgrex.INET do def encode(%Postgrex.INET{} = struct, opts) do @@ -16,6 +16,6 @@ defimpl String.Chars, for: Domain.Types.IPPort do def to_string(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) end -defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do - def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) -end +# defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do +# def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) +# end diff --git a/apps/domain/lib/domain/users.ex b/apps/domain/lib/domain/users.ex index ed30abda4..6ada7dd39 100644 --- a/apps/domain/lib/domain/users.ex +++ b/apps/domain/lib/domain/users.ex @@ -1,5 +1,6 @@ defmodule Domain.Users do alias Domain.{Repo, Auth, Validator, Config, Telemetry} + alias Domain.Accounts alias Domain.Users.{Authorizer, User} require Ecto.Query @@ -100,14 +101,14 @@ defmodule Domain.Users do end end - def create_user(role, attrs, %Auth.Subject{} = subject) do + def create_user(%Accounts.Account{} = account, role, attrs, %Auth.Subject{} = subject) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_users_permission()) do - create_user(role, attrs) + create_user(account, role, attrs) end end - def create_user(role, attrs) do - changeset = User.Changeset.create_changeset(role, attrs) + def create_user(%Accounts.Account{} = account, role, attrs) when is_atom(role) do + changeset = User.Changeset.create_changeset(account, role, attrs) with {:ok, user} <- Repo.insert(changeset) do Telemetry.add_user() diff --git a/apps/domain/lib/domain/users/user.ex b/apps/domain/lib/domain/users/user.ex index 98496f187..de8aa87e2 100644 --- a/apps/domain/lib/domain/users/user.ex +++ b/apps/domain/lib/domain/users/user.ex @@ -24,6 +24,8 @@ defmodule Domain.Users.User do has_many :oidc_connections, Domain.Auth.OIDC.Connection has_many :api_tokens, Domain.ApiTokens.ApiToken + belongs_to :account, Domain.Accounts.Account + field :disabled_at, :utc_datetime_usec timestamps() end diff --git a/apps/domain/lib/domain/users/user/changeset.ex b/apps/domain/lib/domain/users/user/changeset.ex index 85bc9e2c6..1d562e5a3 100644 --- a/apps/domain/lib/domain/users/user/changeset.ex +++ b/apps/domain/lib/domain/users/user/changeset.ex @@ -1,12 +1,12 @@ defmodule Domain.Users.User.Changeset do use Domain, :changeset - alias Domain.Auth + alias Domain.{Accounts, Auth} alias Domain.Users @min_password_length 12 @max_password_length 64 - def create_changeset(role, attrs) when is_atom(role) do + def create_changeset(%Accounts.Account{} = account, role, attrs) when is_atom(role) do %Users.User{} |> cast(attrs, ~w[ email @@ -14,6 +14,7 @@ defmodule Domain.Users.User.Changeset do password_confirmation ]a) |> put_change(:role, role) + |> put_change(:account_id, account.id) |> change_email_changeset() |> validate_if_changed(:password, &change_password_changeset/1) end diff --git a/apps/domain/lib/domain/validator.ex b/apps/domain/lib/domain/validator.ex index 90a7a4847..96973f9c8 100644 --- a/apps/domain/lib/domain/validator.ex +++ b/apps/domain/lib/domain/validator.ex @@ -281,6 +281,35 @@ defmodule Domain.Validator do end end + def validate_required_one_of(%Ecto.Changeset{} = changeset, fields) do + if Enum.any?(fields, &(not empty?(changeset, &1))) do + changeset + else + Enum.reduce( + fields, + changeset, + &add_error(&2, &1, "one of these fields must be present: #{Enum.join(fields, ", ")}", + validation: :one_of, + one_of: fields + ) + ) + end + end + + @doc """ + Applies a validation function for every elements of the list. + + The validation function should take two arguments: field name and element value, + and return the same structure as `validate_change/3`. + """ + def validate_list_elements(%Ecto.Changeset{} = changeset, field, callback) do + validate_change(changeset, field, fn _field, values -> + values + |> Enum.flat_map(&callback.(field, &1)) + |> Enum.uniq() + end) + end + @doc """ Removes change for a given field and original value from it from `changeset.params`. @@ -304,14 +333,15 @@ defmodule Domain.Validator do def put_default_value(changeset, field, value) do case fetch_field(changeset, field) do - {:data, nil} -> put_change(changeset, field, maybe_apply(value)) - :error -> put_change(changeset, field, maybe_apply(value)) + {:data, nil} -> put_change(changeset, field, maybe_apply(changeset, value)) + :error -> put_change(changeset, field, maybe_apply(changeset, value)) _ -> changeset end end - defp maybe_apply(fun) when is_function(fun, 0), do: fun.() - defp maybe_apply(value), do: value + defp maybe_apply(_changeset, fun) when is_function(fun, 0), do: fun.() + defp maybe_apply(changeset, fun) when is_function(fun, 1), do: fun.(changeset) + defp maybe_apply(_changeset, value), do: value def trim_change(changeset, field) do update_change(changeset, field, fn diff --git a/apps/domain/lib/domain/version.ex b/apps/domain/lib/domain/version.ex new file mode 100644 index 000000000..381977110 --- /dev/null +++ b/apps/domain/lib/domain/version.ex @@ -0,0 +1,18 @@ +defmodule Domain.Version do + def fetch_version(user_agent) when is_binary(user_agent) do + user_agent + |> String.split(" ") + |> Enum.find_value(fn + "connlib/" <> version -> version + _ -> nil + end) + |> case do + nil -> {:error, :invalid_user_agent} + version -> {:ok, version} + end + end + + def fetch_gateway_version(_user_agent) do + {:error, :invalid_user_agent} + end +end diff --git a/apps/domain/mix.exs b/apps/domain/mix.exs index ce921da68..10582f011 100644 --- a/apps/domain/mix.exs +++ b/apps/domain/mix.exs @@ -53,7 +53,8 @@ defmodule Domain.MixProject do {:cloak, "~> 1.1"}, {:cloak_ecto, "~> 1.2"}, - # PubSub + # PubSub and Presence + {:phoenix, "~> 1.7", runtime: false}, {:phoenix_pubsub, "~> 2.0"}, # Auth-related deps diff --git a/apps/domain/priv/repo/migrations/20230405171110_create_accounts.exs b/apps/domain/priv/repo/migrations/20230405171110_create_accounts.exs new file mode 100644 index 000000000..77ea1466f --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230405171110_create_accounts.exs @@ -0,0 +1,18 @@ +defmodule Domain.Repo.Migrations.CreateAccounts do + use Ecto.Migration + + def change do + create table(:accounts, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:name, :string, null: false) + + timestamps(type: :utc_datetime_usec) + end + + alter table(:users) do + add(:account_id, references(:accounts, type: :binary_id, on_delete: :delete_all), + null: false + ) + end + end +end diff --git a/apps/domain/priv/repo/migrations/20230405172108_create_network_addresses.exs b/apps/domain/priv/repo/migrations/20230405172108_create_network_addresses.exs new file mode 100644 index 000000000..8bfa1908f --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230405172108_create_network_addresses.exs @@ -0,0 +1,13 @@ +defmodule Domain.Repo.Migrations.CreateNetworkAddresses do + use Ecto.Migration + + def change do + create(table(:network_addresses, primary_key: false)) do + add(:type, :string, null: false) + add(:address, :inet, null: false, primary_key: true) + add(:account_id, references(:accounts, type: :binary_id), null: false, primary_key: true) + + timestamps(type: :utc_datetime_usec, updated_at: false) + end + end +end diff --git a/apps/domain/priv/repo/migrations/20230405181921_create_clients.exs b/apps/domain/priv/repo/migrations/20230405181921_create_clients.exs new file mode 100644 index 000000000..d39178c60 --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230405181921_create_clients.exs @@ -0,0 +1,71 @@ +defmodule Domain.Repo.Migrations.CreateClients do + use Ecto.Migration + + def change do + create table(:clients, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:external_id, :string, null: false) + + add(:name, :string, null: false) + + add(:public_key, :string, null: false) + add(:preshared_key, :binary, null: false) + + add( + :ipv4, + references(:network_addresses, + column: :address, + type: :inet, + with: [account_id: :account_id] + ) + ) + + add( + :ipv6, + references(:network_addresses, + column: :address, + type: :inet, + with: [account_id: :account_id] + ) + ) + + add(:last_seen_user_agent, :string, null: false) + add(:last_seen_remote_ip, :inet, null: false) + add(:last_seen_version, :string, null: false) + add(:last_seen_at, :utc_datetime_usec, null: false) + + add(:account_id, references(:accounts, type: :binary_id), null: false) + add(:user_id, references(:users, type: :binary_id), null: false) + + add(:deleted_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec) + end + + # Used to list clients for a user + create(index(:clients, [:user_id], where: "deleted_at IS NULL")) + + # Used for upserts + create( + index(:clients, [:account_id, :user_id, :external_id], + unique: true, + where: "deleted_at IS NULL" + ) + ) + + # Used to enforce unique IPv4 and IPv6 addresses. + create(index(:clients, [:account_id, :ipv4], unique: true, where: "deleted_at IS NULL")) + create(index(:clients, [:account_id, :ipv6], unique: true, where: "deleted_at IS NULL")) + + # Used to enforce unique names and public keys. + create( + index(:clients, [:account_id, :user_id, :name], unique: true, where: "deleted_at IS NULL") + ) + + create( + index(:clients, [:account_id, :user_id, :public_key], + unique: true, + where: "deleted_at IS NULL" + ) + ) + end +end diff --git a/apps/domain/priv/repo/migrations/20230405181922_create_gateway_groups.exs b/apps/domain/priv/repo/migrations/20230405181922_create_gateway_groups.exs new file mode 100644 index 000000000..d5297fa3d --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230405181922_create_gateway_groups.exs @@ -0,0 +1,29 @@ +defmodule Domain.Repo.Migrations.CreateGatewayGroups do + use Ecto.Migration + + def change do + create table(:gateway_groups, primary_key: false) do + add(:id, :uuid, primary_key: true) + + add(:name_prefix, :string, null: false) + + add(:tags, {:array, :string}, null: false, default: []) + + add(:account_id, references(:accounts, type: :binary_id), null: false) + + add(:deleted_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec) + end + + # Used to match by tags + execute("CREATE INDEX gateway_group_tags_idx on gateway_groups USING GIN (tags)") + + # Used to enforce unique names + create( + index(:gateway_groups, [:account_id, :name_prefix], + unique: true, + where: "deleted_at IS NULL" + ) + ) + end +end diff --git a/apps/domain/priv/repo/migrations/20230405181923_create_gateway_tokens.exs b/apps/domain/priv/repo/migrations/20230405181923_create_gateway_tokens.exs new file mode 100644 index 000000000..bed7409f7 --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230405181923_create_gateway_tokens.exs @@ -0,0 +1,27 @@ +defmodule Domain.Repo.Migrations.CreateGatewayTokens do + use Ecto.Migration + + def change do + create table(:gateway_tokens, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:hash, :string) + + add(:account_id, references(:accounts, type: :binary_id), null: false) + add(:group_id, references(:gateway_groups, type: :binary_id), null: false) + + add(:deleted_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + create( + constraint(:gateway_tokens, :hash_not_null, + check: """ + (hash is NOT NULL AND deleted_at IS NULL) + OR (hash is NULL AND deleted_at IS NOT NULL) + """ + ) + ) + + create(index(:gateway_tokens, [:group_id], where: "deleted_at IS NULL")) + end +end diff --git a/apps/domain/priv/repo/migrations/20230405181924_create_gateways.exs b/apps/domain/priv/repo/migrations/20230405181924_create_gateways.exs new file mode 100644 index 000000000..9bfab99b9 --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230405181924_create_gateways.exs @@ -0,0 +1,68 @@ +defmodule Domain.Repo.Migrations.CreateGateways do + use Ecto.Migration + + def change do + create table(:gateways, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:external_id, :string, null: false) + + add(:name_suffix, :string, null: false) + + add(:public_key, :string, null: false) + + add( + :ipv4, + references(:network_addresses, + column: :address, + type: :inet, + with: [account_id: :account_id] + ) + ) + + add( + :ipv6, + references(:network_addresses, + column: :address, + type: :inet, + with: [account_id: :account_id] + ) + ) + + add(:last_seen_user_agent, :string, null: false) + add(:last_seen_remote_ip, :inet, null: false) + add(:last_seen_version, :string, null: false) + add(:last_seen_at, :utc_datetime_usec, null: false) + + add(:account_id, references(:accounts, type: :binary_id), null: false) + add(:token_id, references(:gateway_tokens, type: :binary_id), null: false) + add(:group_id, references(:gateway_groups, type: :binary_id), null: false) + + add(:deleted_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec) + end + + # Used for upserts. + create( + index(:gateways, [:account_id, :group_id, :external_id], + unique: true, + where: "deleted_at IS NULL" + ) + ) + + # Used to enforce unique IPv4 and IPv6 addresses. + create(index(:gateways, [:account_id, :ipv4], unique: true, where: "deleted_at IS NULL")) + create(index(:gateways, [:account_id, :ipv6], unique: true, where: "deleted_at IS NULL")) + + # Used to enforce unique names and public keys. + create( + index(:gateways, [:account_id, :group_id, :name_suffix], + unique: true, + where: "deleted_at IS NULL" + ) + ) + + create( + index(:gateways, [:account_id, :public_key], unique: true, where: "deleted_at IS NULL") + ) + end +end diff --git a/apps/domain/priv/repo/migrations/20230405182922_create_relay_groups.exs b/apps/domain/priv/repo/migrations/20230405182922_create_relay_groups.exs new file mode 100644 index 000000000..eb0462c80 --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230405182922_create_relay_groups.exs @@ -0,0 +1,19 @@ +defmodule Domain.Repo.Migrations.CreateRelayGroups do + use Ecto.Migration + + def change do + create table(:relay_groups, primary_key: false) do + add(:id, :uuid, primary_key: true) + + add(:name, :string, null: false) + + add(:account_id, references(:accounts, type: :binary_id), null: false) + + add(:deleted_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec) + end + + # Used to enforce unique names + create(index(:relay_groups, [:account_id, :name], unique: true, where: "deleted_at IS NULL")) + end +end diff --git a/apps/domain/priv/repo/migrations/20230405182923_create_relay_tokens.exs b/apps/domain/priv/repo/migrations/20230405182923_create_relay_tokens.exs new file mode 100644 index 000000000..c85096471 --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230405182923_create_relay_tokens.exs @@ -0,0 +1,27 @@ +defmodule Domain.Repo.Migrations.CreateRelayTokens do + use Ecto.Migration + + def change do + create table(:relay_tokens, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:hash, :string) + + add(:account_id, references(:accounts, type: :binary_id), null: false) + add(:group_id, references(:relay_groups, type: :binary_id), null: false) + + add(:deleted_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + create( + constraint(:relay_tokens, :hash_not_null, + check: """ + (hash is NOT NULL AND deleted_at IS NULL) + OR (hash is NULL AND deleted_at IS NOT NULL) + """ + ) + ) + + create(index(:relay_tokens, [:group_id], where: "deleted_at IS NULL")) + end +end diff --git a/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs b/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs new file mode 100644 index 000000000..9055bd298 --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs @@ -0,0 +1,45 @@ +defmodule Domain.Repo.Migrations.CreateRelays do + use Ecto.Migration + + def change do + create table(:relays, primary_key: false) do + add(:id, :uuid, primary_key: true) + + add(:ipv4, :inet) + add(:ipv6, :inet) + + add(:last_seen_user_agent, :string, null: false) + add(:last_seen_remote_ip, :inet, null: false) + add(:last_seen_version, :string, null: false) + add(:last_seen_at, :utc_datetime_usec, null: false) + + add(:account_id, references(:accounts, type: :binary_id)) + add(:token_id, references(:relay_tokens, type: :binary_id), null: false) + add(:group_id, references(:relay_groups, type: :binary_id), null: false) + + add(:deleted_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec) + end + + execute(""" + CREATE UNIQUE INDEX relays_unique_addresses_idx + ON relays (account_id, COALESCE(ipv4, ipv6)) + WHERE deleted_at IS NULL + """) + + # Used to enforce unique IPv4 and IPv6 addresses. + create( + index(:relays, [:account_id, :ipv4], + unique: true, + where: "deleted_at IS NULL AND ipv4 IS NOT NULL" + ) + ) + + create( + index(:relays, [:account_id, :ipv6], + unique: true, + where: "deleted_at IS NULL AND ipv6 IS NOT NULL" + ) + ) + end +end diff --git a/apps/domain/test/domain/clients_test.exs b/apps/domain/test/domain/clients_test.exs new file mode 100644 index 000000000..a933ffd01 --- /dev/null +++ b/apps/domain/test/domain/clients_test.exs @@ -0,0 +1,663 @@ +defmodule Domain.ClientsTest do + use Domain.DataCase, async: true + import Domain.Clients + alias Domain.AccountsFixtures + alias Domain.{NetworkFixtures, UsersFixtures, SubjectFixtures, ClientsFixtures} + alias Domain.Clients + + setup do + account = AccountsFixtures.create_account() + unprivileged_user = UsersFixtures.create_user_with_role(:unprivileged, account: account) + unprivileged_subject = SubjectFixtures.create_subject(unprivileged_user) + + admin_user = UsersFixtures.create_user_with_role(:admin, account: account) + admin_subject = SubjectFixtures.create_subject(admin_user) + + %{ + account: account, + unprivileged_user: unprivileged_user, + unprivileged_subject: unprivileged_subject, + admin_user: admin_user, + admin_subject: admin_subject + } + end + + describe "count_by_account_id/0" do + test "counts clients for an account", %{account: account} do + ClientsFixtures.create_client(account: account) + ClientsFixtures.create_client(account: account) + ClientsFixtures.create_client(account: account) + ClientsFixtures.create_client() + + assert count_by_account_id(account.id) == 3 + end + end + + describe "count_by_user_id/1" do + test "returns 0 if user does not exist" do + assert count_by_user_id(Ecto.UUID.generate()) == 0 + end + + test "returns count of clients for a user" do + client = ClientsFixtures.create_client() + assert count_by_user_id(client.user_id) == 1 + end + end + + describe "fetch_client_by_id/2" do + test "returns error when UUID is invalid", %{unprivileged_subject: subject} do + assert fetch_client_by_id("foo", subject) == {:error, :not_found} + end + + test "does not return deleted clients", %{ + unprivileged_user: user, + unprivileged_subject: subject + } do + client = + ClientsFixtures.create_client(user: user) + |> ClientsFixtures.delete_client() + + assert fetch_client_by_id(client.id, subject) == {:error, :not_found} + end + + test "returns client by id", %{unprivileged_user: user, unprivileged_subject: subject} do + client = ClientsFixtures.create_client(user: user) + assert fetch_client_by_id(client.id, subject) == {:ok, client} + end + + test "returns client that belongs to another user with manage permission", %{ + account: account, + unprivileged_subject: subject + } do + client = ClientsFixtures.create_client(account: account) + + subject = + subject + |> SubjectFixtures.remove_permissions() + |> SubjectFixtures.add_permission(Clients.Authorizer.manage_clients_permission()) + + assert fetch_client_by_id(client.id, subject) == {:ok, client} + end + + test "does not returns client that belongs to another account with manage permission", %{ + unprivileged_subject: subject + } do + client = ClientsFixtures.create_client() + + subject = + subject + |> SubjectFixtures.remove_permissions() + |> SubjectFixtures.add_permission(Clients.Authorizer.manage_clients_permission()) + + assert fetch_client_by_id(client.id, subject) == {:error, :not_found} + end + + test "does not return client that belongs to another user with manage_own permission", %{ + unprivileged_subject: subject + } do + client = ClientsFixtures.create_client() + + subject = + subject + |> SubjectFixtures.remove_permissions() + |> SubjectFixtures.add_permission(Clients.Authorizer.manage_own_clients_permission()) + + assert fetch_client_by_id(client.id, subject) == {:error, :not_found} + end + + test "returns error when client does not exist", %{unprivileged_subject: subject} do + assert fetch_client_by_id(Ecto.UUID.generate(), subject) == + {:error, :not_found} + end + + test "returns error when subject has no permission to view clients", %{ + unprivileged_subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert fetch_client_by_id(Ecto.UUID.generate(), subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, + [ + Clients.Authorizer.manage_clients_permission(), + Clients.Authorizer.manage_own_clients_permission() + ]} + ] + ]}} + end + end + + describe "list_clients/1" do + test "returns empty list when there are no clients", %{admin_subject: subject} do + assert list_clients(subject) == {:ok, []} + end + + test "does not list deleted clients", %{ + unprivileged_user: user, + unprivileged_subject: subject + } do + ClientsFixtures.create_client(user: user) + |> ClientsFixtures.delete_client() + + assert list_clients(subject) == {:ok, []} + end + + test "does not list clients in other accounts", %{ + unprivileged_subject: subject + } do + ClientsFixtures.create_client() + + assert list_clients(subject) == {:ok, []} + end + + test "shows all clients owned by a user for unprivileged subject", %{ + unprivileged_user: user, + admin_user: other_user, + unprivileged_subject: subject + } do + client = ClientsFixtures.create_client(user: user) + ClientsFixtures.create_client(user: other_user) + + assert list_clients(subject) == {:ok, [client]} + end + + test "shows all clients for admin subject", %{ + unprivileged_user: other_user, + admin_user: admin_user, + admin_subject: subject + } do + ClientsFixtures.create_client(user: admin_user) + ClientsFixtures.create_client(user: other_user) + + assert {:ok, clients} = list_clients(subject) + assert length(clients) == 2 + end + + test "returns error when subject has no permission to manage clients", %{ + unprivileged_subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert list_clients(subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, + [ + Clients.Authorizer.manage_clients_permission(), + Clients.Authorizer.manage_own_clients_permission() + ]} + ] + ]}} + end + end + + describe "list_clients_by_user_id/2" do + test "returns empty list when there are no clients for a given user", %{ + admin_user: user, + admin_subject: subject + } do + assert list_clients_by_user_id(Ecto.UUID.generate(), subject) == {:ok, []} + assert list_clients_by_user_id(user.id, subject) == {:ok, []} + ClientsFixtures.create_client() + assert list_clients_by_user_id(user.id, subject) == {:ok, []} + end + + test "returns error when user id is invalid", %{admin_subject: subject} do + assert list_clients_by_user_id("foo", subject) == {:error, :not_found} + end + + test "does not list deleted clients", %{ + unprivileged_user: user, + unprivileged_subject: subject + } do + ClientsFixtures.create_client(user: user) + |> ClientsFixtures.delete_client() + + assert list_clients_by_user_id(user.id, subject) == {:ok, []} + end + + test "does not deleted clients for users in other accounts", %{ + unprivileged_subject: unprivileged_subject, + admin_subject: admin_subject + } do + user = UsersFixtures.create_user_with_role(:unprivileged) + ClientsFixtures.create_client(user: user) + + assert list_clients_by_user_id(user.id, unprivileged_subject) == {:ok, []} + assert list_clients_by_user_id(user.id, admin_subject) == {:ok, []} + end + + test "shows only clients owned by a user for unprivileged subject", %{ + unprivileged_user: user, + admin_user: other_user, + unprivileged_subject: subject + } do + client = ClientsFixtures.create_client(user: user) + ClientsFixtures.create_client(user: other_user) + + assert list_clients_by_user_id(user.id, subject) == {:ok, [client]} + assert list_clients_by_user_id(other_user.id, subject) == {:ok, []} + end + + test "shows all clients owned by another user for admin subject", %{ + unprivileged_user: other_user, + admin_user: admin_user, + admin_subject: subject + } do + ClientsFixtures.create_client(user: admin_user) + ClientsFixtures.create_client(user: other_user) + + assert {:ok, [_client]} = list_clients_by_user_id(admin_user.id, subject) + assert {:ok, [_client]} = list_clients_by_user_id(other_user.id, subject) + end + + test "returns error when subject has no permission to manage clients", %{ + unprivileged_subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert list_clients_by_user_id(Ecto.UUID.generate(), subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, + [ + Clients.Authorizer.manage_clients_permission(), + Clients.Authorizer.manage_own_clients_permission() + ]} + ] + ]}} + end + end + + describe "change_client/1" do + test "returns changeset with given changes", %{admin_user: user} do + client = ClientsFixtures.create_client(user: user) + client_attrs = ClientsFixtures.client_attrs() + + assert changeset = change_client(client, client_attrs) + assert %Ecto.Changeset{data: %Domain.Clients.Client{}} = changeset + + assert changeset.changes == %{name: client_attrs.name} + end + end + + describe "upsert_client/2" do + test "returns errors on invalid attrs", %{ + admin_subject: subject + } do + attrs = %{ + external_id: nil, + public_key: "x", + preshared_key: "x", + ipv4: "1.1.1.256", + ipv6: "fd01::10000" + } + + assert {:error, changeset} = upsert_client(attrs, subject) + + assert errors_on(changeset) == %{ + preshared_key: ["should be 44 character(s)", "must be a base64-encoded string"], + public_key: ["should be 44 character(s)", "must be a base64-encoded string"], + external_id: ["can't be blank"] + } + end + + test "allows creating client with just required attributes", %{ + admin_user: user, + admin_subject: subject + } do + attrs = + ClientsFixtures.client_attrs() + |> Map.delete(:name) + + assert {:ok, client} = upsert_client(attrs, subject) + + assert client.name + + assert client.public_key == attrs.public_key + assert client.preshared_key == attrs.preshared_key + + assert client.user_id == user.id + assert client.account_id == user.account_id + + refute is_nil(client.ipv4) + refute is_nil(client.ipv6) + + assert client.last_seen_remote_ip == %Postgrex.INET{address: subject.context.remote_ip} + assert client.last_seen_user_agent == subject.context.user_agent + assert client.last_seen_version == "0.7.412" + assert client.last_seen_at + end + + test "updates client when it already exists", %{ + admin_subject: subject + } do + client = ClientsFixtures.create_client(subject: subject) + attrs = ClientsFixtures.client_attrs(external_id: client.external_id) + + subject = %{ + subject + | context: %Domain.Auth.Context{ + subject.context + | remote_ip: {100, 64, 100, 101}, + user_agent: "iOS/12.5 (iPhone) connlib/0.7.411" + } + } + + assert {:ok, updated_client} = upsert_client(attrs, subject) + + assert Repo.aggregate(Clients.Client, :count, :id) == 1 + + assert updated_client.name + assert updated_client.last_seen_remote_ip.address == subject.context.remote_ip + assert updated_client.last_seen_remote_ip != client.last_seen_remote_ip + assert updated_client.last_seen_user_agent == subject.context.user_agent + assert updated_client.last_seen_user_agent != client.last_seen_user_agent + assert updated_client.last_seen_version == "0.7.411" + assert updated_client.public_key != client.public_key + assert updated_client.public_key == attrs.public_key + assert updated_client.preshared_key != client.preshared_key + assert updated_client.preshared_key == attrs.preshared_key + + assert updated_client.user_id == client.user_id + assert updated_client.ipv4 == client.ipv4 + assert updated_client.ipv6 == client.ipv6 + assert updated_client.last_seen_at + assert updated_client.last_seen_at != client.last_seen_at + end + + test "does not reserve additional addresses on update", %{ + admin_subject: subject + } do + client = ClientsFixtures.create_client(subject: subject) + + attrs = + ClientsFixtures.client_attrs( + external_id: client.external_id, + last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411", + last_seen_remote_ip: %Postgrex.INET{address: {100, 64, 100, 100}} + ) + + assert {:ok, updated_client} = upsert_client(attrs, subject) + + addresses = + Domain.Network.Address + |> Repo.all() + |> Enum.map(fn %Domain.Network.Address{address: address, type: type} -> + %{address: address, type: type} + end) + + assert length(addresses) == 2 + assert %{address: updated_client.ipv4, type: :ipv4} in addresses + assert %{address: updated_client.ipv6, type: :ipv6} in addresses + end + + test "allows unprivileged user to create a client for himself", %{ + admin_subject: subject + } do + attrs = + ClientsFixtures.client_attrs() + |> Map.delete(:name) + + assert {:ok, _client} = upsert_client(attrs, subject) + end + + test "does not allow to reuse IP addresses", %{ + account: account, + admin_subject: subject + } do + attrs = ClientsFixtures.client_attrs(account: account) + assert {:ok, client} = upsert_client(attrs, subject) + + addresses = + Domain.Network.Address + |> Repo.all() + |> Enum.map(fn %Domain.Network.Address{address: address, type: type} -> + %{address: address, type: type} + end) + + assert length(addresses) == 2 + assert %{address: client.ipv4, type: :ipv4} in addresses + assert %{address: client.ipv6, type: :ipv6} in addresses + + assert_raise Ecto.ConstraintError, fn -> + NetworkFixtures.create_address(address: client.ipv4, account: account) + end + end + + test "ip addresses are unique per account", %{ + account: account, + admin_subject: subject + } do + attrs = ClientsFixtures.client_attrs(account: account) + assert {:ok, client} = upsert_client(attrs, subject) + + assert %Domain.Network.Address{} = NetworkFixtures.create_address(address: client.ipv4) + assert %Domain.Network.Address{} = NetworkFixtures.create_address(address: client.ipv6) + end + + test "returns error when subject has no permission to create clients", %{ + admin_subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert upsert_client(%{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}} + end + end + + describe "update_client/3" do + test "allows admin user to update own clients", %{admin_user: user, admin_subject: subject} do + client = ClientsFixtures.create_client(user: user) + attrs = %{name: "new name"} + + assert {:ok, client} = update_client(client, attrs, subject) + + assert client.name == attrs.name + end + + test "allows admin user to update other users clients", %{ + account: account, + admin_subject: subject + } do + client = ClientsFixtures.create_client(account: account) + attrs = %{name: "new name"} + + assert {:ok, client} = update_client(client, attrs, subject) + + assert client.name == attrs.name + end + + test "allows unprivileged user to update own clients", %{ + unprivileged_user: user, + unprivileged_subject: subject + } do + client = ClientsFixtures.create_client(user: user) + attrs = %{name: "new name"} + + assert {:ok, client} = update_client(client, attrs, subject) + + assert client.name == attrs.name + end + + test "does not allow unprivileged user to update other users clients", %{ + account: account, + unprivileged_subject: subject + } do + client = ClientsFixtures.create_client(account: account) + attrs = %{name: "new name"} + + assert update_client(client, attrs, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + end + + test "does not allow admin user to update clients in other accounts", %{ + admin_subject: subject + } do + client = ClientsFixtures.create_client() + attrs = %{name: "new name"} + + assert update_client(client, attrs, subject) == {:error, :not_found} + end + + test "does not allow to reset required fields to empty values", %{ + admin_user: user, + admin_subject: subject + } do + client = ClientsFixtures.create_client(user: user) + attrs = %{name: nil, public_key: nil} + + assert {:error, changeset} = update_client(client, attrs, subject) + + assert errors_on(changeset) == %{name: ["can't be blank"]} + end + + test "returns error on invalid attrs", %{admin_user: user, admin_subject: subject} do + client = ClientsFixtures.create_client(user: user) + + attrs = %{ + name: String.duplicate("a", 256) + } + + assert {:error, changeset} = update_client(client, attrs, subject) + + assert errors_on(changeset) == %{ + name: ["should be at most 255 character(s)"] + } + end + + test "ignores updates for any field except name", %{ + admin_user: user, + admin_subject: subject + } do + client = ClientsFixtures.create_client(user: user) + + fields = Clients.Client.__schema__(:fields) -- [:name] + value = -1 + + for field <- fields do + assert {:ok, updated_client} = update_client(client, %{field => value}, subject) + assert updated_client == client + end + end + + test "returns error when subject has no permission to update clients", %{ + admin_user: user, + admin_subject: subject + } do + client = ClientsFixtures.create_client(user: user) + + subject = SubjectFixtures.remove_permissions(subject) + + assert update_client(client, %{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}} + + client = ClientsFixtures.create_client() + + assert update_client(client, %{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + end + end + + describe "delete_client/2" do + test "returns error on state conflict", %{admin_user: user, admin_subject: subject} do + client = ClientsFixtures.create_client(user: user) + + assert {:ok, deleted} = delete_client(client, subject) + assert delete_client(deleted, subject) == {:error, :not_found} + assert delete_client(client, subject) == {:error, :not_found} + end + + test "admin can delete own clients", %{admin_user: user, admin_subject: subject} do + client = ClientsFixtures.create_client(user: user) + + assert {:ok, deleted} = delete_client(client, subject) + assert deleted.deleted_at + end + + test "admin can delete other people clients", %{ + unprivileged_user: user, + admin_subject: subject + } do + client = ClientsFixtures.create_client(user: user) + + assert {:ok, deleted} = delete_client(client, subject) + assert deleted.deleted_at + end + + test "admin can not delete clients in other accounts", %{ + admin_subject: subject + } do + client = ClientsFixtures.create_client() + + assert delete_client(client, subject) == {:error, :not_found} + end + + test "unprivileged can delete own clients", %{ + unprivileged_user: user, + unprivileged_subject: subject + } do + client = ClientsFixtures.create_client(user: user) + + assert {:ok, deleted} = delete_client(client, subject) + assert deleted.deleted_at + end + + test "unprivileged can not delete other people clients", %{ + account: account, + unprivileged_subject: subject + } do + client = ClientsFixtures.create_client() + + assert delete_client(client, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + + client = ClientsFixtures.create_client(account: account) + + assert delete_client(client, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + + assert Repo.aggregate(Clients.Client, :count) == 2 + end + + test "returns error when subject has no permission to delete clients", %{ + admin_user: user, + admin_subject: subject + } do + client = ClientsFixtures.create_client(user: user) + + subject = SubjectFixtures.remove_permissions(subject) + + assert delete_client(client, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}} + + client = ClientsFixtures.create_client() + + assert delete_client(client, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + end + end +end diff --git a/apps/domain/test/domain/devices/device/query_test.exs b/apps/domain/test/domain/devices/device/query_test.exs deleted file mode 100644 index 972582c6f..000000000 --- a/apps/domain/test/domain/devices/device/query_test.exs +++ /dev/null @@ -1,179 +0,0 @@ -defmodule Domain.Devices.Device.QueryTest do - use Domain.DataCase, async: true - import Domain.Devices.Device.Query - alias Domain.DevicesFixtures - - describe "next_available_address/3" do - test "selects available IPv4 in CIDR range at the offset" do - cidr = string_to_cidr("10.3.2.0/29") - Domain.Config.put_env_override(:wireguard_ipv4_network, cidr) - gateway_ip = string_to_ip("10.3.2.0") - offset = 3 - - queryable = next_available_address(cidr, offset, [gateway_ip]) - - assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 2, 3}} - end - - test "skips addresses taken by the gateway" do - cidr = string_to_cidr("10.3.3.0/29") - Domain.Config.put_env_override(:wireguard_ipv4_network, cidr) - gateway_ip = string_to_ip("10.3.3.3") - offset = 3 - - queryable = next_available_address(cidr, offset, [gateway_ip]) - - assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 3, 4}} - end - - test "forward scans available address after offset it it's assigned to a device" do - cidr = string_to_cidr("10.3.4.0/29") - Domain.Config.put_env_override(:wireguard_ipv4_network, cidr) - gateway_ip = string_to_ip("10.3.4.0") - offset = 3 - - queryable = next_available_address(cidr, offset, [gateway_ip]) - - DevicesFixtures.create_device(%{ipv4: "10.3.4.3"}) - DevicesFixtures.create_device(%{ipv4: "10.3.4.4"}) - assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 5}} - - DevicesFixtures.create_device(%{ipv4: "10.3.4.5"}) - assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 6}} - end - - test "backward scans available address if forward scan found not available IPs" do - cidr = string_to_cidr("10.3.5.0/29") - Domain.Config.put_env_override(:wireguard_ipv4_network, cidr) - gateway_ip = string_to_ip("10.3.5.0") - offset = 5 - - queryable = next_available_address(cidr, offset, [gateway_ip]) - - DevicesFixtures.create_device(%{ipv4: "10.3.5.5"}) - DevicesFixtures.create_device(%{ipv4: "10.3.5.6"}) - # Notice: end of range is 10.3.5.7 - # but it's a broadcast address that we don't allow to assign - assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 4}} - - DevicesFixtures.create_device(%{ipv4: "10.3.5.4"}) - assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 3}} - end - - test "selects nothing when CIDR range is exhausted" do - cidr = string_to_cidr("10.3.6.0/30") - Domain.Config.put_env_override(:wireguard_ipv4_network, cidr) - gateway_ip = string_to_ip("10.3.6.1") - offset = 1 - - DevicesFixtures.create_device(%{ipv4: "10.3.6.2"}) - queryable = next_available_address(cidr, offset, [gateway_ip]) - assert is_nil(Repo.one(queryable)) - - DevicesFixtures.create_device(%{ipv4: "10.3.6.1"}) - queryable = next_available_address(cidr, offset, []) - assert is_nil(Repo.one(queryable)) - - # Notice: real start of range is 10.3.6.0, - # but it's a typical gateway address that we don't allow to assign - end - - test "prevents two concurrent transactions from acquiring the same address" do - cidr = string_to_cidr("10.3.7.0/29") - Domain.Config.put_env_override(:wireguard_ipv4_network, cidr) - gateway_ip = string_to_ip("10.3.7.3") - offset = 3 - - queryable = next_available_address(cidr, offset, [gateway_ip]) - - test_pid = self() - - spawn(fn -> - Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn -> - Repo.transaction(fn -> - ip = Repo.one(queryable) - send(test_pid, {:ip, ip}) - Process.sleep(200) - end) - end) - end) - - ip1 = Repo.one(queryable) - assert_receive {:ip, ip2}, 1_000 - - assert Enum.sort([ip1, ip2]) == - Enum.sort([ - %Postgrex.INET{address: {10, 3, 7, 4}}, - %Postgrex.INET{address: {10, 3, 7, 5}} - ]) - end - - test "selects available IPv6 in CIDR range at the offset" do - cidr = string_to_cidr("fd00::3:3:0/120") - Domain.Config.put_env_override(:wireguard_ipv6_network, cidr) - gateway_ip = string_to_ip("fd00::3:3:3") - offset = 3 - - queryable = next_available_address(cidr, offset, [gateway_ip]) - - assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 3, 4}} - end - - test "selects available IPv6 at end of CIDR range" do - cidr = string_to_cidr("fd00::/106") - Domain.Config.put_env_override(:wireguard_ipv6_network, cidr) - gateway_ip = string_to_ip("fd00::3:3:3") - offset = 4_194_304 - - queryable = next_available_address(cidr, offset, [gateway_ip]) - - assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 63, 65_535}} - end - - test "works when offset is out of IPv6 CIDR range" do - cidr = string_to_cidr("fd00::/106") - Domain.Config.put_env_override(:wireguard_ipv6_network, cidr) - gateway_ip = string_to_ip("fd00::3:3:3") - offset = 4_194_305 - - queryable = next_available_address(cidr, offset, [gateway_ip]) - - assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 64, 0}} - end - - test "works when netmask allows a large number of devices" do - cidr = string_to_cidr("fd00::/70") - Domain.Config.put_env_override(:wireguard_ipv6_network, cidr) - gateway_ip = string_to_ip("fd00::3:3:3") - offset = 9_223_372_036_854_775_807 - - queryable = next_available_address(cidr, offset, [gateway_ip]) - - assert Repo.one(queryable) == %Postgrex.INET{ - address: {64_768, 0, 0, 0, 32_767, 65_535, 65_535, 65_534} - } - end - - test "selects nothing when IPv6 CIDR range is exhausted" do - cidr = string_to_cidr("fd00::3:2:0/126") - Domain.Config.put_env_override(:wireguard_ipv6_network, cidr) - gateway_ip = string_to_ip("fd00::3:2:1") - offset = 3 - - DevicesFixtures.create_device(%{ipv6: "fd00::3:2:2"}) - - queryable = next_available_address(cidr, offset, [gateway_ip]) - assert is_nil(Repo.one(queryable)) - end - end - - defp string_to_cidr(string) do - {:ok, inet} = Domain.Types.CIDR.cast(string) - inet - end - - defp string_to_ip(string) do - {:ok, inet} = Domain.Types.IP.cast(string) - inet - end -end diff --git a/apps/domain/test/domain/gateways_test.exs b/apps/domain/test/domain/gateways_test.exs new file mode 100644 index 000000000..022d6ad8f --- /dev/null +++ b/apps/domain/test/domain/gateways_test.exs @@ -0,0 +1,683 @@ +defmodule Domain.GatewaysTest do + use Domain.DataCase, async: true + import Domain.Gateways + alias Domain.AccountsFixtures + alias Domain.{NetworkFixtures, UsersFixtures, SubjectFixtures, GatewaysFixtures} + alias Domain.Gateways + + setup do + account = AccountsFixtures.create_account() + user = UsersFixtures.create_user_with_role(:admin, account: account) + subject = SubjectFixtures.create_subject(user) + + %{ + account: account, + user: user, + subject: subject + } + end + + describe "fetch_group_by_id/2" do + test "returns error when UUID is invalid", %{subject: subject} do + assert fetch_group_by_id("foo", subject) == {:error, :not_found} + end + + test "does not return groups from other accounts", %{ + subject: subject + } do + group = GatewaysFixtures.create_group() + assert fetch_group_by_id(group.id, subject) == {:error, :not_found} + end + + test "does not return deleted groups", %{ + account: account, + subject: subject + } do + group = + GatewaysFixtures.create_group(account: account) + |> GatewaysFixtures.delete_group() + + assert fetch_group_by_id(group.id, subject) == {:error, :not_found} + end + + test "returns group by id", %{account: account, subject: subject} do + group = GatewaysFixtures.create_group(account: account) + assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject) + assert fetched_group.id == group.id + end + + test "returns group that belongs to another user", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account) + assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject) + assert fetched_group.id == group.id + end + + test "returns error when group does not exist", %{subject: subject} do + assert fetch_group_by_id(Ecto.UUID.generate(), subject) == + {:error, :not_found} + end + + test "returns error when subject has no permission to view groups", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert fetch_group_by_id(Ecto.UUID.generate(), subject) == + {:error, + {:unauthorized, + [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + end + end + + describe "list_groups/1" do + test "returns empty list when there are no groups", %{subject: subject} do + assert list_groups(subject) == {:ok, []} + end + + test "does not list groups from other accounts", %{ + subject: subject + } do + GatewaysFixtures.create_group() + assert list_groups(subject) == {:ok, []} + end + + test "does not list deleted groups", %{ + account: account, + subject: subject + } do + GatewaysFixtures.create_group(account: account) + |> GatewaysFixtures.delete_group() + + assert list_groups(subject) == {:ok, []} + end + + test "returns all groups", %{ + account: account, + subject: subject + } do + GatewaysFixtures.create_group(account: account) + GatewaysFixtures.create_group(account: account) + GatewaysFixtures.create_group() + + assert {:ok, groups} = list_groups(subject) + assert length(groups) == 2 + end + + test "returns error when subject has no permission to manage groups", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert list_groups(subject) == + {:error, + {:unauthorized, + [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + end + end + + describe "new_group/0" do + test "returns group changeset" do + assert %Ecto.Changeset{data: %Gateways.Group{}, changes: changes} = new_group() + assert Map.has_key?(changes, :name_prefix) + assert Enum.count(changes) == 1 + end + end + + describe "create_group/2" do + test "returns error on empty attrs", %{subject: subject} do + assert {:error, changeset} = create_group(%{}, subject) + assert errors_on(changeset) == %{tokens: ["can't be blank"]} + end + + test "returns error on invalid attrs", %{account: account, subject: subject} do + attrs = %{ + name_prefix: String.duplicate("A", 65), + tags: Enum.map(1..129, &Integer.to_string/1) + } + + assert {:error, changeset} = create_group(attrs, subject) + + assert errors_on(changeset) == %{ + tokens: ["can't be blank"], + name_prefix: ["should be at most 64 character(s)"], + tags: ["should have at most 128 item(s)"] + } + + attrs = %{tags: ["A", "B", "A"]} + assert {:error, changeset} = create_group(attrs, subject) + assert "should not contain duplicates" in errors_on(changeset).tags + + attrs = %{tags: [String.duplicate("A", 65)]} + assert {:error, changeset} = create_group(attrs, subject) + assert "should be at most 64 characters long" in errors_on(changeset).tags + + GatewaysFixtures.create_group(account: account, name_prefix: "foo") + attrs = %{name_prefix: "foo", tokens: [%{}]} + assert {:error, changeset} = create_group(attrs, subject) + assert "has already been taken" in errors_on(changeset).name_prefix + end + + test "creates a group", %{subject: subject} do + attrs = %{ + name_prefix: "foo", + tags: ["bar"], + tokens: [%{}] + } + + assert {:ok, group} = create_group(attrs, subject) + assert group.id + assert group.name_prefix == "foo" + assert group.tags == ["bar"] + assert [%Gateways.Token{}] = group.tokens + end + + test "returns error when subject has no permission to manage groups", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert create_group(%{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + end + end + + describe "change_group/1" do + test "returns changeset with given changes" do + group = GatewaysFixtures.create_group() + + group_attrs = + GatewaysFixtures.group_attrs() + |> Map.delete(:tokens) + + assert changeset = change_group(group, group_attrs) + assert changeset.valid? + assert changeset.changes == %{name_prefix: group_attrs.name_prefix, tags: group_attrs.tags} + end + end + + describe "update_group/3" do + test "does not allow to reset required fields to empty values", %{ + subject: subject + } do + group = GatewaysFixtures.create_group() + attrs = %{name_prefix: nil} + + assert {:error, changeset} = update_group(group, attrs, subject) + + assert errors_on(changeset) == %{name_prefix: ["can't be blank"]} + end + + test "returns error on invalid attrs", %{account: account, subject: subject} do + group = GatewaysFixtures.create_group(account: account) + + attrs = %{ + name_prefix: String.duplicate("A", 65), + tags: Enum.map(1..129, &Integer.to_string/1) + } + + assert {:error, changeset} = update_group(group, attrs, subject) + + assert errors_on(changeset) == %{ + name_prefix: ["should be at most 64 character(s)"], + tags: ["should have at most 128 item(s)"] + } + + attrs = %{tags: ["A", "B", "A"]} + assert {:error, changeset} = update_group(group, attrs, subject) + assert "should not contain duplicates" in errors_on(changeset).tags + + attrs = %{tags: [String.duplicate("A", 65)]} + assert {:error, changeset} = update_group(group, attrs, subject) + assert "should be at most 64 characters long" in errors_on(changeset).tags + + GatewaysFixtures.create_group(account: account, name_prefix: "foo") + attrs = %{name_prefix: "foo"} + assert {:error, changeset} = update_group(group, attrs, subject) + assert "has already been taken" in errors_on(changeset).name_prefix + end + + test "updates a group", %{account: account, subject: subject} do + group = GatewaysFixtures.create_group(account: account) + + attrs = %{ + name_prefix: "foo", + tags: ["bar"] + } + + assert {:ok, group} = update_group(group, attrs, subject) + assert group.name_prefix == "foo" + assert group.tags == ["bar"] + end + + test "returns error when subject has no permission to manage groups", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account) + + subject = SubjectFixtures.remove_permissions(subject) + + assert update_group(group, %{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + end + end + + describe "delete_group/2" do + test "returns error on state conflict", %{account: account, subject: subject} do + group = GatewaysFixtures.create_group(account: account) + + assert {:ok, deleted} = delete_group(group, subject) + assert delete_group(deleted, subject) == {:error, :not_found} + assert delete_group(group, subject) == {:error, :not_found} + end + + test "deletes groups", %{account: account, subject: subject} do + group = GatewaysFixtures.create_group(account: account) + + assert {:ok, deleted} = delete_group(group, subject) + assert deleted.deleted_at + end + + test "deletes all tokens when group is deleted", %{account: account, subject: subject} do + group = GatewaysFixtures.create_group(account: account) + GatewaysFixtures.create_token(group: group) + GatewaysFixtures.create_token(group: [account: account]) + + assert {:ok, deleted} = delete_group(group, subject) + assert deleted.deleted_at + + tokens = + Gateways.Token + |> Repo.all() + |> Enum.filter(fn token -> token.group_id == group.id end) + + assert Enum.all?(tokens, & &1.deleted_at) + end + + test "returns error when subject has no permission to delete groups", %{ + subject: subject + } do + group = GatewaysFixtures.create_group() + + subject = SubjectFixtures.remove_permissions(subject) + + assert delete_group(group, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + end + end + + describe "use_token_by_id_and_secret/2" do + test "returns token when secret is valid" do + token = GatewaysFixtures.create_token() + assert {:ok, token} = use_token_by_id_and_secret(token.id, token.value) + assert is_nil(token.value) + # TODO: While we don't have token rotation implemented, the tokens are all multi-use + # assert is_nil(token.hash) + # refute is_nil(token.deleted_at) + end + + # TODO: While we don't have token rotation implemented, the tokens are all multi-use + # test "returns error when secret was already used" do + # token = GatewaysFixtures.create_token() + + # assert {:ok, _token} = use_token_by_id_and_secret(token.id, token.value) + # assert use_token_by_id_and_secret(token.id, token.value) == {:error, :not_found} + # end + + test "returns error when id is invalid" do + assert use_token_by_id_and_secret("foo", "bar") == {:error, :not_found} + end + + test "returns error when id is not found" do + assert use_token_by_id_and_secret(Ecto.UUID.generate(), "bar") == {:error, :not_found} + end + + test "returns error when secret is invalid" do + token = GatewaysFixtures.create_token() + assert use_token_by_id_and_secret(token.id, "bar") == {:error, :not_found} + end + end + + describe "fetch_gateway_by_id/2" do + test "returns error when UUID is invalid", %{subject: subject} do + assert fetch_gateway_by_id("foo", subject) == {:error, :not_found} + end + + test "does not return gateways from other accounts", %{ + subject: subject + } do + gateway = GatewaysFixtures.create_gateway() + assert fetch_gateway_by_id(gateway.id, subject) == {:error, :not_found} + end + + test "does not return deleted gateways", %{ + account: account, + subject: subject + } do + gateway = + GatewaysFixtures.create_gateway(account: account) + |> GatewaysFixtures.delete_gateway() + + assert fetch_gateway_by_id(gateway.id, subject) == {:error, :not_found} + end + + test "returns gateway by id", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + assert fetch_gateway_by_id(gateway.id, subject) == {:ok, gateway} + end + + test "returns gateway that belongs to another user", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + assert fetch_gateway_by_id(gateway.id, subject) == {:ok, gateway} + end + + test "returns error when gateway does not exist", %{subject: subject} do + assert fetch_gateway_by_id(Ecto.UUID.generate(), subject) == + {:error, :not_found} + end + + test "returns error when subject has no permission to view gateways", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert fetch_gateway_by_id(Ecto.UUID.generate(), subject) == + {:error, + {:unauthorized, + [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + end + end + + describe "list_gateways/1" do + test "returns empty list when there are no gateways", %{subject: subject} do + assert list_gateways(subject) == {:ok, []} + end + + test "does not list deleted gateways", %{ + subject: subject + } do + GatewaysFixtures.create_gateway() + |> GatewaysFixtures.delete_gateway() + + assert list_gateways(subject) == {:ok, []} + end + + test "returns all gateways", %{ + account: account, + subject: subject + } do + GatewaysFixtures.create_gateway(account: account) + GatewaysFixtures.create_gateway(account: account) + GatewaysFixtures.create_gateway() + + assert {:ok, gateways} = list_gateways(subject) + assert length(gateways) == 2 + end + + test "returns error when subject has no permission to manage gateways", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert list_gateways(subject) == + {:error, + {:unauthorized, + [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + end + end + + describe "change_gateway/1" do + test "returns changeset with given changes" do + gateway = GatewaysFixtures.create_gateway() + gateway_attrs = GatewaysFixtures.gateway_attrs() + + assert changeset = change_gateway(gateway, gateway_attrs) + assert %Ecto.Changeset{data: %Domain.Gateways.Gateway{}} = changeset + + assert changeset.changes == %{name_suffix: gateway_attrs.name_suffix} + end + end + + describe "upsert_gateway/3" do + setup context do + token = GatewaysFixtures.create_token(account: context.account) + + context + |> Map.put(:token, token) + |> Map.put(:group, token.group) + end + + test "returns errors on invalid attrs", %{ + token: token + } do + attrs = %{ + external_id: nil, + public_key: "x", + last_seen_user_agent: "foo", + last_seen_remote_ip: {256, 0, 0, 0} + } + + assert {:error, changeset} = upsert_gateway(token, attrs) + + assert errors_on(changeset) == %{ + public_key: ["should be 44 character(s)", "must be a base64-encoded string"], + external_id: ["can't be blank"], + last_seen_user_agent: ["is invalid"] + } + end + + test "allows creating gateway with just required attributes", %{ + token: token + } do + attrs = + GatewaysFixtures.gateway_attrs() + |> Map.delete(:name) + + assert {:ok, gateway} = upsert_gateway(token, attrs) + + assert gateway.name_suffix + assert gateway.public_key == attrs.public_key + + assert gateway.token_id == token.id + assert gateway.group_id == token.group_id + + refute is_nil(gateway.ipv4) + refute is_nil(gateway.ipv6) + + assert gateway.last_seen_remote_ip == attrs.last_seen_remote_ip + assert gateway.last_seen_user_agent == attrs.last_seen_user_agent + assert gateway.last_seen_version == "0.7.412" + assert gateway.last_seen_at + end + + test "updates gateway when it already exists", %{ + token: token + } do + gateway = GatewaysFixtures.create_gateway(token: token) + + attrs = + GatewaysFixtures.gateway_attrs( + external_id: gateway.external_id, + last_seen_remote_ip: {100, 64, 100, 101}, + last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411" + ) + + assert {:ok, updated_gateway} = upsert_gateway(token, attrs) + + assert Repo.aggregate(Gateways.Gateway, :count, :id) == 1 + + assert updated_gateway.name_suffix + assert updated_gateway.last_seen_remote_ip.address == attrs.last_seen_remote_ip + assert updated_gateway.last_seen_remote_ip != gateway.last_seen_remote_ip + assert updated_gateway.last_seen_user_agent == attrs.last_seen_user_agent + assert updated_gateway.last_seen_user_agent != gateway.last_seen_user_agent + assert updated_gateway.last_seen_version == "0.7.411" + assert updated_gateway.last_seen_at + assert updated_gateway.last_seen_at != gateway.last_seen_at + assert updated_gateway.public_key != gateway.public_key + assert updated_gateway.public_key == attrs.public_key + + assert updated_gateway.token_id == token.id + assert updated_gateway.group_id == token.group_id + + assert updated_gateway.ipv4 == gateway.ipv4 + assert updated_gateway.ipv6 == gateway.ipv6 + end + + test "does not reserve additional addresses on update", %{ + token: token + } do + gateway = GatewaysFixtures.create_gateway(token: token) + + attrs = + GatewaysFixtures.gateway_attrs( + external_id: gateway.external_id, + last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411", + last_seen_remote_ip: %Postgrex.INET{address: {100, 64, 100, 100}} + ) + + assert {:ok, updated_gateway} = upsert_gateway(token, attrs) + + addresses = + Domain.Network.Address + |> Repo.all() + |> Enum.map(fn %Domain.Network.Address{address: address, type: type} -> + %{address: address, type: type} + end) + + assert length(addresses) == 2 + assert %{address: updated_gateway.ipv4, type: :ipv4} in addresses + assert %{address: updated_gateway.ipv6, type: :ipv6} in addresses + end + + test "does not allow to reuse IP addresses", %{ + account: account, + token: token + } do + attrs = GatewaysFixtures.gateway_attrs() + assert {:ok, gateway} = upsert_gateway(token, attrs) + + addresses = + Domain.Network.Address + |> Repo.all() + |> Enum.map(fn %Domain.Network.Address{address: address, type: type} -> + %{address: address, type: type} + end) + + assert length(addresses) == 2 + assert %{address: gateway.ipv4, type: :ipv4} in addresses + assert %{address: gateway.ipv6, type: :ipv6} in addresses + + assert_raise Ecto.ConstraintError, fn -> + NetworkFixtures.create_address(account: account, address: gateway.ipv4) + end + end + end + + describe "update_gateway/3" do + test "updates gateways", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + attrs = %{name_suffix: "Foo"} + + assert {:ok, gateway} = update_gateway(gateway, attrs, subject) + + assert gateway.name_suffix == attrs.name_suffix + end + + test "does not allow to reset required fields to empty values", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + attrs = %{name_suffix: nil} + + assert {:error, changeset} = update_gateway(gateway, attrs, subject) + + assert errors_on(changeset) == %{name_suffix: ["can't be blank"]} + end + + test "returns error on invalid attrs", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + + attrs = %{ + name_suffix: String.duplicate("a", 256) + } + + assert {:error, changeset} = update_gateway(gateway, attrs, subject) + + assert errors_on(changeset) == %{ + name_suffix: ["should be at most 8 character(s)"] + } + end + + test "ignores updates for any field except name", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + + fields = Gateways.Gateway.__schema__(:fields) -- [:name_suffix] + value = -1 + + for field <- fields do + assert {:ok, updated_gateway} = update_gateway(gateway, %{field => value}, subject) + assert updated_gateway == gateway + end + end + + test "returns error when subject has no permission to update gateways", %{ + subject: subject + } do + gateway = GatewaysFixtures.create_gateway() + + subject = SubjectFixtures.remove_permissions(subject) + + assert update_gateway(gateway, %{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + end + end + + describe "delete_gateway/2" do + test "returns error on state conflict", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + + assert {:ok, deleted} = delete_gateway(gateway, subject) + assert delete_gateway(deleted, subject) == {:error, :not_found} + assert delete_gateway(gateway, subject) == {:error, :not_found} + end + + test "deletes gateways", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + + assert {:ok, deleted} = delete_gateway(gateway, subject) + assert deleted.deleted_at + end + + test "returns error when subject has no permission to delete gateways", %{ + subject: subject + } do + gateway = GatewaysFixtures.create_gateway() + + subject = SubjectFixtures.remove_permissions(subject) + + assert delete_gateway(gateway, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + end + end +end diff --git a/apps/domain/test/domain/network/address/query_test.exs b/apps/domain/test/domain/network/address/query_test.exs new file mode 100644 index 000000000..2ccd07e58 --- /dev/null +++ b/apps/domain/test/domain/network/address/query_test.exs @@ -0,0 +1,171 @@ +defmodule Domain.Network.Address.QueryTest do + use Domain.DataCase, async: true + import Domain.Network.Address.Query + alias Domain.{AccountsFixtures, NetworkFixtures} + + setup do + account = AccountsFixtures.create_account() + %{account: account} + end + + describe "next_available_address/3" do + test "selects available IPv4 in CIDR range at the offset", %{account: account} do + cidr = string_to_cidr("10.3.2.0/29") + offset = 3 + + queryable = next_available_address(account.id, cidr, offset) + + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 2, 3}} + end + + test "skips addresses that are already taken for an account", %{account: account} do + cidr = string_to_cidr("10.3.3.0/29") + offset = 3 + + queryable = next_available_address(account.id, cidr, offset) + NetworkFixtures.create_address(account: account, address: "10.3.3.3") + + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 3, 4}} + end + + test "addresses are unique per account", %{account: account} do + cidr = string_to_cidr("10.3.3.0/29") + offset = 3 + + queryable = next_available_address(account.id, cidr, offset) + NetworkFixtures.create_address(address: "10.3.3.3") + + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 3, 3}} + end + + test "forward scans available address after offset it it's assigned to a device", %{ + account: account + } do + cidr = string_to_cidr("10.3.4.0/29") + offset = 3 + + queryable = next_available_address(account.id, cidr, offset) + + NetworkFixtures.create_address(account: account, address: "10.3.4.3") + NetworkFixtures.create_address(account: account, address: "10.3.4.4") + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 5}} + + NetworkFixtures.create_address(account: account, address: "10.3.4.5") + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 6}} + end + + test "backward scans available address if forward scan found not available IPs", %{ + account: account + } do + cidr = string_to_cidr("10.3.5.0/29") + offset = 5 + + queryable = next_available_address(account.id, cidr, offset) + + NetworkFixtures.create_address(account: account, address: "10.3.5.5") + NetworkFixtures.create_address(account: account, address: "10.3.5.6") + # Notice: end of range is 10.3.5.7 + # but it's a broadcast address that we don't allow to assign + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 4}} + + NetworkFixtures.create_address(account: account, address: "10.3.5.4") + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 3}} + end + + test "selects nothing when CIDR range is exhausted", %{account: account} do + cidr = string_to_cidr("10.3.6.0/30") + offset = 1 + + NetworkFixtures.create_address(account: account, address: "10.3.6.1") + NetworkFixtures.create_address(account: account, address: "10.3.6.2") + queryable = next_available_address(account.id, cidr, offset) + assert is_nil(Repo.one(queryable)) + + # Notice: real start of range is 10.3.6.0, + # but it's a typical gateway address that we don't allow to assign + end + + test "prevents two concurrent transactions from acquiring the same address", %{ + account: account + } do + cidr = string_to_cidr("10.3.7.0/29") + offset = 3 + + queryable = next_available_address(account.id, cidr, offset) + + test_pid = self() + + spawn(fn -> + Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn -> + Repo.transaction(fn -> + ip = Repo.one(queryable) + send(test_pid, {:ip, ip}) + Process.sleep(200) + end) + end) + end) + + ip1 = Repo.one(queryable) + assert_receive {:ip, ip2}, 1_000 + + assert Enum.sort([ip1, ip2]) == + Enum.sort([ + %Postgrex.INET{address: {10, 3, 7, 3}}, + %Postgrex.INET{address: {10, 3, 7, 4}} + ]) + end + + test "selects available IPv6 in CIDR range at the offset", %{account: account} do + cidr = string_to_cidr("fd00::3:3:0/120") + offset = 3 + + queryable = next_available_address(account.id, cidr, offset) + + assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 3, 3}} + end + + test "selects available IPv6 at end of CIDR range", %{account: account} do + cidr = string_to_cidr("fd00::/106") + offset = 4_194_304 + + queryable = next_available_address(account.id, cidr, offset) + + assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 63, 65_535}} + end + + test "works when offset is out of IPv6 CIDR range", %{account: account} do + cidr = string_to_cidr("fd00::/106") + offset = 4_194_305 + + queryable = next_available_address(account.id, cidr, offset) + + assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 64, 0}} + end + + test "works when netmask allows a large number of devices", %{account: account} do + cidr = string_to_cidr("fd00::/70") + offset = 9_223_372_036_854_775_807 + + queryable = next_available_address(account.id, cidr, offset) + + assert Repo.one(queryable) == %Postgrex.INET{ + address: {64_768, 0, 0, 0, 32_767, 65_535, 65_535, 65_534} + } + end + + test "selects nothing when IPv6 CIDR range is exhausted", %{account: account} do + cidr = string_to_cidr("fd00::3:2:0/126") + offset = 3 + + NetworkFixtures.create_address(account: account, address: "fd00::3:2:2") + + queryable = next_available_address(account.id, cidr, offset) + assert is_nil(Repo.one(queryable)) + end + end + + defp string_to_cidr(string) do + {:ok, inet} = Domain.Types.CIDR.cast(string) + inet + end +end diff --git a/apps/domain/test/domain/network_test.exs b/apps/domain/test/domain/network_test.exs new file mode 100644 index 000000000..1962bc3ad --- /dev/null +++ b/apps/domain/test/domain/network_test.exs @@ -0,0 +1,45 @@ +defmodule Domain.NetworkTest do + use Domain.DataCase, async: true + alias Domain.AccountsFixtures + import Domain.Network + + describe "fetch_next_available_address!/2" do + setup do + account = AccountsFixtures.create_account() + %{account: account} + end + + test "raises when called outside of transaction", %{account: account} do + message = "fetch_next_available_address/1 must be called inside a transaction" + + assert_raise RuntimeError, message, fn -> + fetch_next_available_address!(account, :ipv4) + end + end + + test "raises when CIDR range is exhausted", %{account: account} do + cidrs = %{ + test: %Postgrex.INET{address: {101, 64, 0, 0}, netmask: 32} + } + + Repo.transaction(fn -> + assert_raise Ecto.NoResultsError, fn -> + fetch_next_available_address!(account, :test, cidrs: cidrs) + end + end) + end + + test "returns next available IPv4 address", %{account: account} do + cidrs = %{ + test: %Postgrex.INET{address: {102, 64, 0, 0}, netmask: 30} + } + + Repo.transaction(fn -> + assert %Postgrex.INET{address: {102, 64, 0, last}, netmask: nil} = + fetch_next_available_address!(account, :test, cidrs: cidrs) + + assert last in 1..2 + end) + end + end +end diff --git a/apps/domain/test/domain/relays_test.exs b/apps/domain/test/domain/relays_test.exs new file mode 100644 index 000000000..8fd6652e5 --- /dev/null +++ b/apps/domain/test/domain/relays_test.exs @@ -0,0 +1,543 @@ +defmodule Domain.RelaysTest do + use Domain.DataCase, async: true + import Domain.Relays + alias Domain.{AccountsFixtures, UsersFixtures, SubjectFixtures, RelaysFixtures} + alias Domain.Relays + + setup do + account = AccountsFixtures.create_account() + user = UsersFixtures.create_user_with_role(:admin, account: account) + subject = SubjectFixtures.create_subject(user) + + %{ + account: account, + user: user, + subject: subject + } + end + + describe "fetch_group_by_id/2" do + test "returns error when UUID is invalid", %{subject: subject} do + assert fetch_group_by_id("foo", subject) == {:error, :not_found} + end + + test "does not return groups from other accounts", %{ + subject: subject + } do + group = RelaysFixtures.create_group() + assert fetch_group_by_id(group.id, subject) == {:error, :not_found} + end + + test "does not return deleted groups", %{ + account: account, + subject: subject + } do + group = + RelaysFixtures.create_group(account: account) + |> RelaysFixtures.delete_group() + + assert fetch_group_by_id(group.id, subject) == {:error, :not_found} + end + + test "returns group by id", %{account: account, subject: subject} do + group = RelaysFixtures.create_group(account: account) + assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject) + assert fetched_group.id == group.id + end + + test "returns group that belongs to another user", %{ + account: account, + subject: subject + } do + group = RelaysFixtures.create_group(account: account) + assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject) + assert fetched_group.id == group.id + end + + test "returns error when group does not exist", %{subject: subject} do + assert fetch_group_by_id(Ecto.UUID.generate(), subject) == + {:error, :not_found} + end + + test "returns error when subject has no permission to view groups", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert fetch_group_by_id(Ecto.UUID.generate(), subject) == + {:error, + {:unauthorized, + [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + end + end + + describe "list_groups/1" do + test "returns empty list when there are no groups", %{subject: subject} do + assert list_groups(subject) == {:ok, []} + end + + test "does not list groups from other accounts", %{ + subject: subject + } do + RelaysFixtures.create_group() + assert list_groups(subject) == {:ok, []} + end + + test "does not list deleted groups", %{ + account: account, + subject: subject + } do + RelaysFixtures.create_group(account: account) + |> RelaysFixtures.delete_group() + + assert list_groups(subject) == {:ok, []} + end + + test "returns all groups", %{ + account: account, + subject: subject + } do + RelaysFixtures.create_group(account: account) + RelaysFixtures.create_group(account: account) + RelaysFixtures.create_group() + + assert {:ok, groups} = list_groups(subject) + assert length(groups) == 2 + end + + test "returns error when subject has no permission to manage groups", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert list_groups(subject) == + {:error, + {:unauthorized, + [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + end + end + + describe "new_group/0" do + test "returns group changeset" do + assert %Ecto.Changeset{data: %Relays.Group{}, changes: changes} = new_group() + assert Map.has_key?(changes, :name) + assert Enum.count(changes) == 1 + end + end + + describe "create_group/2" do + test "returns error on empty attrs", %{subject: subject} do + assert {:error, changeset} = create_group(%{}, subject) + assert errors_on(changeset) == %{tokens: ["can't be blank"]} + end + + test "returns error on invalid attrs", %{account: account, subject: subject} do + attrs = %{ + name: String.duplicate("A", 65) + } + + assert {:error, changeset} = create_group(attrs, subject) + + assert errors_on(changeset) == %{ + tokens: ["can't be blank"], + name: ["should be at most 64 character(s)"] + } + + RelaysFixtures.create_group(account: account, name: "foo") + attrs = %{name: "foo", tokens: [%{}]} + assert {:error, changeset} = create_group(attrs, subject) + assert "has already been taken" in errors_on(changeset).name + end + + test "creates a group", %{subject: subject} do + attrs = %{ + name: "foo", + tokens: [%{}] + } + + assert {:ok, group} = create_group(attrs, subject) + assert group.id + assert group.name == "foo" + assert [%Relays.Token{}] = group.tokens + end + + test "returns error when subject has no permission to manage groups", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert create_group(%{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + end + end + + describe "change_group/1" do + test "returns changeset with given changes" do + group = RelaysFixtures.create_group() + + group_attrs = + RelaysFixtures.group_attrs() + |> Map.delete(:tokens) + + assert changeset = change_group(group, group_attrs) + assert changeset.valid? + assert changeset.changes == %{name: group_attrs.name} + end + end + + describe "update_group/3" do + test "does not allow to reset required fields to empty values", %{ + subject: subject + } do + group = RelaysFixtures.create_group() + attrs = %{name: nil} + + assert {:error, changeset} = update_group(group, attrs, subject) + + assert errors_on(changeset) == %{name: ["can't be blank"]} + end + + test "returns error on invalid attrs", %{account: account, subject: subject} do + group = RelaysFixtures.create_group(account: account) + + attrs = %{ + name: String.duplicate("A", 65) + } + + assert {:error, changeset} = update_group(group, attrs, subject) + + assert errors_on(changeset) == %{ + name: ["should be at most 64 character(s)"] + } + + RelaysFixtures.create_group(account: account, name: "foo") + attrs = %{name: "foo"} + assert {:error, changeset} = update_group(group, attrs, subject) + assert "has already been taken" in errors_on(changeset).name + end + + test "updates a group", %{account: account, subject: subject} do + group = RelaysFixtures.create_group(account: account) + + attrs = %{ + name: "foo" + } + + assert {:ok, group} = update_group(group, attrs, subject) + assert group.name == "foo" + end + + test "returns error when subject has no permission to manage groups", %{ + account: account, + subject: subject + } do + group = RelaysFixtures.create_group(account: account) + + subject = SubjectFixtures.remove_permissions(subject) + + assert update_group(group, %{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + end + end + + describe "delete_group/2" do + test "returns error on state conflict", %{account: account, subject: subject} do + group = RelaysFixtures.create_group(account: account) + + assert {:ok, deleted} = delete_group(group, subject) + assert delete_group(deleted, subject) == {:error, :not_found} + assert delete_group(group, subject) == {:error, :not_found} + end + + test "deletes groups", %{account: account, subject: subject} do + group = RelaysFixtures.create_group(account: account) + + assert {:ok, deleted} = delete_group(group, subject) + assert deleted.deleted_at + end + + test "deletes all tokens when group is deleted", %{account: account, subject: subject} do + group = RelaysFixtures.create_group(account: account) + RelaysFixtures.create_token(group: group) + RelaysFixtures.create_token(group: [account: account]) + + assert {:ok, deleted} = delete_group(group, subject) + assert deleted.deleted_at + + tokens = + Relays.Token + |> Repo.all() + |> Enum.filter(fn token -> token.group_id == group.id end) + + assert Enum.all?(tokens, & &1.deleted_at) + end + + test "returns error when subject has no permission to delete groups", %{ + subject: subject + } do + group = RelaysFixtures.create_group() + + subject = SubjectFixtures.remove_permissions(subject) + + assert delete_group(group, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + end + end + + describe "use_token_by_id_and_secret/2" do + test "returns token when secret is valid" do + token = RelaysFixtures.create_token() + assert {:ok, token} = use_token_by_id_and_secret(token.id, token.value) + assert is_nil(token.value) + # TODO: While we don't have token rotation implemented, the tokens are all multi-use + # assert is_nil(token.hash) + # refute is_nil(token.deleted_at) + end + + # TODO: While we don't have token rotation implemented, the tokens are all multi-use + # test "returns error when secret was already used" do + # token = RelaysFixtures.create_token() + + # assert {:ok, _token} = use_token_by_id_and_secret(token.id, token.value) + # assert use_token_by_id_and_secret(token.id, token.value) == {:error, :not_found} + # end + + test "returns error when id is invalid" do + assert use_token_by_id_and_secret("foo", "bar") == {:error, :not_found} + end + + test "returns error when id is not found" do + assert use_token_by_id_and_secret(Ecto.UUID.generate(), "bar") == {:error, :not_found} + end + + test "returns error when secret is invalid" do + token = RelaysFixtures.create_token() + assert use_token_by_id_and_secret(token.id, "bar") == {:error, :not_found} + end + end + + describe "fetch_relay_by_id/2" do + test "returns error when UUID is invalid", %{subject: subject} do + assert fetch_relay_by_id("foo", subject) == {:error, :not_found} + end + + test "does not return relays from other accounts", %{ + subject: subject + } do + relay = RelaysFixtures.create_relay() + assert fetch_relay_by_id(relay.id, subject) == {:error, :not_found} + end + + test "does not return deleted relays", %{ + account: account, + subject: subject + } do + relay = + RelaysFixtures.create_relay(account: account) + |> RelaysFixtures.delete_relay() + + assert fetch_relay_by_id(relay.id, subject) == {:error, :not_found} + end + + test "returns relay by id", %{account: account, subject: subject} do + relay = RelaysFixtures.create_relay(account: account) + assert fetch_relay_by_id(relay.id, subject) == {:ok, relay} + end + + test "returns relay that belongs to another user", %{ + account: account, + subject: subject + } do + relay = RelaysFixtures.create_relay(account: account) + assert fetch_relay_by_id(relay.id, subject) == {:ok, relay} + end + + test "returns error when relay does not exist", %{subject: subject} do + assert fetch_relay_by_id(Ecto.UUID.generate(), subject) == + {:error, :not_found} + end + + test "returns error when subject has no permission to view relays", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert fetch_relay_by_id(Ecto.UUID.generate(), subject) == + {:error, + {:unauthorized, + [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + end + end + + describe "list_relays/1" do + test "returns empty list when there are no relays", %{subject: subject} do + assert list_relays(subject) == {:ok, []} + end + + test "does not list deleted relays", %{ + subject: subject + } do + RelaysFixtures.create_relay() + |> RelaysFixtures.delete_relay() + + assert list_relays(subject) == {:ok, []} + end + + test "returns all relays", %{ + account: account, + subject: subject + } do + RelaysFixtures.create_relay(account: account) + RelaysFixtures.create_relay(account: account) + RelaysFixtures.create_relay() + + assert {:ok, relays} = list_relays(subject) + assert length(relays) == 2 + end + + test "returns error when subject has no permission to manage relays", %{ + subject: subject + } do + subject = SubjectFixtures.remove_permissions(subject) + + assert list_relays(subject) == + {:error, + {:unauthorized, + [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + end + end + + describe "upsert_relay/3" do + setup context do + token = RelaysFixtures.create_token(account: context.account) + + context + |> Map.put(:token, token) + |> Map.put(:group, token.group) + end + + test "returns errors on invalid attrs", %{ + token: token + } do + attrs = %{ + ipv4: "1.1.1.256", + ipv6: "fd01::10000", + last_seen_user_agent: "foo", + last_seen_remote_ip: {256, 0, 0, 0} + } + + assert {:error, changeset} = upsert_relay(token, attrs) + + assert errors_on(changeset) == %{ + ipv4: ["one of these fields must be present: ipv4, ipv6", "is invalid"], + ipv6: ["one of these fields must be present: ipv4, ipv6", "is invalid"], + last_seen_user_agent: ["is invalid"] + } + end + + test "allows creating relay with just required attributes", %{ + token: token + } do + attrs = + RelaysFixtures.relay_attrs() + |> Map.delete(:name) + + assert {:ok, relay} = upsert_relay(token, attrs) + + assert relay.token_id == token.id + assert relay.group_id == token.group_id + + assert relay.ipv4.address == attrs.ipv4 + assert relay.ipv6.address == attrs.ipv6 + + assert relay.last_seen_remote_ip.address == attrs.last_seen_remote_ip + assert relay.last_seen_user_agent == attrs.last_seen_user_agent + assert relay.last_seen_version == "0.7.412" + assert relay.last_seen_at + + assert Repo.aggregate(Domain.Network.Address, :count) == 0 + end + + test "allows creating ipv6-only relays", %{ + token: token + } do + attrs = + RelaysFixtures.relay_attrs() + |> Map.drop([:name, :ipv4]) + + assert {:ok, _relay} = upsert_relay(token, attrs) + assert {:ok, _relay} = upsert_relay(token, attrs) + + assert Repo.one(Relays.Relay) + end + + test "updates relay when it already exists", %{ + token: token + } do + relay = RelaysFixtures.create_relay(token: token) + + attrs = + RelaysFixtures.relay_attrs( + ipv4: relay.ipv4, + last_seen_remote_ip: relay.ipv4, + last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411" + ) + + assert {:ok, updated_relay} = upsert_relay(token, attrs) + + assert Repo.aggregate(Relays.Relay, :count, :id) == 1 + + assert updated_relay.last_seen_remote_ip.address == attrs.last_seen_remote_ip.address + assert updated_relay.last_seen_user_agent == attrs.last_seen_user_agent + assert updated_relay.last_seen_user_agent != relay.last_seen_user_agent + assert updated_relay.last_seen_version == "0.7.411" + assert updated_relay.last_seen_at + assert updated_relay.last_seen_at != relay.last_seen_at + + assert updated_relay.token_id == token.id + assert updated_relay.group_id == token.group_id + + assert updated_relay.ipv4 == relay.ipv4 + assert updated_relay.ipv6.address == attrs.ipv6 + assert updated_relay.ipv6 != relay.ipv6 + + assert Repo.aggregate(Domain.Network.Address, :count) == 0 + end + end + + describe "delete_relay/2" do + test "returns error on state conflict", %{account: account, subject: subject} do + relay = RelaysFixtures.create_relay(account: account) + + assert {:ok, deleted} = delete_relay(relay, subject) + assert delete_relay(deleted, subject) == {:error, :not_found} + assert delete_relay(relay, subject) == {:error, :not_found} + end + + test "deletes relays", %{account: account, subject: subject} do + relay = RelaysFixtures.create_relay(account: account) + + assert {:ok, deleted} = delete_relay(relay, subject) + assert deleted.deleted_at + end + + test "returns error when subject has no permission to delete relays", %{ + subject: subject + } do + relay = RelaysFixtures.create_relay() + + subject = SubjectFixtures.remove_permissions(subject) + + assert delete_relay(relay, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + end + end +end diff --git a/apps/domain/test/domain/users_test.exs b/apps/domain/test/domain/users_test.exs index 2f655966d..862f5db97 100644 --- a/apps/domain/test/domain/users_test.exs +++ b/apps/domain/test/domain/users_test.exs @@ -312,19 +312,27 @@ defmodule Domain.UsersTest do describe "create_user/3" do setup do subject = SubjectFixtures.create_subject() - %{subject: subject} + %{subject: subject, account: subject.account} end - test "returns changeset error when required attrs are missing", %{subject: subject} do - assert {:error, changeset} = create_user(:unprivileged, %{}, subject) + test "returns changeset error when required attrs are missing", %{ + subject: subject, + account: account + } do + assert {:error, changeset} = create_user(account, :unprivileged, %{}, subject) refute changeset.valid? assert errors_on(changeset) == %{email: ["can't be blank"]} end - test "returns error on invalid attrs", %{subject: subject} do + test "returns error on invalid attrs", %{subject: subject, account: account} do assert {:error, changeset} = - create_user(:unprivileged, %{email: "invalid_email", password: "short"}, subject) + create_user( + account, + :unprivileged, + %{email: "invalid_email", password: "short"}, + subject + ) refute changeset.valid? @@ -336,6 +344,7 @@ defmodule Domain.UsersTest do assert {:error, changeset} = create_user( + account, :unprivileged, %{email: "invalid_email", password: String.duplicate("A", 65)}, subject @@ -345,16 +354,20 @@ defmodule Domain.UsersTest do assert "should be at most 64 character(s)" in errors_on(changeset).password assert {:error, changeset} = - create_user(:unprivileged, %{email: String.duplicate(" ", 18)}, subject) + create_user(account, :unprivileged, %{email: String.duplicate(" ", 18)}, subject) refute changeset.valid? assert "can't be blank" in errors_on(changeset).email end - test "requires password confirmation to match the password", %{subject: subject} do + test "requires password confirmation to match the password", %{ + subject: subject, + account: account + } do assert {:error, changeset} = create_user( + account, :unprivileged, %{password: "foo", password_confirmation: "bar"}, subject @@ -364,6 +377,7 @@ defmodule Domain.UsersTest do assert {:error, changeset} = create_user( + account, :unprivileged, %{ password: "password1234", @@ -375,33 +389,33 @@ defmodule Domain.UsersTest do refute Map.has_key?(errors_on(changeset), :password_confirmation) end - test "returns error when email is already taken", %{subject: subject} do + test "returns error when email is already taken", %{subject: subject, account: account} do attrs = UsersFixtures.user_attrs() - assert {:ok, _user} = create_user(:unprivileged, attrs, subject) - assert {:error, changeset} = create_user(:unprivileged, attrs, subject) + assert {:ok, _user} = create_user(account, :unprivileged, attrs, subject) + assert {:error, changeset} = create_user(account, :unprivileged, attrs, subject) refute changeset.valid? assert "has already been taken" in errors_on(changeset).email end - test "returns error when role is invalid", %{subject: subject} do + test "returns error when role is invalid", %{subject: subject, account: account} do attrs = UsersFixtures.user_attrs() assert_raise Ecto.ChangeError, fn -> - create_user(:foo, attrs, subject) + create_user(account, :foo, attrs, subject) end end - test "creates a user in given role", %{subject: subject} do + test "creates a user in given role", %{subject: subject, account: account} do for role <- [:admin, :unprivileged] do attrs = UsersFixtures.user_attrs() - assert {:ok, user} = create_user(role, attrs, subject) + assert {:ok, user} = create_user(account, role, attrs, subject) assert user.role == role end end - test "creates an unprivileged user", %{subject: subject} do + test "creates an unprivileged user", %{subject: subject, account: account} do attrs = UsersFixtures.user_attrs() - assert {:ok, user} = create_user(:unprivileged, attrs, subject) + assert {:ok, user} = create_user(account, :unprivileged, attrs, subject) assert user.role == :unprivileged assert user.email == attrs.email @@ -416,31 +430,31 @@ defmodule Domain.UsersTest do assert is_nil(user.sign_in_token_created_at) end - test "allows creating a user without password", %{subject: subject} do + test "allows creating a user without password", %{subject: subject, account: account} do email = UsersFixtures.user_attrs().email attrs = %{email: email, password: nil, password_confirmation: nil} - assert {:ok, user} = create_user(:unprivileged, attrs, subject) + assert {:ok, user} = create_user(account, :unprivileged, attrs, subject) assert is_nil(user.password_hash) email = UsersFixtures.user_attrs().email attrs = %{email: email, password: "", password_confirmation: ""} - assert {:ok, user} = create_user(:unprivileged, attrs, subject) + assert {:ok, user} = create_user(account, :unprivileged, attrs, subject) assert is_nil(user.password_hash) end - test "trims email", %{subject: subject} do + test "trims email", %{subject: subject, account: account} do attrs = UsersFixtures.user_attrs() updated_attrs = Map.put(attrs, :email, " #{attrs.email} ") - assert {:ok, user} = create_user(:unprivileged, updated_attrs, subject) + assert {:ok, user} = create_user(account, :unprivileged, updated_attrs, subject) assert user.email == attrs.email end - test "returns error when subject can not create users", %{subject: subject} do + test "returns error when subject can not create users", %{subject: subject, account: account} do subject = SubjectFixtures.remove_permissions(subject) - assert create_user(:foo, %{}, subject) == + assert create_user(account, :foo, %{}, subject) == {:error, {:unauthorized, [missing_permissions: [Users.Authorizer.manage_users_permission()]]}} diff --git a/apps/domain/test/support/fixtures/accounts_fixtures.ex b/apps/domain/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 000000000..c43e189b7 --- /dev/null +++ b/apps/domain/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,19 @@ +defmodule Domain.AccountsFixtures do + alias Domain.Accounts + + def account_attrs(attrs \\ %{}) do + Enum.into(attrs, %{ + name: "acc-#{counter()}" + }) + end + + def create_account(attrs \\ %{}) do + attrs = account_attrs(attrs) + {:ok, account} = Accounts.create_account(attrs) + account + end + + defp counter do + System.unique_integer([:positive]) + end +end diff --git a/apps/domain/test/support/fixtures/clients_fixtures.ex b/apps/domain/test/support/fixtures/clients_fixtures.ex new file mode 100644 index 000000000..eafb60f5e --- /dev/null +++ b/apps/domain/test/support/fixtures/clients_fixtures.ex @@ -0,0 +1,55 @@ +defmodule Domain.ClientsFixtures do + alias Domain.Repo + alias Domain.Clients + alias Domain.{AccountsFixtures, UsersFixtures, SubjectFixtures} + + def client_attrs(attrs \\ %{}) do + Enum.into(attrs, %{ + external_id: Ecto.UUID.generate(), + name: "client-#{counter()}", + preshared_key: Domain.Crypto.psk(), + public_key: public_key() + }) + end + + def create_client(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + {account, _attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + {user, attrs} = + Map.pop_lazy(attrs, :user, fn -> + UsersFixtures.create_user_with_role(:unprivileged, account: account) + end) + + {subject, attrs} = + Map.pop_lazy(attrs, :subject, fn -> + SubjectFixtures.create_subject(user) + end) + + attrs = client_attrs(attrs) + + {:ok, client} = Clients.upsert_client(attrs, subject) + client + end + + def delete_client(client) do + client = Repo.preload(client, :account) + admin = UsersFixtures.create_user_with_role(:admin, account: client.account) + subject = SubjectFixtures.create_subject(admin) + {:ok, client} = Clients.delete_client(client, subject) + client + end + + def public_key do + :crypto.strong_rand_bytes(32) + |> Base.encode64() + end + + defp counter do + System.unique_integer([:positive]) + end +end diff --git a/apps/domain/test/support/fixtures/devices_fixtures.ex b/apps/domain/test/support/fixtures/devices_fixtures.ex index 5e23bb32b..e01553a6f 100644 --- a/apps/domain/test/support/fixtures/devices_fixtures.ex +++ b/apps/domain/test/support/fixtures/devices_fixtures.ex @@ -1,8 +1,4 @@ defmodule Domain.DevicesFixtures do - @moduledoc """ - This module defines test helpers for creating - entities via the `Domain.Devices` context. - """ alias Domain.Devices alias Domain.UsersFixtures alias Domain.SubjectFixtures diff --git a/apps/domain/test/support/fixtures/gateways_fixtures.ex b/apps/domain/test/support/fixtures/gateways_fixtures.ex new file mode 100644 index 000000000..51e6b7e23 --- /dev/null +++ b/apps/domain/test/support/fixtures/gateways_fixtures.ex @@ -0,0 +1,122 @@ +defmodule Domain.GatewaysFixtures do + alias Domain.AccountsFixtures + alias Domain.Repo + alias Domain.Gateways + alias Domain.{AccountsFixtures, UsersFixtures, SubjectFixtures} + + def group_attrs(attrs \\ %{}) do + Enum.into(attrs, %{ + name_prefix: "group-#{counter()}", + tags: ["aws", "aws-us-east-#{counter()}"], + tokens: [%{}] + }) + end + + def create_group(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + {subject, attrs} = + Map.pop_lazy(attrs, :subject, fn -> + UsersFixtures.create_user_with_role(:admin, account: account) + |> SubjectFixtures.create_subject() + end) + + attrs = group_attrs(attrs) + + {:ok, group} = Gateways.create_group(attrs, subject) + group + end + + def delete_group(group) do + group = Repo.preload(group, :account) + admin = UsersFixtures.create_user_with_role(:admin, account: group.account) + subject = SubjectFixtures.create_subject(admin) + {:ok, group} = Gateways.delete_group(group, subject) + group + end + + def create_token(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + group = + case Map.pop(attrs, :group, %{}) do + {%Gateways.Group{} = group, _attrs} -> + group + + {group_attrs, _attrs} -> + group_attrs = Enum.into(group_attrs, %{account: account}) + create_group(group_attrs) + end + + Gateways.Token.Changeset.create_changeset(account) + |> Ecto.Changeset.put_change(:group_id, group.id) + |> Repo.insert!() + end + + def gateway_attrs(attrs \\ %{}) do + Enum.into(attrs, %{ + external_id: Ecto.UUID.generate(), + name_suffix: "gw-#{Domain.Crypto.rand_string(5)}", + public_key: public_key(), + last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412", + last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 73, 153}} + }) + end + + def create_gateway(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + {group, attrs} = + case Map.pop(attrs, :group, %{}) do + {%Gateways.Group{} = group, attrs} -> + {group, attrs} + + {group_attrs, attrs} -> + group_attrs = Enum.into(group_attrs, %{account: account}) + group = create_group(group_attrs) + {group, attrs} + end + + {token, attrs} = + Map.pop_lazy(attrs, :token, fn -> + hd(group.tokens) + end) + + attrs = gateway_attrs(attrs) + + {:ok, gateway} = Gateways.upsert_gateway(token, attrs) + gateway + end + + def delete_gateway(gateway) do + gateway = Repo.preload(gateway, :account) + admin = UsersFixtures.create_user_with_role(:admin, account: gateway.account) + subject = SubjectFixtures.create_subject(admin) + {:ok, gateway} = Gateways.delete_gateway(gateway, subject) + gateway + end + + def public_key do + :crypto.strong_rand_bytes(32) + |> Base.encode64() + end + + defp counter do + System.unique_integer([:positive]) + end +end diff --git a/apps/domain/test/support/fixtures/network_fixtures.ex b/apps/domain/test/support/fixtures/network_fixtures.ex new file mode 100644 index 000000000..2537885ea --- /dev/null +++ b/apps/domain/test/support/fixtures/network_fixtures.ex @@ -0,0 +1,27 @@ +defmodule Domain.NetworkFixtures do + alias Domain.Repo + alias Domain.Network + alias Domain.AccountsFixtures + + def address_attrs(attrs \\ %{}) do + attrs = Enum.into(attrs, %{account_id: nil, address: nil, type: nil}) + + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + {:ok, inet} = Domain.Types.INET.cast(attrs.address) + type = type(inet.address) + %{attrs | address: inet, type: type, account_id: account.id} + end + + defp type(tuple) when tuple_size(tuple) == 4, do: :ipv4 + defp type(tuple) when tuple_size(tuple) == 8, do: :ipv6 + + def create_address(attrs \\ %{}) do + %Network.Address{} + |> struct(address_attrs(attrs)) + |> Repo.insert!() + end +end diff --git a/apps/domain/test/support/fixtures/relays_fixtures.ex b/apps/domain/test/support/fixtures/relays_fixtures.ex new file mode 100644 index 000000000..bd629d890 --- /dev/null +++ b/apps/domain/test/support/fixtures/relays_fixtures.ex @@ -0,0 +1,136 @@ +defmodule Domain.RelaysFixtures do + alias Domain.Repo + alias Domain.Relays + alias Domain.{AccountsFixtures, UsersFixtures, SubjectFixtures} + + def group_attrs(attrs \\ %{}) do + Enum.into(attrs, %{ + name: "group-#{counter()}", + tokens: [%{}] + }) + end + + def create_group(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + {subject, attrs} = + Map.pop_lazy(attrs, :subject, fn -> + UsersFixtures.create_user_with_role(:admin, account: account) + |> SubjectFixtures.create_subject() + end) + + attrs = group_attrs(attrs) + + {:ok, group} = Relays.create_group(attrs, subject) + group + end + + def delete_group(group) do + group = Repo.preload(group, :account) + admin = UsersFixtures.create_user_with_role(:admin, account: group.account) + subject = SubjectFixtures.create_subject(admin) + {:ok, group} = Relays.delete_group(group, subject) + group + end + + def create_token(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + group = + case Map.pop(attrs, :group, %{}) do + {%Relays.Group{} = group, _attrs} -> + group + + {group_attrs, _attrs} -> + group_attrs = Enum.into(group_attrs, %{account: account}) + create_group(group_attrs) + end + + Relays.Token.Changeset.create_changeset(account) + |> Ecto.Changeset.put_change(:group_id, group.id) + |> Repo.insert!() + end + + def relay_attrs(attrs \\ %{}) do + ipv4 = random_ipv4() + + Enum.into(attrs, %{ + ipv4: ipv4, + ipv6: random_ipv6(), + last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412", + last_seen_remote_ip: ipv4 + }) + end + + def create_relay(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + {group, attrs} = + case Map.pop(attrs, :group, %{}) do + {%Relays.Group{} = group, attrs} -> + {group, attrs} + + {group_attrs, attrs} -> + group_attrs = Enum.into(group_attrs, %{account: account}) + group = create_group(group_attrs) + {group, attrs} + end + + {token, attrs} = + Map.pop_lazy(attrs, :token, fn -> + hd(group.tokens) + end) + + attrs = relay_attrs(attrs) + + {:ok, relay} = Relays.upsert_relay(token, attrs) + relay + end + + def delete_relay(relay) do + relay = Repo.preload(relay, :account) + admin = UsersFixtures.create_user_with_role(:admin, account: relay.account) + subject = SubjectFixtures.create_subject(admin) + {:ok, relay} = Relays.delete_relay(relay, subject) + relay + end + + def public_key do + :crypto.strong_rand_bytes(32) + |> Base.encode64() + end + + defp counter do + System.unique_integer([:positive]) + end + + defp random_ipv4 do + number = counter() + <> = <> + {a, b, c, d} + end + + defp random_ipv6 do + number = counter() + + <> = <> + + {a, b, c, d, e, f, g, h} + end +end diff --git a/apps/domain/test/support/fixtures/subject_fixtures.ex b/apps/domain/test/support/fixtures/subject_fixtures.ex index 7e91b136b..28910062f 100644 --- a/apps/domain/test/support/fixtures/subject_fixtures.ex +++ b/apps/domain/test/support/fixtures/subject_fixtures.ex @@ -1,3 +1,4 @@ +# TODO: AuthFixtures - name module after context name defmodule Domain.SubjectFixtures do alias Domain.Auth alias Domain.UsersFixtures @@ -10,7 +11,7 @@ defmodule Domain.SubjectFixtures do end def create_subject(user \\ UsersFixtures.create_user_with_role(:admin)) do - Domain.Auth.fetch_subject!(user, {127, 0, 0, 1}, "DummyAgent (1.0.0)") + Domain.Auth.fetch_subject!(user, {100, 64, 100, 58}, "iOS/12.5 (iPhone) connlib/0.7.412") end def remove_permissions(%Auth.Subject{} = subject) do diff --git a/apps/domain/test/support/fixtures/users_fixtures.ex b/apps/domain/test/support/fixtures/users_fixtures.ex index 3b2ce8677..ad295a0f9 100644 --- a/apps/domain/test/support/fixtures/users_fixtures.ex +++ b/apps/domain/test/support/fixtures/users_fixtures.ex @@ -1,7 +1,7 @@ defmodule Domain.UsersFixtures do alias Domain.Repo alias Domain.Users - alias Domain.SubjectFixtures + alias Domain.{AccountsFixtures, SubjectFixtures} def user_attrs(attrs \\ %{}) do Enum.into(attrs, %{ @@ -14,6 +14,11 @@ defmodule Domain.UsersFixtures do def create_user_with_role(role, attrs \\ %{}) do attrs = Enum.into(attrs, %{}) + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + {subject, attrs} = Map.pop_lazy(attrs, :subject, fn -> SubjectFixtures.new() @@ -24,7 +29,7 @@ defmodule Domain.UsersFixtures do attrs = user_attrs(attrs) - {:ok, user} = Users.create_user(role, attrs, subject) + {:ok, user} = Users.create_user(account, role, attrs, subject) user end diff --git a/config/config.exs b/config/config.exs index cb345f121..536329a0d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -63,6 +63,11 @@ config :domain, config :domain, max_devices_per_user: 10 +config :domain, Domain.Auth, + key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5SD", + salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX", + max_age: 30 * 60 + ############################### ##### Web ##################### ############################### @@ -129,6 +134,16 @@ config :api, cookie_signing_salt: "WjllcThpb2Y=", cookie_encryption_salt: "M0EzM0R6NEMyaw==" +config :api, API.Gateway.Socket, + key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5SD", + salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX", + max_age: 30 * 60 + +config :api, API.Relay.Socket, + key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5SD", + salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX", + max_age: 30 * 60 + ############################### ##### Third-party configs ##### ############################### diff --git a/config/runtime.exs b/config/runtime.exs index d6e8ef8be..39445a2ce 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -102,6 +102,7 @@ if config_env() == :prod do port: compile_config!(:phoenix_http_port), protocol_options: compile_config!(:phoenix_http_protocol_options) ], + # TODO: force_ssl: [rewrite_on: [:x_forwarded_proto], hsts: true], url: [ scheme: external_url_scheme, host: external_url_host, diff --git a/config/test.exs b/config/test.exs index 860960ca2..1d728b594 100644 --- a/config/test.exs +++ b/config/test.exs @@ -61,3 +61,6 @@ config :ex_unit, formatters: [JUnitFormatter, ExUnit.CLIFormatter], capture_log: true, exclude: [:acceptance] + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime