diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 4a01aca51..3ea1ebe41 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -101,12 +101,18 @@ defmodule Domain.Gateways do end end - def fetch_gateway_by_id(id, %Auth.Subject{} = subject) do + def fetch_gateway_by_id(id, %Auth.Subject{} = subject, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + 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() + |> case do + {:ok, gateway} -> {:ok, Repo.preload(gateway, preload)} + {:error, reason} -> {:error, reason} + end else false -> {:error, :not_found} other -> other @@ -121,11 +127,16 @@ defmodule Domain.Gateways do |> Repo.preload(preload) end - def list_gateways(%Auth.Subject{} = subject) do + def list_gateways(%Auth.Subject{} = subject, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do - Gateway.Query.all() - |> Authorizer.for_subject(subject) - |> Repo.list() + {:ok, gateways} = + Gateway.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + + {:ok, Repo.preload(gateways, preload)} end end diff --git a/elixir/apps/domain/lib/domain/resources.ex b/elixir/apps/domain/lib/domain/resources.ex index 144fa9782..65345b57a 100644 --- a/elixir/apps/domain/lib/domain/resources.ex +++ b/elixir/apps/domain/lib/domain/resources.ex @@ -1,5 +1,6 @@ defmodule Domain.Resources do alias Domain.{Repo, Validator, Auth} + alias Domain.Gateways alias Domain.Resources.{Authorizer, Resource} def fetch_resource_by_id(id, %Auth.Subject{} = subject) do @@ -46,6 +47,44 @@ defmodule Domain.Resources do end end + def count_resources_for_gateway(%Gateways.Gateway{} = gateway, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, + [ + Authorizer.manage_resources_permission(), + Authorizer.view_available_resources_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do + count = + Resource.Query.all() + |> Authorizer.for_subject(subject) + |> Resource.Query.by_gateway_group_id(gateway.group_id) + |> Repo.aggregate(:count) + + {:ok, count} + end + end + + def list_resources_for_gateway(%Gateways.Gateway{} = gateway, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, + [ + Authorizer.manage_resources_permission(), + Authorizer.view_available_resources_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do + resources = + Resource.Query.all() + |> Resource.Query.by_account_id(subject.account.id) + |> Resource.Query.by_gateway_group_id(gateway.group_id) + |> Repo.all() + + {:ok, resources} + end + end + def create_resource(attrs, %Auth.Subject{} = subject) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do changeset = Resource.Changeset.create_changeset(subject.account, attrs) diff --git a/elixir/apps/domain/lib/domain/resources/resource/query.ex b/elixir/apps/domain/lib/domain/resources/resource/query.ex index a6f74db8e..e1578fbf8 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/query.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/query.ex @@ -13,4 +13,23 @@ defmodule Domain.Resources.Resource.Query do def by_account_id(queryable \\ all(), account_id) do where(queryable, [resources: resources], resources.account_id == ^account_id) end + + def by_gateway_group_id(queryable \\ all(), gateway_group_id) do + queryable + |> with_joined_connections() + |> where([connections: connections], connections.gateway_group_id == ^gateway_group_id) + end + + def with_joined_connections(queryable \\ all()) do + with_named_binding(queryable, :connections, fn queryable, binding -> + queryable + |> join( + :inner, + [resources: resources], + connections in ^Domain.Resources.Connection.Query.all(), + on: connections.resource_id == resources.id, + as: ^binding + ) + end) + end end diff --git a/elixir/apps/domain/lib/domain/types/protocols.ex b/elixir/apps/domain/lib/domain/types/protocols.ex index 1805c10b2..df6c39112 100644 --- a/elixir/apps/domain/lib/domain/types/protocols.ex +++ b/elixir/apps/domain/lib/domain/types/protocols.ex @@ -2,9 +2,9 @@ defimpl String.Chars, for: Postgrex.INET do def to_string(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) end -# defimpl Phoenix.HTML.Safe, for: Postgrex.INET do -# def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) -# end +defimpl Phoenix.HTML.Safe, for: Postgrex.INET do + def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) +end defimpl Jason.Encoder, for: Postgrex.INET do def encode(%Postgrex.INET{} = struct, opts) do @@ -16,6 +16,6 @@ defimpl String.Chars, for: Domain.Types.IPPort do def to_string(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) end -# defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do -# def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) -# end +defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do + def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) +end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 5c1b4e0b4..f8183acf7 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -171,7 +171,7 @@ IO.puts("Created gateway groups:") IO.puts(" #{gateway_group.name_prefix} token: #{Gateways.encode_token!(gateway_group_token)}") IO.puts("") -{:ok, gateway} = +{:ok, gateway1} = Gateways.upsert_gateway(gateway_group_token, %{ external_id: Ecto.UUID.generate(), name_suffix: "gw-#{Domain.Crypto.rand_string(5)}", @@ -180,12 +180,28 @@ IO.puts("") last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 73, 153}} }) +{:ok, gateway2} = + Gateways.upsert_gateway(gateway_group_token, %{ + external_id: Ecto.UUID.generate(), + name_suffix: "gw-#{Domain.Crypto.rand_string(5)}", + public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(), + last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412", + last_seen_remote_ip: %Postgrex.INET{address: {164, 112, 78, 62}} + }) + IO.puts("Created gateways:") -gateway_name = "#{gateway_group.name_prefix}-#{gateway.name_suffix}" +gateway_name = "#{gateway_group.name_prefix}-#{gateway1.name_suffix}" IO.puts(" #{gateway_name}:") -IO.puts(" External UUID: #{gateway.external_id}") -IO.puts(" Public Key: #{gateway.public_key}") -IO.puts(" IPv4: #{gateway.ipv4} IPv6: #{gateway.ipv6}") +IO.puts(" External UUID: #{gateway1.external_id}") +IO.puts(" Public Key: #{gateway1.public_key}") +IO.puts(" IPv4: #{gateway1.ipv4} IPv6: #{gateway1.ipv6}") +IO.puts("") + +gateway_name = "#{gateway_group.name_prefix}-#{gateway2.name_suffix}" +IO.puts(" #{gateway_name}:") +IO.puts(" External UUID: #{gateway1.external_id}") +IO.puts(" Public Key: #{gateway2.public_key}") +IO.puts(" IPv4: #{gateway2.ipv4} IPv6: #{gateway2.ipv6}") IO.puts("") {:ok, dns_resource} = diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 02588ab13..da2294713 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -400,6 +400,15 @@ defmodule Domain.GatewaysTest do {:unauthorized, [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} end + + # TODO: add a test that soft-deleted assocs are not preloaded + test "associations are preloaded when opts given", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + {:ok, gateway} = fetch_gateway_by_id(gateway.id, subject, preload: [:group, :account]) + + assert Ecto.assoc_loaded?(gateway.group) == true + assert Ecto.assoc_loaded?(gateway.account) == true + end end describe "list_gateways/1" do @@ -438,6 +447,18 @@ defmodule Domain.GatewaysTest do {:unauthorized, [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} end + + # TODO: add a test that soft-deleted assocs are not preloaded + test "associations are preloaded when opts given", %{account: account, subject: subject} do + GatewaysFixtures.create_gateway(account: account) + GatewaysFixtures.create_gateway(account: account) + + {:ok, gateways} = list_gateways(subject, preload: [:group, :account]) + assert length(gateways) == 2 + + assert Enum.all?(gateways, fn g -> Ecto.assoc_loaded?(g.group) end) == true + assert Enum.all?(gateways, fn g -> Ecto.assoc_loaded?(g.account) end) == true + end end describe "list_connected_gateways_for_resource/1" do diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index b115944be..c1d8b4538 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -29,18 +29,15 @@ defmodule Domain.ResourcesTest do end test "returns resource when resource exists", %{account: account, subject: subject} do - gateway = GatewaysFixtures.create_gateway(account: account) - resource = ResourcesFixtures.create_resource(account: account, gateway: gateway) + resource = ResourcesFixtures.create_resource(account: account) assert {:ok, fetched_resource} = fetch_resource_by_id(resource.id, subject) assert fetched_resource.id == resource.id end test "does not return deleted resources", %{account: account, subject: subject} do - gateway = GatewaysFixtures.create_gateway(account: account) - {:ok, resource} = - ResourcesFixtures.create_resource(account: account, gateway: gateway) + ResourcesFixtures.create_resource(account: account) |> delete_resource(subject) assert fetch_resource_by_id(resource.id, subject) == {:error, :not_found} @@ -138,6 +135,172 @@ defmodule Domain.ResourcesTest do end end + describe "list_resources_for_gateway/2" do + test "returns empty list when there are no resources associated to gateway", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + + assert list_resources_for_gateway(gateway, subject) == {:ok, []} + end + + test "does not list resources that are not associated to the gateway", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + ResourcesFixtures.create_resource() + + assert list_resources_for_gateway(gateway, subject) == {:ok, []} + end + + test "does not list deleted resources associated to gateway", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + |> delete_resource(subject) + + assert list_resources_for_gateway(gateway, subject) == {:ok, []} + end + + test "returns all resources for a given gateway and account user subject", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + ResourcesFixtures.create_resource(account: account) + + assert {:ok, resources} = list_resources_for_gateway(gateway, subject) + assert length(resources) == 2 + end + + test "returns error when subject has no permission to manage resources", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + subject = AuthFixtures.remove_permissions(subject) + + assert list_resources_for_gateway(gateway, subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, + [ + Resources.Authorizer.manage_resources_permission(), + Resources.Authorizer.view_available_resources_permission() + ]} + ] + ]}} + end + end + + describe "count_resources_for_gateway/2" do + test "returns zero when there are no resources associated to gateway", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + + assert count_resources_for_gateway(gateway, subject) == {:ok, 0} + end + + test "does not count resources that are not associated to the gateway", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + ResourcesFixtures.create_resource(account: account) + + assert count_resources_for_gateway(gateway, subject) == {:ok, 1} + end + + test "does not count deleted resources associated to gateway", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + |> delete_resource(subject) + + assert count_resources_for_gateway(gateway, subject) == {:ok, 1} + end + + test "returns error when subject has no permission to manage resources", + %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + subject = AuthFixtures.remove_permissions(subject) + + assert count_resources_for_gateway(gateway, subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, + [ + Resources.Authorizer.manage_resources_permission(), + Resources.Authorizer.view_available_resources_permission() + ]} + ] + ]}} + end + end + describe "create_resource/2" do test "returns changeset error on empty attrs", %{subject: subject} do assert {:error, changeset} = create_resource(%{}, subject) diff --git a/elixir/apps/web/assets/tailwind.config.js b/elixir/apps/web/assets/tailwind.config.js index 040715e44..58bb37e23 100644 --- a/elixir/apps/web/assets/tailwind.config.js +++ b/elixir/apps/web/assets/tailwind.config.js @@ -6,6 +6,54 @@ const fs = require("fs") const path = require("path") const defaultTheme = require("tailwindcss/defaultTheme") + +const firezoneColors = { + // See our brand palette in Figma. + // These have been reversed to match Tailwind's default order. + + // primary: orange + "heat-wave": { + 50: "#fff9f5", + 100: "#fff1e5", + 200: "#ffddc2", + 300: "#ffbc85", + 400: "#ff9a47", + 450: "#ff7300", + 500: "#ff7605", + 600: "#c25700", + 700: "#7f3900", + 800: "#5c2900", + 900: "#331700", + }, + // accent: violet + "electric-violet": { + 50: "#f8f5ff", + 100: "#ece5ff", + 200: "#d2c2ff", + 300: "#a585ff", + 400: "#7847ff", + 450: "#5e00d6", + 500: "#4805ff", + 600: "#3400c2", + 700: "#37007f", + 800: "#28005c", + 900: "#160033", + }, + // neutral: night-rider + "night-rider": { + 50: "#fcfcfc", + 100: "#f8f7f7", + 200: "#ebebea", + 300: "#dfdedd", + 400: "#c7c4c2", + 500: "#a7a3a0", + 600: "#90867f", + 700: "#766a60", + 800: "#4c3e33", + 900: "#1b140e", + }, +}; + module.exports = { // Use "media" to synchronize dark mode with the OS, "class" to require manual toggle darkMode: "class", @@ -22,17 +70,21 @@ module.exports = { extend: { colors: { brand: "#FD4F00", - primary: { - "50": "#eff6ff", - "100": "#dbeafe", - "200": "#bfdbfe", - "300": "#93c5fd", - "400": "#60a5fa", - "500": "#3b82f6", - "600": "#2563eb", - "700": "#1d4ed8", - "800": "#1e40af", "900": "#1e3a8a" - } + primary: firezoneColors["heat-wave"], + accent: firezoneColors["electric-violet"], + neutral: firezoneColors["night-rider"] + //primary: { + // "50": "#eff6ff", + // "100": "#dbeafe", + // "200": "#bfdbfe", + // "300": "#93c5fd", + // "400": "#60a5fa", + // "500": "#3b82f6", + // "600": "#2563eb", + // "700": "#1d4ed8", + // "800": "#1e40af", + // "900": "#1e3a8a" + //} } }, }, diff --git a/elixir/apps/web/lib/web.ex b/elixir/apps/web/lib/web.ex index ae2046523..06d89a9ed 100644 --- a/elixir/apps/web/lib/web.ex +++ b/elixir/apps/web/lib/web.ex @@ -99,6 +99,8 @@ defmodule Web do import Phoenix.HTML # Core UI components and translation import Web.CoreComponents + import Web.FormComponents + import Web.TableComponents import Web.Gettext # Shortcut for generating JS commands diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index 4d5fe52d5..6299e005a 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -94,7 +94,10 @@ defmodule Web.CoreComponents do <%= for tab <- @tab do %>