Control channels for Clients, Relays and Gateways (#1551)

This commit is contained in:
Andrew Dryga
2023-04-20 11:34:56 -07:00
committed by GitHub
parent 0740d0fdba
commit 58b8d5212f
97 changed files with 4944 additions and 366 deletions

View File

@@ -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
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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} =

View File

@@ -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
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,9 @@
defmodule Domain.Accounts.Account do
use Domain, :schema
schema "accounts" do
field :name, :string
timestamps()
end
end

View File

@@ -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

View File

@@ -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,

View File

@@ -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 =

View File

@@ -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
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ->

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.
"""

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()]]}}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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::size(8), b::size(8), c::size(8), d::size(8)>> = <<number::32>>
{a, b, c, d}
end
defp random_ipv6 do
number = counter()
<<a::size(16), b::size(16), c::size(16), d::size(16), e::size(16), f::size(16), g::size(16),
h::size(16)>> = <<number::128>>
{a, b, c, d, e, f, g, h}
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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 #####
###############################

View File

@@ -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,

View File

@@ -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